codex-overleaf-link 2.2.1 → 2.3.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 (71) hide show
  1. package/README.md +25 -23
  2. package/extension/bootstrap/manifest.template.json +1 -1
  3. package/extension/runtime-manifest.json +5 -71
  4. package/extension/src/background.js +53 -69
  5. package/extension/src/backgroundUpdateCoordinator.js +128 -66
  6. package/extension/src/content/applyResultFormatters.js +2 -3
  7. package/extension/src/content/contentRuntime.js +650 -1449
  8. package/extension/src/content/contextTray.js +4 -6
  9. package/extension/src/content/diagnosticsController.js +1 -2
  10. package/extension/src/content/diffReviewPanel.js +14 -13
  11. package/extension/src/content/generated/content.bundle.js +41370 -0
  12. package/extension/src/content/generated/content.bundle.meta.json +102 -0
  13. package/extension/src/content/legacyGlobalRegistry.js +129 -0
  14. package/extension/src/content/markdownText.js +5 -7
  15. package/extension/src/content/modelPicker.js +1 -1
  16. package/extension/src/content/modelPickerSupport.js +10 -5
  17. package/extension/src/content/moduleRegistryKernel.js +125 -0
  18. package/extension/src/content/nativeCompatibilityController.js +313 -0
  19. package/extension/src/content/otWarmMirror.js +7 -5
  20. package/extension/src/content/pageBridgeClient.js +224 -0
  21. package/extension/src/content/panelMaintenance.js +8 -3
  22. package/extension/src/content/postNavigationSettlementPersistence.js +100 -0
  23. package/extension/src/content/projectProviderSelection.js +4 -12
  24. package/extension/src/content/projectSessionCleanup.js +3 -3
  25. package/extension/src/content/providerSettingsCoordinator.js +59 -26
  26. package/extension/src/content/providerSettingsDialog.js +30 -18
  27. package/extension/src/content/recentProjects.js +15 -23
  28. package/extension/src/content/runGuidanceController.js +1 -1
  29. package/extension/src/content/runQueueScheduler.js +129 -29
  30. package/extension/src/content/runSettlementPersistence.js +51 -0
  31. package/extension/src/content/runTimelineView.js +11 -8
  32. package/extension/src/content/scopedPersistenceCoordinator.js +100 -0
  33. package/extension/src/content/scopedPersistenceDetachedCommit.js +47 -0
  34. package/extension/src/content/scopedPersistencePanelState.js +111 -0
  35. package/extension/src/content/sessionManager.js +15 -12
  36. package/extension/src/content/sessionPersistence.js +67 -8
  37. package/extension/src/content/updateNotice.js +10 -4
  38. package/extension/src/content/writebackOrchestrator.js +91 -23
  39. package/extension/src/contentScript.js +5 -1
  40. package/extension/src/page/trackedChangesLifecycle.js +6 -1
  41. package/extension/src/pageBridge.js +47 -98
  42. package/extension/src/shared/agentTranscript.js +8 -5
  43. package/extension/src/shared/compatibility.js +1 -1
  44. package/extension/src/shared/governanceRules.js +1 -1
  45. package/extension/src/shared/managedUpdateProjection.js +337 -0
  46. package/extension/src/shared/nativeRequestIdentity.js +60 -0
  47. package/extension/src/shared/otText.js +0 -19
  48. package/extension/src/shared/pageRpcContract.js +136 -0
  49. package/extension/src/shared/runExecutionSnapshot.js +177 -0
  50. package/extension/src/shared/runExecutionSnapshotCodec.js +168 -0
  51. package/extension/src/shared/runInputQueue.js +81 -34
  52. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +79 -0
  53. package/extension/src/shared/scopedPersistenceQueuePolicy.js +181 -0
  54. package/extension/src/shared/scopedPersistenceTransaction.js +238 -0
  55. package/extension/src/shared/sessionState.js +64 -48
  56. package/extension/src/shared/settlementFacts.js +351 -0
  57. package/extension/src/shared/storageDb.js +19 -5
  58. package/extension/src/shared/storageMigration.js +275 -61
  59. package/extension/src/shared/storageSessionClaims.js +60 -0
  60. package/extension/src/shared/updateConsent.js +4 -0
  61. package/extension/src/shared/updateRevocationIntent.js +109 -0
  62. package/extension/src/shared/writebackEvidenceProjection.js +150 -0
  63. package/extension/src/shared/writebackOperationEvidence.js +143 -0
  64. package/extension/src/shared/writebackSettlement.js +705 -0
  65. package/native-host/src/codexRuntimeIdentity.js +1 -1
  66. package/native-host/src/codexSessionRunner.js +7 -13
  67. package/native-host/src/index.js +3 -69
  68. package/native-host/src/managedInstall.js +50 -8
  69. package/native-host/src/nativeTransportEnvelope.js +148 -0
  70. package/native-host/src/taskRunnerRuntime.js +3 -3
  71. package/package.json +13 -5
@@ -1,8 +1,16 @@
1
1
  (function initCodexOverleafContentRuntime(root) {
2
2
  'use strict';
3
- function init() {
3
+ function init(dependencies) {
4
4
  'use strict';
5
5
 
6
+ dependencies = dependencies || {};
7
+ const Modules = dependencies.modules;
8
+ if (!Modules || typeof Modules !== 'object') {
9
+ throw Object.assign(new Error('Codex Overleaf content runtime requires validated dependencies.'), {
10
+ code: 'content_dependency_registry_required'
11
+ });
12
+ }
13
+
6
14
  const RUNTIME_INSTALLED_FLAG = '__codexOverleafContentRuntimeInstalled';
7
15
  const RUNTIME_STATE_KEY = '__codexOverleafContentRuntimeState';
8
16
  if (root[RUNTIME_INSTALLED_FLAG]) {
@@ -24,9 +32,6 @@
24
32
  const LEGACY_STORAGE_KEY = 'codexOverleafPanelState';
25
33
  const RUN_PROJECT_SYNC_MAX_AGE_MS = 30000;
26
34
  const RUN_SNAPSHOT_ZIP_TIMEOUT_MS = 30000;
27
- const SNAPSHOT_PAGE_BRIDGE_TIMEOUT_MS = 70000;
28
- const COMPILE_PAGE_BRIDGE_TIMEOUT_MS = 75000;
29
- const CONTEXT_FILE_LIST_PAGE_BRIDGE_TIMEOUT_MS = 35000;
30
35
  const STREAM_RENDER_FLUSH_MS = 80;
31
36
  const STREAM_SAVE_DELAY_MS = 1000;
32
37
  const MAX_RUN_EVENTS = 300;
@@ -55,31 +60,28 @@
55
60
  'skills.install',
56
61
  'skills.remove'
57
62
  ]);
58
- const CANCELLABLE_PAGE_BRIDGE_METHODS = new Set([
59
- 'getProjectSnapshot',
60
- 'getProjectFileList',
61
- 'getCompileLog',
62
- 'ensureReviewing',
63
- 'ensureEditing',
64
- 'waitForSaveState',
65
- 'applyOperations',
66
- 'acceptTrackedChanges',
67
- 'rejectTrackedChanges'
68
- ]);
69
- const activePageBridgeCancellationHandlers = new Map();
63
+ const PageRpcContract = Modules.PageRpcContract;
70
64
  const PANEL_DEFAULT_WIDTH = 380;
71
65
  const PANEL_MIN_WIDTH = 340;
72
66
  const PANEL_MAX_WIDTH = 760;
73
67
  const PAGE_MIN_WIDTH = 520;
74
- const CodexOverleafCompatibility = window.CodexOverleafCompatibility;
68
+ const CodexOverleafCompatibility = Modules.Compatibility;
75
69
  const INSTALL_COMMAND = CodexOverleafCompatibility?.buildInstallCommand?.(
76
70
  undefined,
77
71
  undefined,
78
72
  getCurrentExtensionId()
79
73
  ) || 'curl -fsSL https://raw.githubusercontent.com/Ghqqqq/codex-overleaf-link/main/install.sh | bash -s -- --extension-id <chrome-extension-id>';
80
- const PAGE_BRIDGE_SCRIPT_REVISION = '2026-05-21-editor-readiness-v11';
81
- const PAGE_BRIDGE_CAPABILITY = createPageBridgeCapability();
82
- const pageBridgeReady = injectPageBridge();
74
+ const pageBridgeClient = Modules.PageBridgeClient.create({
75
+ root,
76
+ window,
77
+ document,
78
+ chromeApi: chrome,
79
+ crypto,
80
+ contract: PageRpcContract,
81
+ compatibility: CodexOverleafCompatibility,
82
+ isCancellationRequested: () => runCancellationRequested
83
+ });
84
+ pageBridgeClient.start();
83
85
  const {
84
86
  createSession,
85
87
  deleteSession,
@@ -92,43 +94,45 @@
92
94
  selectVisibleSessionsForList,
93
95
  setActiveSession,
94
96
  updateActiveSession
95
- } = window.CodexOverleafSessionState;
96
- const LineReferences = window.CodexOverleafLineReferences;
97
- const { getProjectStorageKey } = window.CodexOverleafStorageKeys;
98
- const { HIGH_RISK_TYPES, buildChangeSummaryLine, hasSkippedApplyOperations } = window.CodexOverleafSummary;
97
+ } = Modules.SessionState;
98
+ const LineReferences = Modules.LineReferences;
99
+ const { getProjectStorageKey } = Modules.StorageKeys;
100
+ const { HIGH_RISK_TYPES, buildChangeSummaryLine, hasSkippedApplyOperations } = Modules.Summary;
99
101
  const {
100
102
  buildExpectedFilesAfterOperations,
101
103
  buildSnapshotRestoreUndo,
102
104
  buildUndoCheckpoint
103
- } = window.CodexOverleafUndoOperations;
105
+ } = Modules.UndoOperations;
104
106
  const { buildHumanCompletionReport, mapAgentEventToActivity, stripEmptyHtmlCommentPlaceholders,
105
- translateRawError } = window.CodexOverleafAgentTranscript;
106
- const i18n = window.CodexOverleafI18n;
107
- const nativeChannel = window.CodexOverleafNativeChannel.create({
107
+ translateRawError } = Modules.AgentTranscript;
108
+ const i18n = Modules.I18n;
109
+ const nativeChannel = Modules.NativeChannel.create({
108
110
  chrome,
109
111
  crypto
110
112
  });
111
- const writebackController = window.CodexOverleafWritebackController;
112
- const ReviewHunks = window.CodexOverleafReviewHunks;
113
- const DiffReviewPanel = window.CodexOverleafDiffReviewPanel;
114
- const ContextTray = window.CodexOverleafContextTray;
115
- const LocalSkillsPanel = window.CodexOverleafLocalSkillsPanel;
116
- const runController = window.CodexOverleafRunController;
117
- const RunInputQueue = window.CodexOverleafRunInputQueue;
118
- const RunQueueScheduler = window.CodexOverleafRunQueueScheduler;
119
- const ActiveTurnControl = window.CodexOverleafActiveTurnControl;
120
- const PendingInputView = window.CodexOverleafPendingInputView;
121
- const mirrorHealth = window.CodexOverleafMirrorHealth;
122
- const otWarmMirrorController = window.CodexOverleafOtWarmMirrorController;
123
- const PanelRenderer = window.CodexOverleafPanelRenderer;
124
- const SessionPanel = window.CodexOverleafSessionPanel;
125
- const SettingsPanel = window.CodexOverleafSettingsPanel;
126
- const DiagnosticsPanel = window.CodexOverleafDiagnosticsPanel;
127
- const ComposerPanel = window.CodexOverleafComposerPanel;
128
- const GovernanceRules = window.CodexOverleafGovernanceRules;
129
- const SensitiveScan = window.CodexOverleafSensitiveScan;
130
- const AuditRecords = window.CodexOverleafAuditRecords;
131
- const FailureReasons = window.CodexOverleafFailureReasons;
113
+ const writebackController = Modules.WritebackController;
114
+ const ReviewHunks = Modules.ReviewHunks;
115
+ const DiffReviewPanel = Modules.DiffReviewPanel;
116
+ const ContextTray = Modules.ContextTray;
117
+ const LocalSkillsPanel = Modules.LocalSkillsPanel;
118
+ const runController = Modules.RunController;
119
+ const RunInputQueue = Modules.RunInputQueue;
120
+ const RunQueueScheduler = Modules.RunQueueScheduler;
121
+ const ActiveTurnControl = Modules.ActiveTurnControl;
122
+ const PendingInputView = Modules.PendingInputView;
123
+ const mirrorHealth = Modules.MirrorHealth;
124
+ const otWarmMirrorController = Modules.OtWarmMirrorController;
125
+ const PanelRenderer = Modules.PanelRenderer;
126
+ const SessionPanel = Modules.SessionPanel;
127
+ const SettingsPanel = Modules.SettingsPanel;
128
+ const DiagnosticsPanel = Modules.DiagnosticsPanel;
129
+ const ComposerPanel = Modules.ComposerPanel;
130
+ const GovernanceRules = Modules.GovernanceRules;
131
+ const SensitiveScan = Modules.SensitiveScan;
132
+ const AuditRecords = Modules.AuditRecords;
133
+ const FailureReasons = Modules.FailureReasons;
134
+ const RunExecutionSnapshot = Modules.RunExecutionSnapshot;
135
+ const WritebackSettlement = Modules.WritebackSettlement;
132
136
 
133
137
  // Module composition (hoisted-safe zone). These wiring blocks MUST run
134
138
  // before any controller below consumes their destructured exports by
@@ -140,7 +144,6 @@
140
144
  // `rejected`; `accepted` and `rejected` are terminal. Kept in sync with
141
145
  // sessionState.js. A run is in the tracked-change lifecycle when it carries
142
146
  // one of these values or still holds tracked-change refs.
143
- const TERMINAL_TRACKED_CHANGE_STATUS = new Set(['accepted', 'rejected']);
144
147
 
145
148
  // The UI-local in-flight lock for tracked-change accept/reject. Never written
146
149
  // to trackedChangeStatus and never persisted — it lives only on the controller.
@@ -173,9 +176,12 @@
173
176
  // runtime collaborators they need; the destructured consts keep every
174
177
  // existing call site unchanged. Function declarations hoist, so passing them
175
178
  // here is safe; mutable state is handed over as lazy getters.
176
- const markdownText = window.CodexOverleafMarkdownText.create({
179
+ const markdownText = Modules.MarkdownText.create({
180
+ LineReferences: Modules.LineReferences,
181
+ MathText: Modules.MathText,
177
182
  tx,
178
183
  callPageBridge,
184
+ getCurrentProjectId,
179
185
  getCurrentProjectReferenceFiles,
180
186
  showPluginToast,
181
187
  recordRenderingDiagnostic: detail => appendTechnicalEvent({ type: 'rendering.diagnostic', detail })
@@ -190,8 +196,8 @@
190
196
  hasUnsafeRuntimePathSegments
191
197
  } = markdownText;
192
198
 
193
- const projectSettingsCoordinator = window.CodexOverleafProjectSettingsCoordinator.create({
194
- CodexOverleafTheme: window.CodexOverleafTheme,
199
+ const projectSettingsCoordinator = Modules.ProjectSettingsCoordinator.create({
200
+ CodexOverleafTheme: Modules.Theme,
195
201
  GovernanceRules,
196
202
  SettingsPanel,
197
203
  closeContextTray: () => closeContextTray(),
@@ -248,7 +254,11 @@
248
254
  updateSkillsEntrySummary
249
255
  } = projectSettingsCoordinator;
250
256
 
251
- const otWarmMirror = window.CodexOverleafOtWarmMirror.create({
257
+ const otWarmMirror = Modules.OtWarmMirror.create({
258
+ otWarmMirrorController,
259
+ runController,
260
+ mirrorHealth,
261
+ projectFiles: Modules.ProjectFiles,
252
262
  tr,
253
263
  tx,
254
264
  closeDiagnosticsMenu,
@@ -308,7 +318,8 @@
308
318
  prepareMirrorStaleRetry
309
319
  } = otWarmMirror;
310
320
 
311
- const diagnosticsController = window.CodexOverleafDiagnosticsController.create({
321
+ const diagnosticsController = Modules.DiagnosticsController.create({
322
+ Compatibility: Modules.Compatibility,
312
323
  tr,
313
324
  tx,
314
325
  getExtensionCompatibilityMetadata,
@@ -352,7 +363,8 @@
352
363
  // recovery-action handlers, change-history read path, history & storage
353
364
  // card, aggressive-compaction notice. Instantiated BEFORE runTimelineView,
354
365
  // whose deps reference the recovery handlers.
355
- const panelMaintenance = window.CodexOverleafPanelMaintenance.create({
366
+ const panelMaintenance = Modules.PanelMaintenance.create({
367
+ StorageDb: Modules.StorageDb,
356
368
  tx,
357
369
  showPluginToast: (...args) => showPluginToast(...args),
358
370
  showPluginConfirm: (...args) => showPluginConfirm(...args),
@@ -386,7 +398,8 @@
386
398
  openStorageSettings
387
399
  } = panelMaintenance;
388
400
 
389
- const runTimelineView = window.CodexOverleafRunTimelineView.create({
401
+ const runTimelineView = Modules.RunTimelineView.create({
402
+ RunGuidanceView: Modules.RunGuidanceView,
390
403
  tr,
391
404
  tx,
392
405
  getLocale,
@@ -413,7 +426,8 @@
413
426
  showNativeSetupGuidance: () => showNativeUpdateGuidanceModal({}),
414
427
  openProjectFileForFailure,
415
428
  openStorageSettings,
416
- trackedChangeInFlight
429
+ trackedChangeInFlight,
430
+ projectRunSettlement: run => WritebackSettlement.projectRunSettlement(run)
417
431
  });
418
432
  const {
419
433
  resetAutoFollow,
@@ -435,7 +449,10 @@
435
449
  configureAcceptButton
436
450
  } = runTimelineView;
437
451
 
438
- const sessionManager = window.CodexOverleafSessionManager.create({
452
+ const sessionManager = Modules.SessionManager.create({
453
+ SessionPanel: Modules.SessionPanel,
454
+ SessionState: Modules.SessionState,
455
+ SessionMenuView: Modules.SessionMenuView,
439
456
  tr,
440
457
  showPluginConfirm,
441
458
  showPluginToast,
@@ -466,7 +483,9 @@
466
483
  updateSessionById
467
484
  } = sessionManager;
468
485
 
469
- const applyResultFormatters = window.CodexOverleafApplyResultFormatters.create({
486
+ const applyResultFormatters = Modules.ApplyResultFormatters.create({
487
+ i18n: Modules.I18n,
488
+ FailureReasons: Modules.FailureReasons,
470
489
  getLocale,
471
490
  tr,
472
491
  tx,
@@ -484,12 +503,20 @@
484
503
  formatOperationFiles
485
504
  } = applyResultFormatters;
486
505
 
506
+ const runSettlementPersistence = Modules.RunSettlementPersistence.create({
507
+ writebackSettlement: WritebackSettlement,
508
+ getCurrentRunView: () => currentRunView,
509
+ findRunRecord,
510
+ getCurrentProjectId,
511
+ saveStateSoon
512
+ });
513
+
487
514
  // v1.6.3 structural-debt phase 6: the sync-writeback orchestration
488
515
  // (applySyncChangesToOverleaf + post-write verify/mirror/compile pipeline)
489
516
  // lives in writebackOrchestrator.js. Every dep below is either a hoisted
490
517
  // function declaration, an already-initialized const above, or a lazy
491
518
  // accessor thunk — safe in the top-of-init() wiring zone.
492
- const writebackOrchestrator = window.CodexOverleafWritebackOrchestrator.create({
519
+ const writebackOrchestrator = Modules.WritebackOrchestrator.create({
493
520
  tr,
494
521
  tx,
495
522
  getLocale,
@@ -529,9 +556,12 @@
529
556
  confirmBinaryOperations,
530
557
  filterSyncChangesByOperations,
531
558
  writebackController,
559
+ writebackSettlement: WritebackSettlement,
560
+ compileAdapter: Modules.CompileAdapter,
532
561
  RUN_SNAPSHOT_ZIP_TIMEOUT_MS,
533
562
  getState: () => state,
534
- getCurrentRunView: () => currentRunView
563
+ getCurrentRunView: () => currentRunView,
564
+ onMirrorRefreshSettled: runSettlementPersistence.settleMirrorRefresh
535
565
  });
536
566
  const {
537
567
  applySyncChangesToOverleaf,
@@ -570,7 +600,8 @@
570
600
  }
571
601
  }
572
602
 
573
- const modelPicker = window.CodexOverleafModelPicker.create({
603
+ const modelPicker = Modules.ModelPicker.create({
604
+ Support: Modules.ModelPickerSupport,
574
605
  tr,
575
606
  tx,
576
607
  getLocale,
@@ -603,7 +634,7 @@
603
634
  updateModelDisplay
604
635
  } = modelPicker;
605
636
 
606
- const projectProviderSelection = window.CodexOverleafProjectProviderSelection.create({
637
+ const projectProviderSelection = Modules.ProjectProviderSelection.create({
607
638
  tx, tr, getRunningSessionIds, showToast: showPluginToast, showConfirm: showPluginConfirm,
608
639
  getState: () => state, setState: next => { state = next; }, getPanel: () => panel,
609
640
  readSelectedModel: readSelectedModelInput, readSelectedSpeed: readSelectedSpeedInput,
@@ -611,7 +642,9 @@
611
642
  normalizeState: normalizePanelState, applyStateToPanel
612
643
  });
613
644
 
614
- const providerSettingsCoordinator = window.CodexOverleafProviderSettingsCoordinator.create({
645
+ const providerSettingsCoordinator = Modules.ProviderSettingsCoordinator.create({
646
+ ProviderProfiles: Modules.ProviderProfiles,
647
+ ProviderSettingsDialog: Modules.ProviderSettingsDialog,
615
648
  tx,
616
649
  sendBackgroundNative,
617
650
  document,
@@ -658,7 +691,13 @@
658
691
  }
659
692
  });
660
693
 
661
- const recentProjects = window.CodexOverleafRecentProjects.create({
694
+ const recentProjects = Modules.RecentProjects.create({
695
+ ProjectSessionCleanup: Modules.ProjectSessionCleanup,
696
+ SessionPersistence: Modules.SessionPersistence,
697
+ SessionState: Modules.SessionState,
698
+ StorageDb: Modules.StorageDb,
699
+ StorageKeys: Modules.StorageKeys,
700
+ StorageMigration: Modules.StorageMigration,
662
701
  tr,
663
702
  tx,
664
703
  openCustomInstructionsSettings,
@@ -690,7 +729,7 @@
690
729
  const MAX_COMPOSER_ATTACHMENT_TOTAL_BYTES = 32 * 1024 * 1024;
691
730
  const MAX_COMPOSER_ATTACHMENTS = 8;
692
731
  const MAX_ATTACHMENT_PREVIEW_DATA_URL_CHARS = 768 * 1024;
693
- const composerAttachmentController = window.CodexOverleafComposerAttachments.createComposerAttachmentController({
732
+ const composerAttachmentController = Modules.ComposerAttachments.createComposerAttachmentController({
694
733
  getPanel: () => panel,
695
734
  tr,
696
735
  tx,
@@ -708,6 +747,7 @@
708
747
  reviewHunks: ReviewHunks,
709
748
  tr,
710
749
  callPageBridge,
750
+ getCurrentProjectId: () => getCurrentProjectId(),
711
751
  getRunEvents: () => currentRunView?.events,
712
752
  appendRunEvent,
713
753
  scrollLogToBottom,
@@ -736,7 +776,7 @@
736
776
  closeDiagnosticsMenu,
737
777
  closeCustomInstructionsSettings,
738
778
  closeSlashMenu,
739
- projectFiles: window.CodexOverleafProjectFiles
779
+ projectFiles: Modules.ProjectFiles
740
780
  });
741
781
  const localSkillsPanel = LocalSkillsPanel.createLocalSkillsPanelController({
742
782
  root: window,
@@ -795,6 +835,8 @@
795
835
  // (captured at the start of the in-flight save) be the last writer.
796
836
  let saveStateInFlight = false;
797
837
  let saveStateRunAfterFlight = false;
838
+ let saveStateInFlightPromise = null;
839
+ let pendingSaveStateOptions = null;
798
840
  let streamRenderTimer = null;
799
841
  let pendingStreamRenderEvents = new Map();
800
842
  let storageNoticeKeys = new Set();
@@ -806,7 +848,35 @@
806
848
  let runCancellationRequested = false;
807
849
  let runCancellationController = null;
808
850
  let pluginConfirmController = null;
809
- const updateIdleClient = root.CodexOverleafUpdateIdle?.create({
851
+ let scopedPersistenceCoordinator = null;
852
+ const nativeCompatibilityController = Modules.NativeCompatibilityController.create({
853
+ compatibility: CodexOverleafCompatibility,
854
+ nativeChannel,
855
+ gatedMethods: NATIVE_COMPATIBILITY_GATED_METHODS,
856
+ getExtensionCompatibilityMetadata,
857
+ fallbackNativeCompatibility,
858
+ getNativeCompatibilityClassification,
859
+ installCommand: INSTALL_COMMAND,
860
+ throwIfCancellationRequested: throwIfRunCancellationRequested,
861
+ getCurrentRunView: () => currentRunView,
862
+ appendRunEvent,
863
+ showPluginToast,
864
+ getPanel: () => panel,
865
+ ensurePanelOpen,
866
+ getPanelRendererInstance: () => panelRendererInstance,
867
+ setBadge: (...args) => PanelRenderer.setBadge(...args),
868
+ setDiagnosticsHealth,
869
+ getState: () => state,
870
+ localStorage: root.localStorage,
871
+ document,
872
+ navigator,
873
+ chromeApi: chrome,
874
+ onboardingTipStorageKey: ONBOARDING_TIP_STORAGE_KEY,
875
+ setTimeout: (...args) => root.setTimeout(...args),
876
+ tr,
877
+ tx
878
+ });
879
+ const updateIdleClient = Modules.UpdateIdle?.create({
810
880
  document,
811
881
  getBusyState: () => ({
812
882
  run: Boolean(currentRunView),
@@ -878,6 +948,7 @@
878
948
  });
879
949
 
880
950
  async function init() {
951
+ await refreshAccountScopeId();
881
952
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
882
953
  state = normalizePanelState(await loadStoredState(), { restoreRunningRuns: true });
883
954
  initializeRunQueueScheduler();
@@ -890,7 +961,7 @@
890
961
  .catch(() => {});
891
962
  }, 900);
892
963
  ensurePanelOpen();
893
- root.CodexOverleafUpdateNotice?.mount?.(panel?.panelEl || panel, {
964
+ Modules.UpdateNotice?.mount?.(panel?.panelEl || panel, {
894
965
  getLocale: () => state?.locale || 'en'
895
966
  });
896
967
  // v1.8.1: on a non-editor route the panel must NEVER first paint the
@@ -1554,7 +1625,7 @@
1554
1625
  }
1555
1626
 
1556
1627
  async function getRecentAuditLogsForCurrentProject(limit = 12) {
1557
- const StorageDb = window.CodexOverleafStorageDb;
1628
+ const StorageDb = Modules.StorageDb;
1558
1629
  if (!StorageDb?.getAllByIndex) {
1559
1630
  return [];
1560
1631
  }
@@ -1673,20 +1744,36 @@
1673
1744
  await pendingMirror.catch(() => {});
1674
1745
  }
1675
1746
  readPanelInputs();
1676
- // Freeze submitted run identity before the panel can change during the run.
1677
- const submittedMode = state.mode;
1678
- const submittedRequireReviewing = state.requireReviewing === true;
1679
- const submittedCustomInstructions = getCustomInstructionsForCurrentProject();
1680
- const submittedSkillLoadingSettings = getSkillLoadingSettings();
1681
- const submittedAttachments = getComposerAttachmentsForRun();
1682
- const submittedSkillInvocation = getComposerSkillInvocationForRun();
1683
-
1684
1747
  const queuedInput = options.queuedInput || null;
1685
1748
  const task = String(queuedInput?.text || state.task || '').trim();
1686
1749
  if (!task) {
1687
1750
  appendLog(tx('Enter a task first.', '请先输入任务。'));
1688
1751
  return;
1689
1752
  }
1753
+ const submittedPanelState = {
1754
+ mode: state.mode,
1755
+ providerId: state.providerId,
1756
+ model: state.model,
1757
+ reasoningEffort: state.reasoningEffort,
1758
+ speedTier: state.speedTier,
1759
+ autoRecompile: state.autoRecompile !== false,
1760
+ requireReviewing: state.requireReviewing === true,
1761
+ focusFiles: getActiveFocusFiles()
1762
+ };
1763
+ const submittedCustomInstructions = getCustomInstructionsForCurrentProject();
1764
+ const submittedSkillLoadingSettings = getSkillLoadingSettings();
1765
+ const submittedAttachments = getComposerAttachmentsForRun();
1766
+ const submittedSkillInvocation = getComposerSkillInvocationForRun();
1767
+ await providerSettingsCoordinator.ensureLoaded();
1768
+ const executionSnapshot = queuedInput
1769
+ ? RunExecutionSnapshot.resolveForExecution(
1770
+ RunExecutionSnapshot.fromQueuePayload(queuedInput.payload),
1771
+ { catalog: providerSettingsCoordinator.getCatalog() }
1772
+ )
1773
+ : captureCurrentExecutionSnapshot(submittedPanelState);
1774
+ // Freeze submitted run identity before the panel can change during the run.
1775
+ const submittedMode = executionSnapshot.mode;
1776
+ const submittedRequireReviewing = executionSnapshot.requireReviewing === true;
1690
1777
 
1691
1778
  runCancellationRequested = false;
1692
1779
  runCancellationController = new AbortController();
@@ -1694,24 +1781,26 @@
1694
1781
  currentRunView = startRunView({
1695
1782
  task,
1696
1783
  mode: submittedMode,
1697
- model: state.model,
1698
- reasoningEffort: state.reasoningEffort,
1699
- speedTier: state.speedTier,
1784
+ model: executionSnapshot.model,
1785
+ reasoningEffort: executionSnapshot.reasoningEffort,
1786
+ speedTier: executionSnapshot.speedTier,
1787
+ executionSnapshot,
1700
1788
  attachments: submittedAttachments,
1701
1789
  skillInvocation: submittedSkillInvocation,
1702
1790
  queueItemId: queuedInput?.id || ''
1703
1791
  });
1704
1792
  const runSessionId = currentRunView.sessionId;
1705
- if (queuedInput?.id) {
1706
- await runQueueScheduler?.markExecuting(runSessionId, queuedInput.id, currentRunView.recordId);
1707
- }
1708
1793
  let runAuditDraft = null;
1709
1794
  try {
1795
+ if (queuedInput?.id) {
1796
+ await runQueueScheduler?.markExecuting(runSessionId, queuedInput.id, currentRunView.recordId);
1797
+ }
1710
1798
  runAuditDraft = await awaitRunStep(createAuditDraftForRun({
1711
1799
  task,
1712
1800
  sessionId: runSessionId,
1713
1801
  mode: submittedMode,
1714
- focusFiles: getActiveFocusFiles()
1802
+ focusFiles: executionSnapshot.focusFiles,
1803
+ executionSnapshot
1715
1804
  }));
1716
1805
  announceCrossTabRunStart();
1717
1806
  // Binary attachments belong to this submitted turn. The run record has
@@ -1726,13 +1815,13 @@
1726
1815
  status: 'running',
1727
1816
  detail: {
1728
1817
  [tr('mode')]: formatModeLabel(submittedMode),
1729
- [tx('Model', '模型')]: state.model,
1730
- [tx('Reasoning effort', '推理强度')]: state.reasoningEffort,
1731
- [tx('Speed', '速度')]: state.speedTier,
1818
+ [tx('Model', '模型')]: executionSnapshot.model,
1819
+ [tx('Reasoning effort', '推理强度')]: executionSnapshot.reasoningEffort,
1820
+ [tx('Speed', '速度')]: executionSnapshot.speedTier,
1732
1821
  [tx('Track required', '要求留痕')]: submittedRequireReviewing ? tx('yes', '是') : tx('no', '否'),
1733
1822
  [tx('Skill', '技能')]: submittedSkillInvocation?.title || tr('noneValue'),
1734
1823
  [tx('Attachments', '附件')]: submittedAttachments.map(attachment => attachment.name).join(listSeparator()) || tr('noneValue'),
1735
- '@context': formatContextItems(getActiveFocusFiles())
1824
+ '@context': formatContextItems(executionSnapshot.focusFiles)
1736
1825
  }
1737
1826
  });
1738
1827
  if (submittedSkillInvocation?.id === 'skill-installer') {
@@ -1744,15 +1833,16 @@
1744
1833
  submittedCustomInstructions,
1745
1834
  submittedSkillLoadingSettings,
1746
1835
  submittedAttachments,
1747
- submittedSkillInvocation
1836
+ submittedSkillInvocation,
1837
+ executionSnapshot
1748
1838
  }));
1749
1839
  return;
1750
1840
  }
1751
1841
  appendRunEvent({
1752
1842
  // @context prioritizes these files; complete project runs may still update related files.
1753
1843
  title: tx(
1754
- `This run will prioritize: ${formatContextItems(getActiveFocusFiles())} (persists across turns; clear via +)`,
1755
- `本轮将优先处理:${formatContextItems(getActiveFocusFiles())}(跨轮保留,可在 + 中清除)`
1844
+ `This run will prioritize: ${formatContextItems(executionSnapshot.focusFiles)} (persists across turns; clear via +)`,
1845
+ `本轮将优先处理:${formatContextItems(executionSnapshot.focusFiles)}(跨轮保留,可在 + 中清除)`
1756
1846
  ),
1757
1847
  status: 'completed'
1758
1848
  });
@@ -1770,7 +1860,7 @@
1770
1860
  return;
1771
1861
  }
1772
1862
 
1773
- let focusFiles = getActiveFocusFiles();
1863
+ let focusFiles = [...executionSnapshot.focusFiles];
1774
1864
  const warmStart = await awaitRunStep(resolveWarmRunStart({ focusFiles, mode: submittedMode }));
1775
1865
  let project = warmStart.project || null;
1776
1866
  let useExistingMirror = warmStart.useExistingMirror;
@@ -1802,7 +1892,7 @@
1802
1892
  project,
1803
1893
  snapshotWarnings,
1804
1894
  focusFiles,
1805
- isUsableProjectFileContent: window.CodexOverleafProjectFiles.isUsableProjectFileContent
1895
+ isUsableProjectFileContent: Modules.ProjectFiles.isUsableProjectFileContent
1806
1896
  });
1807
1897
  let restrictToFocusFiles = runController.shouldRestrictWritebackToFocus({ focusFiles, focusedPartialSnapshot });
1808
1898
  if (snapshotWarnings.blocking.length && !warmMirrorReuse.useExistingMirror && !focusedPartialSnapshot) {
@@ -1986,7 +2076,7 @@
1986
2076
  const userChoice = await showThreadResumeFailedPrompt();
1987
2077
  if (userChoice === 'new') {
1988
2078
  updateSessionById(runSessionId, { codexThreadId: '' });
1989
- const StorageDb = window.CodexOverleafStorageDb;
2079
+ const StorageDb = Modules.StorageDb;
1990
2080
  if (StorageDb) {
1991
2081
  const record = await StorageDb.getRecord('sessions', runSessionId);
1992
2082
  if (record) {
@@ -2169,8 +2259,10 @@
2169
2259
  assistantMessage,
2170
2260
  unsupportedChanges: response.result.unsupportedChanges || [],
2171
2261
  mode: submittedMode,
2172
- requireReviewing: submittedRequireReviewing
2262
+ requireReviewing: submittedRequireReviewing,
2263
+ autoRecompile: executionSnapshot.autoRecompile
2173
2264
  });
2265
+ runSettlementPersistence.applyToCurrentRun(syncOutcome.settlement);
2174
2266
  if (runCancellationRequested) {
2175
2267
  throw createRunCancellationError();
2176
2268
  }
@@ -2213,7 +2305,11 @@
2213
2305
  // can catch early-return branches and unrecognized skip codes.
2214
2306
  const postNavigationStatus = settleRunAfterNavigation(finishedRunRecord, syncOutcome);
2215
2307
  if (postNavigationStatus) {
2216
- persistPostNavigationRunStatus(finishedRunRecord, postNavigationStatus);
2308
+ await persistPostNavigationRunStatus(
2309
+ finishedRunRecord,
2310
+ postNavigationStatus,
2311
+ syncOutcome.settlement
2312
+ );
2217
2313
  }
2218
2314
  }
2219
2315
  } catch (_settlementError) {
@@ -2242,7 +2338,7 @@
2242
2338
  const returnedThreadId = response.result?.threadId || '';
2243
2339
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2244
2340
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2245
- const StorageDb = window.CodexOverleafStorageDb;
2341
+ const StorageDb = Modules.StorageDb;
2246
2342
  if (StorageDb) {
2247
2343
  const record = await StorageDb.getRecord('sessions', runSessionId);
2248
2344
  if (record) {
@@ -2334,13 +2430,13 @@
2334
2430
  setRunning(false);
2335
2431
  nativeChannel.clearActiveRequest();
2336
2432
  stopRunElapsedTick();
2337
- currentRunView = null;
2338
- runCancellationRequested = false;
2339
- runCancellationController = null;
2340
2433
  if (isExperimentalOtEnabled()) {
2341
2434
  await resumeOtWarmMirror('run-settled');
2342
2435
  }
2343
- await saveState().catch(() => {});
2436
+ await flushQueuedSaveState().catch(() => {});
2437
+ currentRunView = null;
2438
+ runCancellationRequested = false;
2439
+ runCancellationController = null;
2344
2440
  activeTurnControl.release(settlement.requestId);
2345
2441
  if (settlement.requestId) {
2346
2442
  activeTurnControl.acknowledge(settlement.requestId).catch(() => {});
@@ -2357,7 +2453,8 @@
2357
2453
  submittedCustomInstructions,
2358
2454
  submittedSkillLoadingSettings,
2359
2455
  submittedAttachments,
2360
- submittedSkillInvocation
2456
+ submittedSkillInvocation,
2457
+ executionSnapshot
2361
2458
  }) {
2362
2459
  appendRunEvent({
2363
2460
  title: tx('Starting the Codex skill installer.', '正在启动 Codex skill installer。'),
@@ -2369,9 +2466,9 @@
2369
2466
  projectId: getCurrentProjectId(),
2370
2467
  mode: submittedMode,
2371
2468
  task,
2372
- model: state.model,
2373
- reasoningEffort: state.reasoningEffort,
2374
- speedTier: state.speedTier,
2469
+ model: executionSnapshot.model,
2470
+ reasoningEffort: executionSnapshot.reasoningEffort,
2471
+ speedTier: executionSnapshot.speedTier,
2375
2472
  session: state.session,
2376
2473
  threadId: codexThreadId || undefined,
2377
2474
  customInstructions: submittedCustomInstructions || undefined,
@@ -2463,7 +2560,7 @@
2463
2560
  const returnedThreadId = response.result?.threadId || '';
2464
2561
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2465
2562
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2466
- const StorageDb = window.CodexOverleafStorageDb;
2563
+ const StorageDb = Modules.StorageDb;
2467
2564
  if (StorageDb) {
2468
2565
  const record = await StorageDb.getRecord('sessions', runSessionId);
2469
2566
  if (record) {
@@ -2597,7 +2694,7 @@
2597
2694
  });
2598
2695
  }
2599
2696
 
2600
- const runGuidanceController = window.CodexOverleafRunGuidanceController.create({
2697
+ const runGuidanceController = Modules.RunGuidanceController.create({
2601
2698
  findRunRecord, appendRunEvent, getCurrentRunView: () => currentRunView,
2602
2699
  renderRunEvent, cssEscape, bumpUnreadIfDetached
2603
2700
  });
@@ -2612,11 +2709,10 @@
2612
2709
  readInputs: readPanelInputs,
2613
2710
  getTask: () => panel?.querySelector('[data-task]')?.value || '',
2614
2711
  hasAttachments: () => getComposerAttachmentsForRun().length > 0,
2615
- capturePayload: () => ({
2616
- mode: state.mode,
2617
- autoRecompile: state.autoRecompile !== false,
2618
- requireReviewing: state.requireReviewing,
2619
- focusFiles: getActiveFocusFiles()
2712
+ capturePayload: () => RunExecutionSnapshot.toQueuePayload(captureCurrentExecutionSnapshot()),
2713
+ prepareClaim: () => providerSettingsCoordinator.ensureLoaded(),
2714
+ resolveExecutionSnapshot: snapshot => RunExecutionSnapshot.resolveForExecution(snapshot, {
2715
+ catalog: providerSettingsCoordinator.getCatalog()
2620
2716
  }),
2621
2717
  clearTask: () => {
2622
2718
  panel.querySelector('[data-task]').value = '';
@@ -2624,15 +2720,11 @@
2624
2720
  autosizeTaskTextarea();
2625
2721
  syncComposerSendAvailability();
2626
2722
  },
2627
- applyQueuedInput: item => {
2628
- state.autoRecompile = item.payload?.autoRecompile !== false;
2629
- state = updateActiveSession(state, {
2630
- task: item.text,
2631
- mode: item.payload?.mode || state.mode,
2632
- requireReviewing: item.payload?.requireReviewing !== false,
2633
- focusFiles: item.payload?.focusFiles || []
2634
- });
2635
- applyStateToPanel();
2723
+ applyQueuedInput: async item => {
2724
+ RunExecutionSnapshot.resolveForExecution(
2725
+ RunExecutionSnapshot.fromQueuePayload(item.payload),
2726
+ { catalog: providerSettingsCoordinator.getCatalog() }
2727
+ );
2636
2728
  },
2637
2729
  run: item => runTask({ queuedInput: item }),
2638
2730
  sendSteer: params => sendBackgroundNative({ method: 'codex.steer', params }),
@@ -2643,13 +2735,33 @@
2643
2735
  removeGuidance: runGuidanceController.remove,
2644
2736
  toast: (message, status) => showPluginToast(message, { status }),
2645
2737
  tr,
2646
- save: () => saveState(),
2647
- saveSoon: () => saveStateSoon(),
2738
+ save: persistenceOptions => saveState(persistenceOptions),
2739
+ saveSoon: persistenceOptions => saveStateSoon(120, persistenceOptions),
2648
2740
  randomUUID: () => crypto.randomUUID()
2649
2741
  });
2650
2742
  runQueueScheduler = runInputCoordinator.scheduler;
2651
2743
  }
2652
2744
 
2745
+ function captureCurrentExecutionSnapshot(submitted = {}) {
2746
+ const requestedProviderId = submitted.providerId || state?.providerId || 'builtin';
2747
+ const selection = providerSettingsCoordinator.getRunSelection(requestedProviderId);
2748
+ const providerId = selection?.providerId || requestedProviderId;
2749
+ return RunExecutionSnapshot.capture({
2750
+ mode: submitted.mode ?? state?.mode,
2751
+ providerId,
2752
+ providerRevision: selection?.providerRevision,
2753
+ model: submitted.model ?? state?.model,
2754
+ reasoningEffort: submitted.reasoningEffort ?? state?.reasoningEffort,
2755
+ speedTier: submitted.speedTier ?? state?.speedTier,
2756
+ autoRecompile: submitted.autoRecompile ?? state?.autoRecompile !== false,
2757
+ requireReviewing: submitted.requireReviewing ?? state?.requireReviewing === true,
2758
+ focusFiles: submitted.focusFiles ?? getActiveFocusFiles()
2759
+ }, {
2760
+ source: 'submitted',
2761
+ requireProviderRevision: providerId !== 'builtin'
2762
+ });
2763
+ }
2764
+
2653
2765
  function queueComposerInput() {
2654
2766
  return runInputCoordinator?.queueComposerInput();
2655
2767
  }
@@ -2716,11 +2828,7 @@
2716
2828
  }
2717
2829
 
2718
2830
  function cancelActivePageBridgeRequests() {
2719
- const handlers = Array.from(activePageBridgeCancellationHandlers.values());
2720
- activePageBridgeCancellationHandlers.clear();
2721
- for (const cancel of handlers) {
2722
- cancel();
2723
- }
2831
+ pageBridgeClient.cancelActiveRequests();
2724
2832
  }
2725
2833
 
2726
2834
  // Recovery path for the case where a previous Codex run leaked the
@@ -2884,9 +2992,11 @@
2884
2992
  skillInvocation,
2885
2993
  submittedMode
2886
2994
  } = {}) {
2995
+ const executionSnapshot = currentRunView?.executionSnapshot || captureCurrentExecutionSnapshot();
2996
+ const submittedState = RunExecutionSnapshot.applyToState(state, executionSnapshot);
2887
2997
  const params = runController.buildCodexRunParams({
2888
2998
  currentProjectId: getCurrentProjectId(),
2889
- state,
2999
+ state: submittedState,
2890
3000
  task,
2891
3001
  project,
2892
3002
  useExistingMirror,
@@ -2913,17 +3023,17 @@
2913
3023
  ...params,
2914
3024
  clientRunId: currentRunView?.recordId || '',
2915
3025
  clientSessionId: currentRunView?.sessionId || '',
2916
- providerSelection: providerSettingsCoordinator.getRunSelection(state?.providerId)
3026
+ providerSelection: RunExecutionSnapshot.toProviderSelection(executionSnapshot)
2917
3027
  };
2918
3028
  }
2919
3029
 
2920
3030
  async function sendTrackedCodexRun(params) {
2921
- const compatibilityGate = await ensureNativeCompatibilityForMethod('codex.run');
3031
+ const compatibilityGate = await nativeCompatibilityController.ensureForMethod('codex.run');
2922
3032
  if (!compatibilityGate.ok) {
2923
3033
  return compatibilityGate.response;
2924
3034
  }
2925
- throwIfCancelledBeforeNativeDispatch('codex.run');
2926
- const tracked = nativeChannel.sendNativeTracked(attachNativeCompatibilityEvidence({
3035
+ throwIfRunCancellationRequested();
3036
+ const tracked = nativeChannel.sendNativeTracked(nativeCompatibilityController.attachEvidence({
2927
3037
  method: 'codex.run',
2928
3038
  params
2929
3039
  }, compatibilityGate.compatibility));
@@ -3003,19 +3113,23 @@
3003
3113
  }
3004
3114
 
3005
3115
  async function createAuditDraftForRun(input = {}) {
3006
- const StorageDb = window.CodexOverleafStorageDb;
3116
+ const StorageDb = Modules.StorageDb;
3007
3117
  if (!StorageDb || !AuditRecords) {
3008
3118
  return null;
3009
3119
  }
3120
+ const executionSnapshot = input.executionSnapshot
3121
+ || currentRunView?.executionSnapshot
3122
+ || captureCurrentExecutionSnapshot();
3010
3123
  const draft = AuditRecords.buildAuditDraftRecord({
3011
3124
  projectId: getCurrentProjectId(),
3012
3125
  sessionId: input.sessionId || '',
3013
3126
  turnId: currentRunView?.recordId || '',
3014
- mode: input.mode || state?.mode || '',
3015
- ...providerSettingsCoordinator.getProviderSnapshot(state?.providerId),
3016
- model: state?.model || '',
3017
- reasoningEffort: state?.reasoningEffort || '',
3018
- speedTier: state?.speedTier || '',
3127
+ mode: input.mode || executionSnapshot.mode,
3128
+ ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot.providerId),
3129
+ providerRevision: executionSnapshot.providerRevision,
3130
+ model: executionSnapshot.model,
3131
+ reasoningEffort: executionSnapshot.reasoningEffort,
3132
+ speedTier: executionSnapshot.speedTier,
3019
3133
  task: input.task || '',
3020
3134
  focusFiles: input.focusFiles || [],
3021
3135
  selectedSkillIds: input.selectedSkillIds || []
@@ -3037,7 +3151,7 @@
3037
3151
  }
3038
3152
 
3039
3153
  async function finalizeAuditDraftPartial(draft, updates = {}) {
3040
- const StorageDb = window.CodexOverleafStorageDb;
3154
+ const StorageDb = Modules.StorageDb;
3041
3155
  if (!StorageDb || !draft) {
3042
3156
  return draft;
3043
3157
  }
@@ -3048,7 +3162,7 @@
3048
3162
  }
3049
3163
 
3050
3164
  async function finalizeAuditRecord(draft, updates = {}) {
3051
- const StorageDb = window.CodexOverleafStorageDb;
3165
+ const StorageDb = Modules.StorageDb;
3052
3166
  if (!StorageDb || !AuditRecords || !draft) {
3053
3167
  return null;
3054
3168
  }
@@ -3843,8 +3957,8 @@
3843
3957
  }
3844
3958
 
3845
3959
  function normalizeSafeProjectPath(value) {
3846
- if (window.CodexOverleafProjectFiles?.normalizeSafeProjectPath) {
3847
- return window.CodexOverleafProjectFiles.normalizeSafeProjectPath(value);
3960
+ if (Modules.ProjectFiles?.normalizeSafeProjectPath) {
3961
+ return Modules.ProjectFiles.normalizeSafeProjectPath(value);
3848
3962
  }
3849
3963
  return String(value || '')
3850
3964
  .replace(/\s+/g, ' ')
@@ -4123,6 +4237,7 @@
4123
4237
  // `runProjectId` is that it survives navigation; `activeProjectId` does not.
4124
4238
  let activeProjectId = null;
4125
4239
  let lastSpaPathname = '';
4240
+ let spaRouteGeneration = 0;
4126
4241
 
4127
4242
  function cancelPendingWritebacks(prevProjectId) {
4128
4243
  // The codebase does not maintain a separate pending-writeback queue;
@@ -4172,6 +4287,17 @@
4172
4287
  });
4173
4288
  }
4174
4289
 
4290
+ function getScopedPersistenceCoordinator() {
4291
+ if (scopedPersistenceCoordinator) return scopedPersistenceCoordinator;
4292
+ scopedPersistenceCoordinator = Modules.ScopedPersistenceCoordinator?.createController({
4293
+ Transaction: Modules.ScopedPersistenceTransaction,
4294
+ chromeApi: chrome,
4295
+ sessionStorage: window.sessionStorage,
4296
+ getAccountScopeId: () => window.codexOverleafDeriveAccountScopeId?.() || ''
4297
+ });
4298
+ return scopedPersistenceCoordinator;
4299
+ }
4300
+
4175
4301
  async function reloadProjectRunHistory(newProjectId) {
4176
4302
  // Rebind the storage key to the new project and reload the panel state
4177
4303
  // from chrome.storage.local so the per-project session list reflects
@@ -4265,21 +4391,34 @@
4265
4391
  } catch (_error) { /* swallow */ }
4266
4392
  }
4267
4393
 
4268
- function onSpaRouteChange() {
4269
- const url = window.location;
4394
+ async function onSpaRouteChange() {
4395
+ const generation = ++spaRouteGeneration;
4396
+ const url = new URL(window.location.href);
4397
+ const pathname = url.pathname;
4398
+ const previousProjectId = activeProjectId;
4399
+ const previousAccountScopeId = cachedAccountScopeId;
4400
+ const nextProjectId = isProjectEditorRoute(url)
4401
+ ? url.pathname.match(/^\/project\/([^/?#]+)/)[1]
4402
+ : null;
4403
+ if (nextProjectId !== activeProjectId) {
4404
+ leaveActiveProject(nextProjectId);
4405
+ }
4270
4406
  // Recompute the account scope on every route change (spec §5.2 per-
4271
4407
  // project derivation: account menu is global chrome and should be
4272
4408
  // readable on per-project URLs too).
4273
- refreshAccountScopeId().catch(() => { /* fail-closed handled inside */ });
4409
+ await refreshAccountScopeId();
4410
+ if (generation !== spaRouteGeneration || pathname !== window.location.pathname) {
4411
+ return;
4412
+ }
4274
4413
  if (isProjectEditorRoute(url)) {
4275
- const newId = url.pathname.match(/^\/project\/([^/?#]+)/)[1];
4276
- if (newId !== activeProjectId) {
4277
- leaveActiveProject(newId);
4278
- enterProject(newId);
4414
+ if (
4415
+ nextProjectId !== previousProjectId
4416
+ || cachedAccountScopeId !== previousAccountScopeId
4417
+ ) {
4418
+ enterProject(nextProjectId);
4279
4419
  }
4280
4420
  renderPerProjectVariant();
4281
4421
  } else {
4282
- leaveActiveProject(null);
4283
4422
  // Variant render is async (it awaits the IndexedDB query); the hook
4284
4423
  // does not await — subsequent renders pick up the rebound state.
4285
4424
  renderRecentProjectsVariant().catch(() => { /* swallow */ });
@@ -4303,11 +4442,9 @@
4303
4442
  return;
4304
4443
  }
4305
4444
  lastSpaPathname = next;
4306
- try {
4307
- onSpaRouteChange();
4308
- } catch (_error) {
4445
+ onSpaRouteChange().catch(() => {
4309
4446
  // Route-hook failures must never crash the page.
4310
- }
4447
+ });
4311
4448
  };
4312
4449
  const wrap = name => {
4313
4450
  const original = window.history[name];
@@ -4327,103 +4464,16 @@
4327
4464
  lastSpaPathname = window.location.pathname;
4328
4465
  }
4329
4466
 
4330
- // -----------------------------------------------------------------------
4331
- // Post-navigation run settlement (spec §5.7.1 + plan Step 6).
4332
- // -----------------------------------------------------------------------
4333
- //
4334
- // When a Codex run completes after the user navigated away from
4335
- // run.runProjectId, classify the outcome and persist the run on the
4336
- // ORIGINAL project's record (NOT activeProjectId — that's the whole
4337
- // point of the T2 immutability contract).
4338
4467
  function settleRunAfterNavigation(run, runResult) {
4339
- if (activeProjectId === run.runProjectId) {
4340
- // User is still on the project — normal settlement path; the caller
4341
- // continues unchanged.
4342
- return null;
4343
- }
4344
- // Welcome-panel + write-guard:
4345
- // accept the full `syncOutcome` (not just a pre-flattened skipped list).
4346
- // The previous shape — only `{ skipped: [...] }` from
4347
- // `collectRunResultSkipped` — silently classified runs as
4348
- // `background_completed` when (a) the skipped entries' codes were
4349
- // outside a small allow-list (e.g. delete_confirmation_rejected,
4350
- // governance_blocked, binary_confirmation_rejected) or (b) the
4351
- // syncOutcome was an early-return branch with
4352
- // `hasSkippedOperations === true` but no `applied` (which flattens to
4353
- // an empty skipped list).
4354
- //
4355
- // Spec §5.7.1 classification:
4356
- // 1. Any skipped entry with failure.code IN
4357
- // { 'aborted_project_changed', 'editor_project_id_unavailable' }
4358
- // → 'abandoned_after_navigation'.
4359
- // 2. Else if syncOutcome.hasSkippedOperations === true OR any skipped
4360
- // entry exists (code-bearing or not)
4361
- // → 'needs_review_after_navigation'.
4362
- // (Spec defense-in-depth: when in doubt, claim needs_review rather
4363
- // than background_completed.)
4364
- // 3. Else if at least one applied write AND no skipped/needs_review/
4365
- // failed entries AND post-write verification was clean
4366
- // → 'background_completed'.
4367
- // 4. Else (no skipped, no applied) — true no-op run (e.g. Ask-mode
4368
- // where there was nothing to write) → 'background_completed'.
4369
- const skipped = collectRunResultSkipped(runResult);
4370
- const wasGuardAborted = skipped.some(entry => {
4371
- const code = entry && entry.result && entry.result.code;
4372
- return code === 'aborted_project_changed' || code === 'editor_project_id_unavailable';
4468
+ return WritebackSettlement.settlePostNavigation({
4469
+ run,
4470
+ runResult,
4471
+ navigation: { occurred: activeProjectId !== run.runProjectId }
4373
4472
  });
4374
- if (wasGuardAborted) {
4375
- return 'abandoned_after_navigation';
4376
- }
4377
- const hasSkippedOperations = runResult && runResult.hasSkippedOperations === true;
4378
- if (hasSkippedOperations || skipped.length > 0) {
4379
- // Rule 2: ANY skipped entry — code-bearing or not — and/or the
4380
- // outcome flag — yields needs_review_after_navigation. This is the
4381
- // defense-in-depth net for skip codes outside the recognized list
4382
- // (delete_confirmation_rejected, governance_blocked,
4383
- // binary_confirmation_rejected, etc.) and for early-return branches
4384
- // where `applied` is absent but `hasSkippedOperations` is true.
4385
- return 'needs_review_after_navigation';
4386
- }
4387
- // Rule 3 / 4: no skipped, no failed. Either at least one clean applied
4388
- // write (post-write verification implicitly clean because rule 2
4389
- // already caught write_observed_mismatch / accept_not_verified /
4390
- // undo_not_verified / tracked_changes_remain via their skipped-entry
4391
- // form), OR a true no-op run with nothing to write. Both classify as
4392
- // background_completed per spec §5.7.1 rule 4.
4393
- return 'background_completed';
4394
- }
4395
-
4396
- // Flatten the `applied.skipped` arrays from a syncOutcome into the
4397
- // shape `settleRunAfterNavigation` expects: `{ skipped: [{ result: { code } }] }`.
4398
- // Accepts both shapes:
4399
- // 1. The full syncOutcome from `applySyncChangesToOverleaf` — reads
4400
- // `syncOutcome.applied.skipped`.
4401
- // 2. A pre-flattened shape `{ skipped: [...] }` — used by tests and by
4402
- // historical call sites.
4403
- // Spec §5.7.1: settleRunAfterNavigation now consumes the full
4404
- // syncOutcome directly, but this helper remains useful for the
4405
- // applied-array path AND keeps the legacy test surface intact.
4473
+ }
4474
+
4406
4475
  function collectRunResultSkipped(syncOutcome) {
4407
- if (!syncOutcome) {
4408
- return [];
4409
- }
4410
- const skipped = [];
4411
- const applied = syncOutcome.applied;
4412
- if (applied && Array.isArray(applied.skipped)) {
4413
- for (const entry of applied.skipped) {
4414
- if (entry) {
4415
- skipped.push(entry);
4416
- }
4417
- }
4418
- }
4419
- if (Array.isArray(syncOutcome.skipped)) {
4420
- for (const entry of syncOutcome.skipped) {
4421
- if (entry) {
4422
- skipped.push(entry);
4423
- }
4424
- }
4425
- }
4426
- return skipped;
4476
+ return WritebackSettlement.collectRunResultSkipped(syncOutcome);
4427
4477
  }
4428
4478
 
4429
4479
  // Persist the post-navigation status to the run's original
@@ -4438,50 +4488,73 @@
4438
4488
  // `reloadProjectRunHistory`), so the in-memory mutation would not
4439
4489
  // reach the right record. Go directly through StorageDb to update
4440
4490
  // the original project's session record by id.
4441
- async function persistPostNavigationRunStatus(run, postNavigationStatus) {
4491
+ async function persistPostNavigationRunStatus(run, postNavigationStatus, settlementResult) {
4442
4492
  if (!run || !postNavigationStatus) {
4443
- return;
4493
+ return { ok: false, reason: 'invalid_post_navigation_settlement' };
4444
4494
  }
4445
4495
  const postNavigationStatusText = getPostNavigationRunStatusText(postNavigationStatus);
4446
- run.status = postNavigationStatus;
4447
- run.statusText = postNavigationStatusText;
4448
- run.finishedAt = run.finishedAt || new Date().toISOString();
4449
- if (currentRunView && currentRunView.runProjectId === activeProjectId) {
4496
+ Object.assign(run, WritebackSettlement.applySettlementTransition(
4497
+ run,
4498
+ WritebackSettlement.transitionPostNavigationStatus({
4499
+ status: postNavigationStatus,
4500
+ statusText: postNavigationStatusText,
4501
+ finishedAt: run.finishedAt || new Date().toISOString()
4502
+ })
4503
+ ));
4504
+ if (
4505
+ currentRunView
4506
+ && currentRunView.runProjectId === activeProjectId
4507
+ && currentRunView.runAccountScopeId === cachedAccountScopeId
4508
+ ) {
4450
4509
  // Same project still active — the run record is reachable through the
4451
4510
  // normal state and the regular save path will persist it.
4452
4511
  saveStateSoon();
4453
- return;
4512
+ return { ok: true, lane: 'active_project' };
4454
4513
  }
4455
4514
  // Different project. Update the original session's run record directly.
4456
4515
  try {
4457
- const StorageDb = window.CodexOverleafStorageDb;
4458
- if (!StorageDb) {
4459
- return;
4460
- }
4461
4516
  const sessionId = currentRunView ? currentRunView.sessionId : '';
4462
4517
  if (!sessionId) {
4463
- return;
4464
- }
4465
- const record = await StorageDb.getRecord('sessions', sessionId);
4466
- if (!record) {
4467
- return;
4518
+ throw Object.assign(new Error('Post-navigation settlement has no original session identity.'), {
4519
+ code: 'post_navigation_settlement_persistence_failed',
4520
+ reason: 'session_scope_unavailable'
4521
+ });
4468
4522
  }
4469
- const runs = Array.isArray(record.runs) ? record.runs : [];
4470
- const targetRun = runs.find(item => item && item.id === run.id);
4471
- if (!targetRun) {
4472
- return;
4523
+ const persistence = Modules.PostNavigationSettlementPersistence;
4524
+ if (!persistence || typeof persistence.persistRequired !== 'function') {
4525
+ throw Object.assign(new Error('Post-navigation settlement persistence is unavailable.'), {
4526
+ code: 'post_navigation_settlement_persistence_failed',
4527
+ reason: 'persistence_module_unavailable'
4528
+ });
4473
4529
  }
4474
- targetRun.status = postNavigationStatus;
4475
- targetRun.statusText = postNavigationStatusText;
4476
- targetRun.finishedAt = run.finishedAt;
4477
- record.lastActivityAt = new Date().toISOString();
4478
- record.updatedAt = record.lastActivityAt;
4479
- await StorageDb.putRecord('sessions', record);
4480
- } catch (_error) {
4481
- // Settlement persistence is best-effort. The original project's
4482
- // existing record still holds the run; the worst-case is that the
4483
- // dashboard badge does not show the post-navigation reclassification
4484
- // for this one run.
4530
+ return await persistence.persistRequired({
4531
+ StorageDb: Modules.StorageDb,
4532
+ PersistenceCoordinator: getScopedPersistenceCoordinator(),
4533
+ WritebackSettlement,
4534
+ projectId: currentRunView?.runProjectId || '',
4535
+ accountScopeId: currentRunView?.runAccountScopeId || '',
4536
+ sessionId,
4537
+ runId: run.id,
4538
+ status: postNavigationStatus,
4539
+ statusText: postNavigationStatusText,
4540
+ finishedAt: run.finishedAt,
4541
+ settlementResult
4542
+ });
4543
+ } catch (error) {
4544
+ const reason = String(error?.reason || error?.code || 'unknown_failure');
4545
+ run.postNavigationPersistenceFailure = {
4546
+ code: 'post_navigation_settlement_persistence_failed',
4547
+ reason,
4548
+ at: new Date().toISOString()
4549
+ };
4550
+ appendPlainLog(tx(
4551
+ `Could not persist the completed run to its original project (${reason}). Reopen that project to recover the interrupted record.`,
4552
+ `无法将已完成运行保存到原项目(${reason})。请重新打开该项目以恢复中断记录。`
4553
+ ));
4554
+ return {
4555
+ ok: false,
4556
+ reason
4557
+ };
4485
4558
  }
4486
4559
  }
4487
4560
 
@@ -4510,7 +4583,7 @@
4510
4583
  getCachedAccountScopeId: () => cachedAccountScopeId,
4511
4584
  // T5 surfaces: variant renderer + helpers. Tests drive
4512
4585
  // `renderRecentProjectsVariant` against a stubbed `panel` and
4513
- // `window.CodexOverleafStorageDb` to assert layout / row behavior.
4586
+ // `Modules.StorageDb` to assert layout / row behavior.
4514
4587
  renderRecentProjectsVariant,
4515
4588
  renderRecentProjectRow,
4516
4589
  renderStatusBadge,
@@ -5225,7 +5298,7 @@
5225
5298
  }
5226
5299
 
5227
5300
  const mirrorStatus = await getMirrorFreshness();
5228
- const mirrorHealth = window.CodexOverleafMirrorHealth.classifyMirrorHealth(mirrorStatus);
5301
+ const mirrorHealth = Modules.MirrorHealth.classifyMirrorHealth(mirrorStatus);
5229
5302
  if (!mirrorStatus?.exists || !mirrorHealth.reusable) {
5230
5303
  return { useExistingMirror: false, reason: 'mirror_not_fresh', mirrorStatus };
5231
5304
  }
@@ -5254,7 +5327,7 @@
5254
5327
  return candidates
5255
5328
  .filter(file =>
5256
5329
  typeof file.content === 'string' &&
5257
- window.CodexOverleafProjectFiles.isUsableProjectFileContent(file.content) &&
5330
+ Modules.ProjectFiles.isUsableProjectFileContent(file.content) &&
5258
5331
  file.path &&
5259
5332
  !seen.has(file.path) &&
5260
5333
  seen.add(file.path)
@@ -5363,7 +5436,7 @@
5363
5436
  blocking.push('Full project snapshot was not captured.');
5364
5437
  }
5365
5438
 
5366
- const unusable = textFiles.filter(file => !window.CodexOverleafProjectFiles.isUsableProjectFileContent(file.content));
5439
+ const unusable = textFiles.filter(file => !Modules.ProjectFiles.isUsableProjectFileContent(file.content));
5367
5440
  if (unusable.length === textFiles.length) {
5368
5441
  blocking.push('Captured file contents are empty or still loading.');
5369
5442
  } else if (unusable.length) {
@@ -5401,545 +5474,84 @@
5401
5474
  }
5402
5475
 
5403
5476
  function sendNative(payload) {
5404
- return (async () => {
5405
- const compatibilityGate = await ensureNativeCompatibilityForMethod(payload?.method);
5406
- if (!compatibilityGate.ok) {
5407
- return compatibilityGate.response;
5408
- }
5409
- throwIfCancelledBeforeNativeDispatch(payload?.method);
5410
- return nativeChannel.sendNative(attachNativeCompatibilityEvidence(payload, compatibilityGate.compatibility));
5411
- })();
5477
+ return nativeCompatibilityController.sendNative(payload);
5412
5478
  }
5413
5479
 
5414
5480
  function sendBackgroundNative(payload) {
5415
- return (async () => {
5416
- const compatibilityGate = await ensureNativeCompatibilityForMethod(payload?.method);
5417
- if (!compatibilityGate.ok) {
5418
- return compatibilityGate.response;
5419
- }
5420
- throwIfCancelledBeforeNativeDispatch(payload?.method);
5421
- return nativeChannel.sendBackgroundNative(attachNativeCompatibilityEvidence(payload, compatibilityGate.compatibility));
5422
- })();
5423
- }
5424
-
5425
- function throwIfCancelledBeforeNativeDispatch(method) {
5426
- if (NATIVE_COMPATIBILITY_GATED_METHODS.has(method)) {
5427
- throwIfRunCancellationRequested();
5428
- }
5429
- }
5430
-
5431
- async function ensureNativeCompatibilityForMethod(method) {
5432
- const params = CodexOverleafCompatibility?.buildBridgePingParams
5433
- ? CodexOverleafCompatibility.buildBridgePingParams(getExtensionCompatibilityMetadata())
5434
- : {};
5435
- const response = await nativeChannel.sendBackgroundNative({
5436
- method: 'bridge.ping',
5437
- params
5438
- });
5439
- const compatibility = CodexOverleafCompatibility?.evaluateNativeCompatibility
5440
- ? CodexOverleafCompatibility.evaluateNativeCompatibility(response, getExtensionCompatibilityMetadata())
5441
- : fallbackNativeCompatibility(response);
5442
- const allowed = !NATIVE_COMPATIBILITY_GATED_METHODS.has(method) ||
5443
- (CodexOverleafCompatibility?.isNativeMethodAllowed
5444
- ? CodexOverleafCompatibility.isNativeMethodAllowed(method, compatibility)
5445
- : compatibility.status === 'ok');
5446
- if (allowed) {
5447
- return { ok: true, compatibility };
5448
- }
5449
-
5450
- const message = formatNativeCompatibilityBlockedMessage(method, compatibility);
5451
- const error = {
5452
- code: 'native_update_required',
5453
- message,
5454
- status: compatibility.status || 'unknown_native',
5455
- classification: getNativeCompatibilityClassification(compatibility),
5456
- installCommand: compatibility.installCommand || INSTALL_COMMAND,
5457
- updateCommand: compatibility.updateCommand || compatibility.installCommand || INSTALL_COMMAND,
5458
- currentNativeVersion: compatibility.currentNativeVersion || compatibility.nativeVersion || compatibility.native?.version || '',
5459
- requiredVersion: compatibility.requiredVersion || CodexOverleafCompatibility?.BUILD_TARGET_VERSION || '',
5460
- releaseUrl: compatibility.releaseUrl || ''
5461
- };
5462
- notifyNativeCompatibilityBlocked(error, compatibility);
5463
- return {
5464
- ok: false,
5465
- compatibility,
5466
- response: {
5467
- ok: false,
5468
- error
5469
- }
5470
- };
5471
- }
5472
-
5473
- function attachNativeCompatibilityEvidence(payload = {}, compatibility) {
5474
- if (!compatibility || !NATIVE_COMPATIBILITY_GATED_METHODS.has(payload?.method)) {
5475
- return payload;
5476
- }
5477
- return {
5478
- ...payload,
5479
- params: {
5480
- ...(payload.params || {}),
5481
- nativeCompatibility: compatibility
5482
- }
5483
- };
5484
- }
5485
-
5486
- function notifyNativeCompatibilityBlocked(error, compatibility = {}) {
5487
- const message = error?.message || String(error || '');
5488
- if (currentRunView) {
5489
- appendRunEvent({
5490
- title: message,
5491
- status: 'failed'
5492
- });
5493
- showNativeUpdateGuidanceModal(compatibility);
5494
- } else {
5495
- showNativeUpdateGuidanceModal(compatibility);
5496
- showPluginToast(message, { status: 'failed', sticky: true });
5497
- }
5481
+ return nativeCompatibilityController.sendBackgroundNative(payload);
5498
5482
  }
5499
5483
 
5500
5484
  async function refreshNativeCompatibilityBadge() {
5501
- if (!panelRendererInstance?.headerEl || !CodexOverleafCompatibility?.evaluateNativeCompatibility) {
5502
- return;
5503
- }
5504
- try {
5505
- const params = CodexOverleafCompatibility.buildBridgePingParams
5506
- ? CodexOverleafCompatibility.buildBridgePingParams(getExtensionCompatibilityMetadata())
5507
- : {};
5508
- const response = await sendBackgroundNative({ method: 'bridge.ping', params });
5509
- const compatibility = CodexOverleafCompatibility.evaluateNativeCompatibility(response, getExtensionCompatibilityMetadata());
5510
- const classification = getNativeCompatibilityClassification(compatibility);
5511
- if (classification === 'compatible') {
5512
- PanelRenderer.setBadge(panelRendererInstance.headerEl, { type: 'none' });
5513
- setDiagnosticsHealth('ok');
5514
- try {
5515
- window.localStorage.setItem('codexOverleafNativeEverOk', 'true');
5516
- } catch (_storageError) { /* private mode etc.; the wizard just stays eligible */ }
5517
- return;
5518
- }
5519
- // update-available reads as attention; anything else (incompatible /
5520
- // unsupported) reads as a hard problem on the diagnostics dot.
5521
- setDiagnosticsHealth(classification === 'update-available' ? 'warn' : 'fail');
5522
- PanelRenderer.setBadge(panelRendererInstance.headerEl, {
5523
- type: 'update',
5524
- tooltip: tx('Native host update available', 'Native host 可更新'),
5525
- onClick: () => showNativeUpdateGuidanceModal(compatibility)
5526
- });
5527
- // A never-installed host does NOT throw: background resolves
5528
- // {ok:false, native_connection_failed} and classification lands here.
5529
- if (!compatibility?.native?.version) {
5530
- maybePromptFirstRunSetup();
5531
- }
5532
- } catch (_error) {
5533
- setDiagnosticsHealth('fail');
5534
- // No ping response at all means the native host is missing or not
5535
- // running — telling a never-installed user an "update" exists is
5536
- // misleading; point them at setup instead.
5537
- PanelRenderer.setBadge(panelRendererInstance.headerEl, {
5538
- type: 'update',
5539
- tooltip: tx('Native host is not responding — click for setup steps', 'Native host 未响应——点击查看安装步骤'),
5540
- onClick: () => showNativeUpdateGuidanceModal(fallbackNativeCompatibility({ ok: false }))
5541
- });
5542
- maybePromptFirstRunSetup();
5543
- }
5544
- }
5545
-
5546
- // First-run wizard (v1.7): the native host is the one hard prerequisite,
5547
- // and pre-1.7 its absence was a 9px red dot. Auto-open the install guidance
5548
- // ONCE per browser profile; the flag is set before showing so a dismissal
5549
- // is respected forever (no nag loop).
5550
- function maybePromptFirstRunSetup() {
5551
- let storage = null;
5552
- try {
5553
- storage = window.localStorage;
5554
- if (!storage || storage.getItem('codexOverleafSetupPromptShown') === 'true') {
5555
- return;
5556
- }
5557
- // A profile that ever completed a successful ping is an installed user
5558
- // having a transient outage, not a first run — never auto-modal them.
5559
- if (storage.getItem('codexOverleafNativeEverOk') === 'true') {
5560
- return;
5561
- }
5562
- storage.setItem('codexOverleafSetupPromptShown', 'true');
5563
- } catch (_storageError) {
5564
- return;
5565
- }
5566
- showNativeUpdateGuidanceModal(fallbackNativeCompatibility({ ok: false }));
5485
+ return nativeCompatibilityController.refreshBadge();
5567
5486
  }
5568
5487
 
5569
5488
  function showNativeUpdateGuidanceModal(compatibility = {}) {
5570
- if (!panel) {
5571
- ensurePanelOpen();
5572
- }
5573
- const existing = panel.querySelector('[data-native-update-guidance]');
5574
- existing?.remove();
5575
-
5576
- const native = compatibility.native || {};
5577
- const extensionVersion = compatibility.extensionVersion || CodexOverleafCompatibility?.BUILD_TARGET_VERSION || '';
5578
- const nativeVersion = compatibility.currentNativeVersion || compatibility.nativeVersion || native.version || tx('missing', '未安装');
5579
- const command = compatibility.updateCommand || compatibility.installCommand || INSTALL_COMMAND;
5580
- const releaseUrl = compatibility.releaseUrl || '';
5581
-
5582
- const overlay = document.createElement('div');
5583
- overlay.className = 'codex-plugin-confirm';
5584
- overlay.dataset.nativeUpdateGuidance = 'true';
5585
- overlay.setAttribute('role', 'dialog');
5586
- overlay.setAttribute('aria-modal', 'true');
5587
- const nativeMissing = !native.version;
5588
- overlay.setAttribute('aria-label', nativeMissing
5589
- ? tx('Set up the native host', '安装 Native host')
5590
- : tx('Native host update available', 'Native host 可更新'));
5591
-
5592
- const card = document.createElement('section');
5593
- card.className = 'codex-plugin-confirm-card';
5594
-
5595
- const title = document.createElement('div');
5596
- title.className = 'codex-plugin-confirm-title';
5597
- title.textContent = nativeMissing
5598
- ? tx('One step left: install the native host', '还差一步:安装 Native host')
5599
- : tx('Native host update available', 'Native host 可更新');
5600
-
5601
- const body = document.createElement('div');
5602
- body.className = 'codex-plugin-confirm-body';
5603
- body.textContent = [
5604
- nativeMissing
5605
- ? tx(`Extension v${extensionVersion} is ready; Codex still needs its local bridge to read and edit this project.`, `扩展 v${extensionVersion} 已就绪;Codex 还需要本地桥接程序才能读写这个项目。`)
5606
- : tx(`Extension v${extensionVersion} / Native v${nativeVersion}`, `扩展 v${extensionVersion} / Native v${nativeVersion}`),
5607
- tx('Run the platform-specific command below, reload the extension, then refresh Overleaf.', '运行下面的平台命令,重新加载扩展,然后刷新 Overleaf。')
5608
- ].join('\n\n');
5609
-
5610
- const commandCode = document.createElement('code');
5611
- commandCode.textContent = command;
5612
- commandCode.style.display = 'block';
5613
- commandCode.style.whiteSpace = 'pre-wrap';
5614
- commandCode.style.marginTop = '0.75rem';
5615
- body.append(commandCode);
5616
-
5617
- if (releaseUrl) {
5618
- const link = document.createElement('a');
5619
- link.href = releaseUrl;
5620
- link.target = '_blank';
5621
- link.rel = 'noopener noreferrer';
5622
- link.textContent = tx('Open GitHub Release', '打开 GitHub Release');
5623
- link.style.display = 'block';
5624
- link.style.marginTop = '0.75rem';
5625
- body.append(link);
5626
- }
5627
-
5628
- const actions = document.createElement('div');
5629
- actions.className = 'codex-plugin-confirm-actions';
5630
- const copy = document.createElement('button');
5631
- copy.type = 'button';
5632
- copy.className = 'codex-plugin-confirm-confirm';
5633
- copy.textContent = tx('Copy command', '复制命令');
5634
- copy.addEventListener('click', async () => {
5635
- await navigator.clipboard.writeText(command);
5636
- copy.textContent = tx('Copied', '已复制');
5637
- });
5638
- const close = document.createElement('button');
5639
- close.type = 'button';
5640
- close.className = 'codex-plugin-confirm-cancel';
5641
- close.textContent = tx('Close', '关闭');
5642
- close.addEventListener('click', () => overlay.remove());
5643
- actions.append(close, copy);
5644
-
5645
- card.append(title, body, actions);
5646
- overlay.append(card);
5647
- overlay.addEventListener('click', event => {
5648
- if (event.target === overlay) {
5649
- overlay.remove();
5650
- }
5651
- });
5652
- panel.append(overlay);
5653
- copy.focus();
5489
+ return nativeCompatibilityController.showUpdateGuidance(compatibility);
5654
5490
  }
5655
5491
 
5656
5492
  function scheduleFirstUseOnboardingTip() {
5657
- setTimeout(() => {
5658
- maybeShowFirstUseOnboardingTip().catch(() => {});
5659
- }, 250);
5660
- }
5661
-
5662
- async function maybeShowFirstUseOnboardingTip() {
5663
- if (!panel || (state?.sessions || []).length > 0) {
5664
- return;
5665
- }
5666
- const stored = await chrome.storage.local.get([ONBOARDING_TIP_STORAGE_KEY]);
5667
- if (stored?.[ONBOARDING_TIP_STORAGE_KEY]) {
5668
- return;
5669
- }
5670
- const modeSelect = panel.querySelector('[data-mode]');
5671
- if (!modeSelect) {
5672
- return;
5673
- }
5674
- const tip = document.createElement('div');
5675
- tip.className = 'codex-onboarding-tip';
5676
- tip.dataset.onboardingTip = 'true';
5677
- tip.setAttribute('role', 'status');
5678
- tip.textContent = tx(
5679
- 'Tip: Start with Ask mode to explore safely. Switch to Suggest to review edits before applying.',
5680
- '提示:先用 Ask 模式安全探索;需要修改时切到 Suggest,应用前可先审阅。'
5681
- );
5682
- Object.assign(tip.style, {
5683
- position: 'absolute',
5684
- zIndex: '2147483647',
5685
- maxWidth: '280px'
5686
- });
5687
- panel.append(tip);
5688
- const panelRect = panel.getBoundingClientRect();
5689
- const modeRect = modeSelect.getBoundingClientRect();
5690
- tip.style.left = `${Math.max(12, modeRect.left - panelRect.left)}px`;
5691
- tip.style.bottom = `${Math.max(72, panelRect.bottom - modeRect.top + 8)}px`;
5692
-
5693
- const dismiss = () => {
5694
- tip.remove();
5695
- chrome.storage.local.set({ [ONBOARDING_TIP_STORAGE_KEY]: true }).catch?.(() => {});
5696
- panel.removeEventListener('click', dismiss, true);
5697
- panel.removeEventListener('keydown', dismiss, true);
5698
- };
5699
- panel.addEventListener('click', dismiss, true);
5700
- panel.addEventListener('keydown', dismiss, true);
5701
- setTimeout(dismiss, 8000);
5702
- }
5703
-
5704
- function formatNativeCompatibilityBlockedMessage(method, compatibility = {}) {
5705
- const statusValue = compatibility.status || 'unknown_native';
5706
- const params = { method: method || 'native request' };
5707
- switch (statusValue) {
5708
- case 'native_too_old':
5709
- return tr('nativeCompatibilityBlockedNativeTooOld', params);
5710
- case 'extension_too_old':
5711
- return tr('nativeCompatibilityBlockedExtensionTooOld', params);
5712
- case 'protocol_unsupported':
5713
- return tr('nativeCompatibilityBlockedProtocol', params);
5714
- case 'native_unhealthy':
5715
- return tr('nativeCompatibilityBlockedUnhealthy', params);
5716
- case 'native_missing':
5717
- return tr('nativeCompatibilityBlockedMissing', params);
5718
- default:
5719
- return tr('nativeCompatibilityBlockedGeneric', params);
5720
- }
5493
+ nativeCompatibilityController.scheduleFirstUseOnboardingTip();
5721
5494
  }
5722
5495
 
5723
5496
  async function callPageBridge(method, params) {
5724
- try {
5725
- await pageBridgeReady;
5726
- } catch (error) {
5727
- return {
5728
- ok: false,
5729
- error: `Page bridge unavailable: ${error.message}`
5730
- };
5731
- }
5732
- const timeoutMs = getPageBridgeTimeoutMs(method);
5733
- return sendPageBridgeRequest(method, params, {
5734
- timeoutMs
5735
- });
5736
- }
5737
-
5738
- function sendPageBridgeRequest(method, params, options = {}) {
5739
- const id = crypto.randomUUID();
5740
- return new Promise((resolve, reject) => {
5741
- const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 8000;
5742
- const cancellable = CANCELLABLE_PAGE_BRIDGE_METHODS.has(method);
5743
- let settled = false;
5744
- let timeout = null;
5745
-
5746
- function cleanup() {
5747
- if (timeout !== null) {
5748
- window.clearTimeout(timeout);
5749
- }
5750
- window.removeEventListener('message', onMessage);
5751
- if (cancellable) {
5752
- activePageBridgeCancellationHandlers.delete(id);
5753
- }
5754
- }
5755
-
5756
- function resolveOnce(value) {
5757
- if (settled) {
5758
- return;
5759
- }
5760
- settled = true;
5761
- cleanup();
5762
- resolve(value);
5763
- }
5764
-
5765
- function rejectOnce(error) {
5766
- if (settled) {
5767
- return;
5768
- }
5769
- settled = true;
5770
- cleanup();
5771
- reject(error);
5772
- }
5773
-
5774
- function cancelRequest() {
5775
- const error = new Error('Codex run was cancelled by the user');
5776
- error.code = 'codex_cancelled';
5777
- error.cancelled = true;
5778
- rejectOnce(error);
5779
- }
5780
-
5781
- timeout = window.setTimeout(() => {
5782
- resolveOnce({ ok: false, error: 'Page bridge timed out' });
5783
- }, timeoutMs);
5784
-
5785
- function onMessage(event) {
5786
- if (event.source !== window
5787
- || event.origin !== window.location.origin
5788
- || event.data?.source !== 'codex-overleaf/page'
5789
- || event.data?.pageBridgeVersion !== CodexOverleafCompatibility?.BUILD_TARGET_VERSION
5790
- || event.data?.pageBridgeRevision !== PAGE_BRIDGE_SCRIPT_REVISION
5791
- || event.data.id !== id) {
5792
- return;
5793
- }
5794
- resolveOnce(event.data.result);
5795
- }
5796
-
5797
- if (cancellable) {
5798
- activePageBridgeCancellationHandlers.set(id, cancelRequest);
5799
- if (runCancellationRequested) {
5800
- window.queueMicrotask(cancelRequest);
5801
- }
5802
- }
5803
-
5804
- window.addEventListener('message', onMessage);
5805
- // This capability narrows accidental/spoofed page-bridge calls. It is not
5806
- // a secret from malicious same-page Overleaf scripts because postMessage
5807
- // traffic is page-visible.
5808
- window.postMessage({
5809
- source: 'codex-overleaf/content',
5810
- id,
5811
- method,
5812
- params,
5813
- capability: PAGE_BRIDGE_CAPABILITY
5814
- }, window.location.origin);
5815
- });
5497
+ return pageBridgeClient.call(method, params);
5816
5498
  }
5817
5499
 
5818
5500
  function getPageBridgeTimeoutMs(method) {
5819
- if (method === 'getProjectSnapshot') {
5820
- return SNAPSHOT_PAGE_BRIDGE_TIMEOUT_MS;
5821
- }
5822
- if (method === 'getProjectFileList') {
5823
- return CONTEXT_FILE_LIST_PAGE_BRIDGE_TIMEOUT_MS;
5824
- }
5825
- if (method === 'triggerCompile' || method === 'getCompileLog') {
5826
- return COMPILE_PAGE_BRIDGE_TIMEOUT_MS;
5827
- }
5828
- if (method === 'rejectTrackedChanges' || method === 'acceptTrackedChanges') {
5829
- return 120000;
5830
- }
5831
- if (method === 'applyOperations') {
5832
- // Each per-op step inside writebackRouter.applyOperationsCore can wait
5833
- // up to 5s for openFileByPath (treeOperations.js:230, :259) plus up to
5834
- // 5s for waitForActiveEditorText plus reviewing/save-state polling.
5835
- // A multi-op write on a freshly-loaded Overleaf editor can run 15-30s
5836
- // in the wild. The pre-fix default of 8000ms timed out mid-write and
5837
- // left the page-side promise running uncontrolled — a 'zombie' write
5838
- // could still land after the content-side reported failure. 30s is a
5839
- // sane upper bound that covers the realistic slow path without
5840
- // letting a genuinely hung dispatch tie up the UI indefinitely.
5841
- return 30000;
5842
- }
5843
- return 8000;
5844
- }
5845
-
5846
- async function injectPageBridge() {
5847
- await injectScriptOnce('src/shared/reviewing.js', 'codex-overleaf-reviewing-script');
5848
- await injectScriptOnce('src/shared/projectFiles.js', 'codex-overleaf-project-files-script');
5849
- await injectScriptOnce('src/shared/compatibility.js', 'codex-overleaf-compatibility-page-script');
5850
- await injectScriptOnce('src/shared/staleGuard.js', 'codex-overleaf-stale-guard-script');
5851
- await injectScriptOnce('src/shared/compileAdapter.js', 'codex-overleaf-compile-adapter-script');
5852
- await injectScriptOnce('src/shared/governanceRules.js', 'codex-overleaf-governance-rules-script');
5853
- await injectScriptOnce('src/shared/sensitiveScan.js', 'codex-overleaf-sensitive-scan-script');
5854
- await injectScriptOnce('src/shared/auditRecords.js', 'codex-overleaf-audit-records-script');
5855
- await injectScriptOnce('src/page/saveState.js', 'codex-overleaf-save-state-script', { force: true });
5856
- await injectScriptOnce('src/page/overleafCapabilities.js', 'codex-overleaf-capabilities-script');
5857
- await injectScriptOnce('src/page/compileBridge.js', 'codex-overleaf-compile-bridge-script');
5858
- await injectScriptOnce('src/page/overleafEditor.js', 'codex-overleaf-editor-script');
5859
- await injectScriptOnce('src/page/overleafProjectSnapshot.js', 'codex-overleaf-project-snapshot-script');
5860
- await injectOptionalOtDependencies();
5861
- await injectScriptOnce('src/page/pageBridgeCapability.js', 'codex-overleaf-page-bridge-capability-script');
5862
- await injectScriptOnce('src/page/treeOperations.js', 'codex-overleaf-tree-operations-script', { force: true });
5863
- await injectScriptOnce('src/page/snapshotRouter.js', 'codex-overleaf-snapshot-router-script');
5864
- await injectScriptOnce('src/page/writeGuard.js', 'codex-overleaf-write-guard-script', { force: true });
5865
- // Phase 7 (v1.8.0): the tracked-changes lifecycle must be defined in the
5866
- // page world BEFORE writebackRouter.create() dereferences it — the vm
5867
- // test harnesses inject it manually, so only this list proves it loads.
5868
- await injectScriptOnce('src/page/trackedChangesLifecycle.js', 'codex-overleaf-tracked-changes-lifecycle-script', { force: true });
5869
- await injectScriptOnce('src/page/writebackRouter.js', 'codex-overleaf-writeback-router-script', { force: true });
5870
- await injectScriptOnce('src/pageBridge.js', 'codex-overleaf-page-bridge-script', { force: true });
5871
- await initializePageBridgeCapability();
5872
- }
5873
-
5874
- async function injectOptionalOtDependencies() {
5875
- try {
5876
- await injectScriptOnce('src/shared/otText.js', 'codex-overleaf-ot-text-script');
5877
- await injectScriptOnce('src/page/overleafRealtimeObserver.js', 'codex-overleaf-realtime-observer-script');
5878
- } catch (_error) {
5879
- // The page bridge has a read-only unavailable fallback for these optional OT helpers.
5880
- }
5501
+ return pageBridgeClient.resolveTimeoutMs(method);
5881
5502
  }
5882
5503
 
5883
- function injectScriptOnce(src, id, options = {}) {
5884
- return new Promise((resolve, reject) => {
5885
- const existing = document.getElementById(id);
5886
- if (existing && options.force !== true) {
5887
- resolve();
5888
- return;
5889
- }
5890
- existing?.remove?.();
5891
- const script = document.createElement('script');
5892
- const timeout = window.setTimeout(() => {
5893
- cleanup();
5894
- reject(new Error(`Timed out loading ${src}`));
5895
- }, 8000);
5896
- function cleanup() {
5897
- window.clearTimeout(timeout);
5898
- script.onload = null;
5899
- script.onerror = null;
5900
- script.remove();
5901
- }
5902
- script.id = id;
5903
- const runtimePrefix = String(root.__CODEX_OVERLEAF_RUNTIME_PREFIX__ || '');
5904
- script.src = chrome.runtime.getURL(runtimePrefix + src);
5905
- script.onload = () => {
5906
- cleanup();
5907
- resolve();
5908
- };
5909
- script.onerror = () => {
5910
- cleanup();
5911
- reject(new Error(`Failed to load ${src}`));
5912
- };
5913
- (document.head || document.documentElement).append(script);
5914
- });
5504
+ async function loadStoredState() {
5505
+ const projectId = getCurrentProjectId();
5506
+ const persistenceCoordinator = getScopedPersistenceCoordinator();
5507
+ const hydrationView = projectId && persistenceCoordinator
5508
+ ? await persistenceCoordinator.beginHydration(projectId)
5509
+ : null;
5510
+ const hydrationAccountScopeId = hydrationView?.accountScopeId || cachedAccountScopeId || '';
5511
+ const loaded = await loadStoredStateForProject(hydrationAccountScopeId);
5512
+ if (
5513
+ hydrationView
5514
+ && persistenceCoordinator
5515
+ && !persistenceCoordinator.isCurrent(hydrationView)
5516
+ ) {
5517
+ throw Object.assign(new Error('Scoped hydration was superseded.'), {
5518
+ code: 'stale_hydration'
5519
+ });
5520
+ }
5521
+ return loaded;
5915
5522
  }
5916
5523
 
5917
- async function initializePageBridgeCapability() {
5918
- const result = await sendPageBridgeRequest('initializeCapability', {}, {
5919
- timeoutMs: 8000
5920
- });
5921
- if (!result?.ok) {
5922
- throw new Error(result?.error || result?.reason || 'Page bridge capability initialization failed');
5524
+ function buildScopedFallbackStorageKey(baseKey, accountScopeId) {
5525
+ const account = typeof accountScopeId === 'string' ? accountScopeId.trim() : '';
5526
+ if (!account) {
5527
+ throw Object.assign(new Error('Scoped fallback storage requires an account identity.'), {
5528
+ code: 'account_scope_unavailable'
5529
+ });
5923
5530
  }
5531
+ return `${baseKey}:account:${encodeURIComponent(account)}`;
5924
5532
  }
5925
5533
 
5926
- function createPageBridgeCapability() {
5927
- if (crypto?.randomUUID) {
5928
- return crypto.randomUUID();
5929
- }
5930
- const bytes = new Uint8Array(24);
5931
- crypto.getRandomValues(bytes);
5932
- return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
5534
+ function isScopedPersistenceIdentityFailure(error) {
5535
+ return [
5536
+ 'account_scope_unavailable',
5537
+ 'scoped_claim_unavailable',
5538
+ 'stale_hydration',
5539
+ 'stale_view'
5540
+ ].includes(error?.code);
5933
5541
  }
5934
5542
 
5935
- async function loadStoredState() {
5543
+ async function loadStoredStateForProject(accountScopeId = cachedAccountScopeId || '') {
5936
5544
  try {
5937
- const StorageDb = window.CodexOverleafStorageDb;
5938
- const Migration = window.CodexOverleafStorageMigration;
5545
+ const StorageDb = Modules.StorageDb;
5546
+ const Migration = Modules.StorageMigration;
5939
5547
  const projectId = getCurrentProjectId();
5940
5548
  const legacyKey = storageKey;
5941
5549
 
5942
- const { prefs, sessions, activeSessionId } = await Migration.runMigrationIfNeeded(projectId, legacyKey);
5550
+ const { prefs, sessions, activeSessionId } = await Migration.runMigrationIfNeeded(
5551
+ projectId,
5552
+ legacyKey,
5553
+ accountScopeId
5554
+ );
5943
5555
 
5944
5556
  return {
5945
5557
  model: prefs.model || '', providerId: prefs.providerId || 'builtin',
@@ -5958,6 +5570,7 @@
5958
5570
  panelWidth: prefs.panelWidth || PANEL_DEFAULT_WIDTH,
5959
5571
  sessions: sessions.map(session => ({
5960
5572
  id: session.id,
5573
+ accountScopeId: session.accountScopeId || accountScopeId,
5961
5574
  title: session.title || '',
5962
5575
  titleSource: session.titleSource || 'auto',
5963
5576
  focusFiles: session.focusFiles || [],
@@ -5980,13 +5593,16 @@
5980
5593
  runs: []
5981
5594
  };
5982
5595
  } catch (error) {
5983
- // Fallback to legacy loading if IndexedDB fails
5984
- const keys = storageKey === LEGACY_STORAGE_KEY
5985
- ? [LEGACY_STORAGE_KEY]
5986
- : [storageKey, LEGACY_STORAGE_KEY];
5987
- const stored = await chrome.storage.local.get(keys);
5988
- const fallback = stored[storageKey] || stored[LEGACY_STORAGE_KEY] || {};
5989
- if (fallback?.__codexOverleafCompactFallback === true) {
5596
+ if (!accountScopeId || isScopedPersistenceIdentityFailure(error)) {
5597
+ throw error;
5598
+ }
5599
+ const fallbackKey = buildScopedFallbackStorageKey(storageKey, accountScopeId);
5600
+ const stored = await chrome.storage.local.get([fallbackKey]);
5601
+ const fallback = stored[fallbackKey] || {};
5602
+ if (
5603
+ fallback?.__codexOverleafCompactFallback === true
5604
+ && fallback.__codexOverleafFallbackAccountScopeId === accountScopeId
5605
+ ) {
5990
5606
  return {
5991
5607
  mode: fallback.mode || '', providerId: fallback.providerId || 'builtin',
5992
5608
  model: fallback.model || '',
@@ -6007,7 +5623,7 @@
6007
5623
  runs: []
6008
5624
  };
6009
5625
  }
6010
- return fallback;
5626
+ throw error;
6011
5627
  }
6012
5628
  }
6013
5629
 
@@ -6027,8 +5643,8 @@
6027
5643
 
6028
5644
  async function saveState(options) {
6029
5645
  try {
6030
- const StorageDb = window.CodexOverleafStorageDb;
6031
- const Migration = window.CodexOverleafStorageMigration;
5646
+ const StorageDb = Modules.StorageDb;
5647
+ const Migration = Modules.StorageMigration;
6032
5648
  // Welcome-panel + write-guard:
6033
5649
  // post-navigation persistence gate. The normal URL-derived projectId
6034
5650
  // belongs to the route the user is *currently looking at*. When a run
@@ -6074,86 +5690,85 @@
6074
5690
  compactState.experimentalOtByProject = state.experimentalOtByProject;
6075
5691
  compactState.customInstructionsByProject = state.customInstructionsByProject;
6076
5692
  compactState.governanceRulesByProject = state.governanceRulesByProject;
6077
-
6078
- // Save lightweight prefs to chrome.storage.local
6079
- const latestPrefs = typeof Migration.loadPrefs === 'function'
6080
- ? await Migration.loadPrefs()
6081
- : {};
6082
- const prefsFromState = StorageDb.extractLightweightPrefs(compactState, projectId);
6083
- const prefs = {
6084
- ...(latestPrefs && typeof latestPrefs === 'object' ? latestPrefs : {}),
6085
- ...prefsFromState
6086
- };
6087
- prefs.activeSessionByProject = StorageDb.buildActiveSessionByProject(
6088
- latestPrefs?.activeSessionByProject || {},
5693
+ const stateSnapshot = typeof structuredClone === 'function'
5694
+ ? structuredClone(state)
5695
+ : JSON.parse(JSON.stringify(state));
5696
+ const compactStateSnapshot = typeof structuredClone === 'function'
5697
+ ? structuredClone(compactState)
5698
+ : JSON.parse(JSON.stringify(compactState));
5699
+
5700
+ const persistScopedState = persistenceContext => Modules.ScopedPersistenceCoordinator.persistPanelState({
5701
+ state: stateSnapshot,
5702
+ compactState: compactStateSnapshot,
6089
5703
  projectId,
6090
- compactState.activeSessionId || state.activeSessionId || ''
6091
- );
6092
- prefs.experimentalOtByProject = {
6093
- ...(latestPrefs?.experimentalOtByProject || {}),
6094
- [projectId]: normalizeExperimentalOtByProject(state?.experimentalOtByProject)[projectId] === true
6095
- };
6096
- const normalizedGovernanceByProject = typeof normalizeGovernanceRulesByProject === 'function'
6097
- ? normalizeGovernanceRulesByProject(state?.governanceRulesByProject)
6098
- : (state?.governanceRulesByProject || {});
6099
- prefs.governanceRulesByProject = {
6100
- ...(latestPrefs?.governanceRulesByProject || {}),
6101
- ...normalizedGovernanceByProject
6102
- };
6103
- const normalizedCustomProject = normalizeCustomInstructionsByProject({ [projectId]: '' });
6104
- const normalizedCustomProjectId = Object.keys(normalizedCustomProject)[0] || '';
6105
- const currentCustomInstructions = normalizeCustomInstructionsByProject(state?.customInstructionsByProject);
6106
- prefs.customInstructionsByProject = {
6107
- ...(latestPrefs?.customInstructionsByProject || {})
6108
- };
6109
- if (normalizedCustomProjectId) {
6110
- prefs.customInstructionsByProject[normalizedCustomProjectId] =
6111
- Object.prototype.hasOwnProperty.call(currentCustomInstructions, normalizedCustomProjectId)
6112
- ? currentCustomInstructions[normalizedCustomProjectId]
6113
- : '';
6114
- }
6115
- await Migration.savePrefs(prefs);
6116
-
6117
- // Save all displayable session state to IndexedDB. The panel history lives here;
6118
- // chrome.storage.local only keeps small pointers/preferences.
6119
- const sessionRecords = (state.sessions || []).map(session => (
6120
- StorageDb.buildSessionRecord({
6121
- ...session,
6122
- projectId,
6123
- title: session.title || '',
6124
- titleSource: session.titleSource || 'auto',
6125
- codexThreadId: session.codexThreadId || '',
6126
- status: 'active',
6127
- focusFiles: Array.isArray(session.focusFiles) ? session.focusFiles : [],
6128
- createdAt: session.createdAt,
6129
- updatedAt: session.updatedAt
6130
- }, { preserveRunActionPayload: true })
6131
- ));
6132
- await window.CodexOverleafSessionPersistence.writeSessions({
6133
- Migration, StorageDb, projectId,
6134
5704
  deletedSessionIds: options?.deletedSessionIds,
6135
- sessionRecords
5705
+ Migration,
5706
+ StorageDb,
5707
+ SessionPersistence: Modules.SessionPersistence,
5708
+ normalizeExperimentalOtByProject,
5709
+ normalizeGovernanceRulesByProject,
5710
+ normalizeCustomInstructionsByProject,
5711
+ persistenceContext
6136
5712
  });
5713
+
5714
+ const persistenceCoordinator = getScopedPersistenceCoordinator();
5715
+ if (!persistenceCoordinator) {
5716
+ throw Object.assign(new Error('Scoped persistence coordinator is unavailable.'), {
5717
+ code: 'scoped_persistence_unavailable'
5718
+ });
5719
+ } else {
5720
+ const committed = await persistenceCoordinator.commit(
5721
+ projectId,
5722
+ {
5723
+ detached: Boolean(projectIdOverride),
5724
+ rebase: true,
5725
+ queueMutation: options?.queueMutation,
5726
+ queueMutations: options?.queueMutations,
5727
+ sessionTombstones: options?.deletedSessionIds
5728
+ },
5729
+ persistScopedState
5730
+ );
5731
+ if (!committed.ok && (
5732
+ options?.queueMutation
5733
+ || options?.queueMutations?.length
5734
+ || committed.reason !== 'stale_view'
5735
+ )) {
5736
+ throw Object.assign(new Error(`Scoped persistence failed: ${committed.reason}`), {
5737
+ code: committed.reason
5738
+ });
5739
+ }
5740
+ }
6137
5741
  } catch (error) {
6138
- // Fallback: try legacy save
5742
+ if (
5743
+ error?.code === 'account_scope_unavailable' &&
5744
+ !options?.queueMutation &&
5745
+ !(Array.isArray(options?.queueMutations) && options.queueMutations.length)
5746
+ ) {
5747
+ return;
5748
+ }
5749
+ if (options?.queueMutation || options?.queueMutations?.length) {
5750
+ throw error;
5751
+ }
5752
+ let fallbackError = null;
6139
5753
  try {
6140
- await chrome.storage.local.set({ [storageKey]: prepareCompactFallbackState(state) });
6141
- } catch (fallbackError) {
6142
- // Structured FailureReason §9.8: persistence raised a quota error.
6143
- // Surface a `storage_quota_exceeded` failure (warning, retryable)
6144
- // alongside the legacy toast notice so downstream renderers and tests
6145
- // can detect the quota-specific class. The structured emit fires for
6146
- // both the original `error` (primary IndexedDB write) and the
6147
- // `fallbackError` (chrome.storage.local fallback).
6148
- const quotaHit = isStorageQuotaError(error) || isStorageQuotaError(fallbackError);
6149
- if (quotaHit) {
6150
- emitStorageQuotaFailure(error, fallbackError);
6151
- }
6152
- if (typeof appendStorageNoticeOnce === 'function') {
6153
- appendStorageNoticeOnce('save-failed', tx(`Failed to save session state: ${error.message}`, `保存会话状态失败:${error.message}`));
6154
- } else {
6155
- throw fallbackError;
6156
- }
5754
+ const accountScopeId = cachedAccountScopeId || '';
5755
+ const fallbackKey = buildScopedFallbackStorageKey(storageKey, accountScopeId);
5756
+ await chrome.storage.local.set({
5757
+ [fallbackKey]: {
5758
+ ...prepareCompactFallbackState(state),
5759
+ __codexOverleafFallbackAccountScopeId: accountScopeId
5760
+ }
5761
+ });
5762
+ } catch (caughtFallbackError) {
5763
+ fallbackError = caughtFallbackError;
5764
+ }
5765
+ if (isStorageQuotaError(error)) {
5766
+ emitStorageQuotaFailure(error, fallbackError);
5767
+ }
5768
+ if (typeof appendStorageNoticeOnce === 'function') {
5769
+ appendStorageNoticeOnce('save-failed', tx(`Failed to save session state: ${error.message}`, `保存会话状态失败:${error.message}`));
5770
+ } else {
5771
+ throw error;
6157
5772
  }
6158
5773
  }
6159
5774
  }
@@ -6216,7 +5831,8 @@
6216
5831
  showPluginToast(text, { status: 'warning', sticky: true });
6217
5832
  }
6218
5833
 
6219
- function saveStateSoon(delayMs = 120) {
5834
+ function saveStateSoon(delayMs = 120, persistenceOptions = null) {
5835
+ pendingSaveStateOptions = mergeSaveStateOptions(pendingSaveStateOptions, persistenceOptions);
6220
5836
  // If a saveState() is already in flight, do NOT start a parallel one.
6221
5837
  // Mark the trailing flag so the in-flight save's completion callback
6222
5838
  // schedules another one — that way the final disk snapshot reflects
@@ -6238,9 +5854,15 @@
6238
5854
  }
6239
5855
 
6240
5856
  function runQueuedSaveState() {
5857
+ if (saveStateInFlightPromise) {
5858
+ saveStateRunAfterFlight = true;
5859
+ return saveStateInFlightPromise;
5860
+ }
6241
5861
  saveStateInFlight = true;
6242
5862
  saveStateRunAfterFlight = false;
6243
- saveState()
5863
+ const persistenceOptions = pendingSaveStateOptions;
5864
+ pendingSaveStateOptions = null;
5865
+ const runPromise = saveState(persistenceOptions)
6244
5866
  .catch(error => {
6245
5867
  if (isStorageQuotaError(error)) {
6246
5868
  // Structured FailureReason §9.8: timer-driven saveState raised a
@@ -6252,6 +5874,9 @@
6252
5874
  appendPlainLog(tx(`Failed to save session state: ${formatStateSaveError(error)}`, `保存会话状态失败:${formatStateSaveError(error)}`));
6253
5875
  })
6254
5876
  .finally(() => {
5877
+ if (saveStateInFlightPromise === runPromise) {
5878
+ saveStateInFlightPromise = null;
5879
+ }
6255
5880
  saveStateInFlight = false;
6256
5881
  if (saveStateRunAfterFlight) {
6257
5882
  // A saveStateSoon() landed during the in-flight phase. Flush it
@@ -6260,6 +5885,52 @@
6260
5885
  saveStateSoon(0);
6261
5886
  }
6262
5887
  });
5888
+ saveStateInFlightPromise = runPromise;
5889
+ return runPromise;
5890
+ }
5891
+
5892
+ async function flushQueuedSaveState(persistenceOptions = null) {
5893
+ pendingSaveStateOptions = mergeSaveStateOptions(pendingSaveStateOptions, persistenceOptions);
5894
+ let persistLatestSnapshot = true;
5895
+ while (persistLatestSnapshot) {
5896
+ if (saveStateTimer) {
5897
+ clearTimeout(saveStateTimer);
5898
+ saveStateTimer = null;
5899
+ }
5900
+ if (saveStateInFlightPromise) {
5901
+ await saveStateInFlightPromise;
5902
+ if (saveStateTimer) {
5903
+ clearTimeout(saveStateTimer);
5904
+ saveStateTimer = null;
5905
+ }
5906
+ }
5907
+
5908
+ saveStateRunAfterFlight = false;
5909
+ await runQueuedSaveState();
5910
+ persistLatestSnapshot = Boolean(
5911
+ saveStateTimer
5912
+ || saveStateRunAfterFlight
5913
+ || pendingSaveStateOptions
5914
+ );
5915
+ }
5916
+ }
5917
+
5918
+ function mergeSaveStateOptions(current, incoming) {
5919
+ if (!incoming) return current;
5920
+ const queueMutations = [
5921
+ ...(current?.queueMutations || (current?.queueMutation ? [current.queueMutation] : [])),
5922
+ ...(incoming?.queueMutations || (incoming?.queueMutation ? [incoming.queueMutation] : []))
5923
+ ];
5924
+ return {
5925
+ ...(current || {}),
5926
+ ...incoming,
5927
+ queueMutation: undefined,
5928
+ queueMutations,
5929
+ deletedSessionIds: Array.from(new Set([
5930
+ ...(current?.deletedSessionIds || []),
5931
+ ...(incoming?.deletedSessionIds || [])
5932
+ ]))
5933
+ };
6263
5934
  }
6264
5935
 
6265
5936
  function scheduleRunStateSave(kind) {
@@ -6511,7 +6182,15 @@
6511
6182
  renderPendingInputs();
6512
6183
  }
6513
6184
 
6514
- function startRunView({ task, mode, model, reasoningEffort, speedTier, queueItemId = '' }) {
6185
+ function startRunView({
6186
+ task,
6187
+ mode,
6188
+ model,
6189
+ reasoningEffort,
6190
+ speedTier,
6191
+ queueItemId = '',
6192
+ executionSnapshot = null
6193
+ }) {
6515
6194
  let attachments = [];
6516
6195
  if (Array.isArray(arguments[0]?.attachments)) {
6517
6196
  attachments = arguments[0].attachments;
@@ -6521,7 +6200,8 @@
6521
6200
  id: createRunId(),
6522
6201
  task,
6523
6202
  mode,
6524
- ...providerSettingsCoordinator.getProviderSnapshot(state?.providerId),
6203
+ ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot?.providerId || state?.providerId),
6204
+ providerRevision: executionSnapshot?.providerRevision || '',
6525
6205
  model,
6526
6206
  reasoningEffort,
6527
6207
  speedTier,
@@ -6539,6 +6219,9 @@
6539
6219
  nativeRequestId: '',
6540
6220
  codexTurnId: '',
6541
6221
  nativeEventSeq: 0,
6222
+ executionSnapshot: executionSnapshot
6223
+ ? RunExecutionSnapshot.cloneSnapshot(executionSnapshot)
6224
+ : undefined,
6542
6225
  // Welcome-panel + write-guard:
6543
6226
  // Immutable per-run capture of the project this run was submitted
6544
6227
  // against. Every writeback / accept / undo dispatch attaches this id
@@ -6546,7 +6229,8 @@
6546
6229
  // is still acting on the same project before mutating the editor. No
6547
6230
  // language-level enforcement — the field is treated as immutable by
6548
6231
  // convention and never reassigned anywhere downstream.
6549
- runProjectId: getCurrentProjectId() || ''
6232
+ runProjectId: getCurrentProjectId() || '',
6233
+ runAccountScopeId: cachedAccountScopeId || ''
6550
6234
  };
6551
6235
  const active = getActiveSession(state);
6552
6236
  const titlePatch = active?.titleSource !== 'manual'
@@ -6576,6 +6260,8 @@
6576
6260
  // Mirror the immutable runProjectId on the view so writeback /
6577
6261
  // accept / undo dispatches don't need to re-look up the record.
6578
6262
  runProjectId: record.runProjectId,
6263
+ runAccountScopeId: record.runAccountScopeId,
6264
+ executionSnapshot: record.executionSnapshot,
6579
6265
  root,
6580
6266
  runProcess: root.querySelector('[data-run-process]'),
6581
6267
  processLabel: root.querySelector('[data-run-process-summary]'),
@@ -6590,6 +6276,19 @@
6590
6276
  };
6591
6277
  }
6592
6278
 
6279
+ function touchSessionForTerminalRun(sessionId, finishedAt) {
6280
+ const session = Array.isArray(state.sessions)
6281
+ ? state.sessions.find(item => item?.id === sessionId)
6282
+ : null;
6283
+ if (!session) return '';
6284
+ const previousUpdatedAt = Date.parse(session.updatedAt || '') || 0;
6285
+ const requestedUpdatedAt = Date.parse(finishedAt || '') || Date.now();
6286
+ const updatedAt = new Date(Math.max(requestedUpdatedAt, previousUpdatedAt + 1)).toISOString();
6287
+ session.updatedAt = updatedAt;
6288
+ session.lastActivityAt = updatedAt;
6289
+ return updatedAt;
6290
+ }
6291
+
6593
6292
  function finishRunView(text, status) {
6594
6293
  if (!currentRunView) {
6595
6294
  return;
@@ -6603,6 +6302,7 @@
6603
6302
  record.status = status;
6604
6303
  record.statusText = statusText;
6605
6304
  record.finishedAt = new Date().toISOString();
6305
+ touchSessionForTerminalRun(currentRunView.sessionId, record.finishedAt);
6606
6306
  currentRunView.terminalStatus = status;
6607
6307
  runGuidanceController.settleView(currentRunView);
6608
6308
  // Welcome-panel + write-guard:
@@ -7032,31 +6732,11 @@
7032
6732
 
7033
6733
 
7034
6734
  function isUndoResultEffectivelyApplied(run, result) {
7035
- if (!result?.skipped?.length) {
7036
- return true;
7037
- }
7038
- const expectedByPath = new Map((run?.undoExpectedFiles || [])
7039
- .filter(file => file?.path && typeof file.content === 'string')
7040
- .map(file => [file.path, file.content]));
7041
- if (!expectedByPath.size) {
7042
- return false;
7043
- }
7044
- const undoRestore = buildNoTraceUndoRestore(run);
7045
- const editPaths = Array.from(new Set((undoRestore.operations || [])
7046
- .filter(operation => (
7047
- operation?.type === 'edit'
7048
- && operation.path
7049
- && expectedByPath.has(operation.path)
7050
- ))
7051
- .map(operation => operation.path)));
7052
- if (!editPaths.length) {
7053
- return false;
7054
- }
7055
- return editPaths.every(path => (result.applied || []).some(item => (
7056
- item?.operation?.type === 'edit'
7057
- && item.operation.path === path
7058
- && item?.result?.verifiedContent === expectedByPath.get(path)
7059
- )));
6735
+ return WritebackSettlement.isUndoResultEffectivelyApplied(
6736
+ run,
6737
+ result,
6738
+ buildNoTraceUndoRestore(run).operations
6739
+ );
7060
6740
  }
7061
6741
 
7062
6742
  async function undoRun(runId) {
@@ -7166,7 +6846,11 @@
7166
6846
  // A subset undo never closes the ledger: unselected files can still
7167
6847
  // be undone later, so the run stays 'partial' and the button usable.
7168
6848
  const fullSelection = selectedOperations.length === undoOperations.length;
7169
- setRunUndoStatus(runId, undoApplied && fullSelection ? 'applied' : 'partial');
6849
+ applyLegacyUndoSettlement(
6850
+ runId,
6851
+ undoApplied && fullSelection ? 'applied' : 'partial',
6852
+ result
6853
+ );
7170
6854
  }
7171
6855
 
7172
6856
  async function undoRunTrackedChanges(runId, run) {
@@ -7239,91 +6923,13 @@
7239
6923
  }
7240
6924
  });
7241
6925
  if (lifecycleReject) {
7242
- // Structured FailureReason §9.5: post-undo proof step. If the page op
7243
- // reported ok-ish (no skipped items) but the per-path verifiedContent
7244
- // does not match this run's expected pre-write content, synthesize an
7245
- // `undo_not_verified` failure (warning, retryable, needs_review,
7246
- // changedDocument:true) and attach it to the result so the §7
7247
- // settlement matrix routes the run to `needs_review`.
7248
- attachUndoNotVerifiedFailure(run, result);
7249
- // §7 settlement matrix: only land in terminal `rejected` when post-action
7250
- // proof is sufficient. If any per-op failure marks `needs_review` (or
7251
- // matches a known unverified code), settle as `needs_review` so both
7252
- // Accept and Undo stay actionable and the user can reconcile.
7253
- applyRejectSettlement(runId, result);
7254
- return;
7255
- }
7256
- setRunUndoStatus(runId, result.skipped?.length ? 'partial' : 'applied');
7257
- }
7258
-
7259
- // Post-undo proof step (§9.5). Called only on lifecycle reject paths after
7260
- // the page bridge has returned. We only synthesize an `undo_not_verified`
7261
- // failure when:
7262
- // - The page op did not already emit a needs_review-class failure (the
7263
- // settlement matrix would have caught it).
7264
- // - The reject result has no skipped items (otherwise the existing
7265
- // skipped failures drive settlement).
7266
- // - At least one expected pre-run path has no matching `applied` entry
7267
- // whose `verifiedContent` equals the expected content. (Note: we do
7268
- // NOT delegate to `isUndoResultEffectivelyApplied` because that helper
7269
- // short-circuits to true on `skipped.length === 0` — the §9.5 contract
7270
- // requires real per-path verifiedContent proof.)
7271
- // The synthesized failure is appended to `result.skipped` as a synthetic
7272
- // proof entry so `collectFailuresFromResult` picks it up.
7273
- function attachUndoNotVerifiedFailure(run, result) {
7274
- if (!result || typeof result !== 'object') return;
7275
- if (result.ok === false) return;
7276
- if (Array.isArray(result.skipped) && result.skipped.length > 0) return;
7277
- const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
7278
- if (!expectedFiles.length) return;
7279
- if (isUndoVerifiedContentMatching(run, result)) return;
7280
- const firstPath = expectedFiles.find(entry => entry && typeof entry.path === 'string')?.path || '';
7281
- const failure = buildContentFailure('undo_not_verified', { path: firstPath, type: 'undo' }, {
7282
- changedDocument: true,
7283
- terminalState: 'needs_review',
7284
- evidence: {
7285
- undoApplied: true,
7286
- verified: false,
7287
- expectedFileCount: expectedFiles.length
7288
- }
7289
- });
7290
- const synthetic = {
7291
- operation: { path: firstPath, type: 'undo' },
7292
- result: {
7293
- ok: false,
7294
- code: 'undo_not_verified',
7295
- reason: failure.userMessage,
7296
- failure
7297
- }
7298
- };
7299
- if (!Array.isArray(result.skipped)) {
7300
- result.skipped = [];
7301
- }
7302
- result.skipped.push(synthetic);
7303
- result.ok = false;
7304
- }
7305
-
7306
- // Per-path verifiedContent match for the §9.5 proof step. Returns true
7307
- // only when every expected pre-run path has a matching `applied` entry
7308
- // whose `result.verifiedContent` equals the expected content. Empty
7309
- // expected files map → true (nothing to prove).
7310
- function isUndoVerifiedContentMatching(run, result) {
7311
- const expectedByPath = new Map((run?.undoExpectedFiles || [])
7312
- .filter(file => file && typeof file.path === 'string' && typeof file.content === 'string')
7313
- .map(file => [file.path, file.content]));
7314
- if (!expectedByPath.size) return true;
7315
- const applied = Array.isArray(result.applied) ? result.applied : [];
7316
- for (const [path, expected] of expectedByPath.entries()) {
7317
- const matched = applied.some(entry => {
7318
- if (!entry || !entry.operation) return false;
7319
- if (entry.operation.path !== path) return false;
7320
- if (entry.operation.type !== 'edit') return false;
7321
- if (entry.result && entry.result.ok === false) return false;
7322
- return entry.result && entry.result.verifiedContent === expected;
6926
+ WritebackSettlement.attachUndoNotVerifiedFailure(run, result, {
6927
+ buildFailure: buildContentFailure
7323
6928
  });
7324
- if (!matched) return false;
6929
+ applyTrackedChangeSettlement(runId, 'reject', result);
6930
+ return;
7325
6931
  }
7326
- return true;
6932
+ applyLegacyUndoSettlement(runId, result.skipped?.length ? 'partial' : 'applied', result);
7327
6933
  }
7328
6934
 
7329
6935
  async function acceptRun(runId) {
@@ -7345,8 +6951,6 @@
7345
6951
  return;
7346
6952
  }
7347
6953
 
7348
- // UI-local in-flight lock: disables the acting button with progress. Never
7349
- // written to trackedChangeStatus, never persisted.
7350
6954
  trackedChangeInFlight.set(runId, 'accept');
7351
6955
  refreshRunCardControls(runId);
7352
6956
  appendRunRecordEvent(runId, {
@@ -7361,25 +6965,12 @@
7361
6965
  trackedChanges: run.undoTrackedChanges || [],
7362
6966
  expectedFiles: run.undoExpectedFiles || [],
7363
6967
  postFiles: buildTrackedUndoPostFiles(run),
7364
- // The run's own forward writeback operations. The Accept replay
7365
- // re-applies these exact patches so it writes only the changed
7366
- // fragments, never a whole-file overwrite.
7367
6968
  appliedOperations: Array.isArray(run.appliedOperations) ? run.appliedOperations : [],
7368
- // Welcome-panel + write-guard:
7369
- // bind the accept replay to the run's original project. If the user
7370
- // has navigated to a different project the page-side guard refuses
7371
- // with `aborted_project_changed` before any mutation.
7372
6969
  runProjectId: getRunProjectIdForWriteback(run)
7373
6970
  });
7374
6971
  } finally {
7375
6972
  trackedChangeInFlight.delete(runId);
7376
6973
  }
7377
- // Surface every page-layer step as its own run-card event row so the user
7378
- // can copy-paste the full Accept trace if the sticky-Editing reconfirm
7379
- // ever still cannot keep the replay untracked. Each entry is
7380
- // self-contained — the diagnostic info inline carries the reviewing
7381
- // detector verdict, the strict gate booleans, and per-op
7382
- // re-toggle/verify outcomes so no extra page-state query is needed.
7383
6974
  appendAcceptDiagnosticEvents(runId, Array.isArray(result.diagnostics) ? result.diagnostics : []);
7384
6975
  appendRunRecordEvent(runId, {
7385
6976
  title: tr('runAcceptTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
@@ -7396,10 +6987,6 @@
7396
6987
  }))
7397
6988
  }
7398
6989
  });
7399
- // Accept All is editor-undo + untracked replay. If the editor-undo could not
7400
- // reach the pre-write state (content drifted), the page layer bails without
7401
- // re-writing and returns not-ok. The run then stays `pending` so the user
7402
- // can retry — it does NOT go to a decisive terminal status.
7403
6990
  if (result.ok === false) {
7404
6991
  appendRunRecordEvent(runId, {
7405
6992
  title: tr('runAcceptTrackedFailed'),
@@ -7408,121 +6995,22 @@
7408
6995
  [tr('detailReason')]: tr('runAcceptTrackedFailedReason')
7409
6996
  }
7410
6997
  });
7411
- refreshRunCardControls(runId);
7412
- return;
6998
+ if (!WritebackSettlement.isSuccessfulTrackedChangeSettlement(result)) {
6999
+ refreshRunCardControls(runId);
7000
+ }
7413
7001
  }
7414
- // Structured FailureReason §9.6: post-accept proof step. If the page op
7415
- // returned ok but cannot prove a clean post-accept state (no skipped
7416
- // items, no remaining tracked-change refs proven, and the post-files
7417
- // content of at least one path is missing or mismatched), synthesize an
7418
- // `accept_not_verified` failure (warning, retryable, needs_review,
7419
- // changedDocument:true) and attach it so the §7 settlement matrix routes
7420
- // the run to `needs_review` rather than terminal `accepted`.
7421
- attachAcceptNotVerifiedFailure(run, result);
7422
- // §7 settlement matrix: only land in terminal `accepted` when post-action
7423
- // proof is sufficient. If any per-op failure marks `needs_review` (or
7424
- // matches a known unverified code), settle as `needs_review` so both
7425
- // Accept and Undo stay actionable and the user can reconcile.
7426
- applyAcceptSettlement(runId, result);
7427
- }
7428
-
7429
- // Fail-closed when the run record carries no runProjectId of its own.
7430
- //
7431
- // Older persisted runs (pre-v1.3.8) and restored-from-history runs may have
7432
- // an empty runProjectId. Returning the editor's current projectId as a
7433
- // fallback would let the page-side write-guard pass when the user happens
7434
- // to be in the project they originally ran the task on — but the run
7435
- // itself has NO authoritative binding, so we can't actually prove it.
7436
- // Returning '' triggers the runProjectId-missing branch of writeGuard /
7437
- // checkWritebackRunProjectId, which surfaces editor_project_id_unavailable
7438
- // with the canonical 'Refresh Overleaf and retry' next-step text.
7002
+ WritebackSettlement.attachAcceptNotVerifiedFailure(run, result, {
7003
+ buildFailure: buildContentFailure
7004
+ });
7005
+ applyTrackedChangeSettlement(runId, 'accept', result);
7006
+ }
7007
+
7439
7008
  function getRunProjectIdForWriteback(run) {
7440
7009
  return typeof run?.runProjectId === 'string' && run.runProjectId
7441
7010
  ? run.runProjectId
7442
7011
  : '';
7443
7012
  }
7444
7013
 
7445
- // Post-accept proof step (§9.6). Called only on lifecycle accept paths
7446
- // after the page bridge has returned. We only synthesize an
7447
- // `accept_not_verified` failure when:
7448
- // - The page op did not already emit a needs_review-class failure (the
7449
- // settlement matrix would have caught it).
7450
- // - The accept result has no skipped items (otherwise the existing
7451
- // skipped failures drive settlement).
7452
- // - The page bridge did not confirm clean post-accept state — either no
7453
- // `verified` flag, or the per-path applied items do not show the
7454
- // trackedChange was confirmed cleared.
7455
- // The synthesized failure is appended to `result.skipped` as a synthetic
7456
- // proof entry so `collectFailuresFromResult` picks it up.
7457
- function attachAcceptNotVerifiedFailure(run, result) {
7458
- if (!result || typeof result !== 'object') return;
7459
- if (result.ok === false) return;
7460
- if (Array.isArray(result.skipped) && result.skipped.length > 0) return;
7461
- if (isAcceptResultEffectivelyVerified(run, result)) return;
7462
- const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
7463
- const firstPath = expectedFiles.find(entry => entry && typeof entry.path === 'string')?.path
7464
- || (run?.undoTrackedChanges || []).find(change => change && typeof change.path === 'string')?.path
7465
- || '';
7466
- const failure = buildContentFailure('accept_not_verified', { path: firstPath, type: 'accept' }, {
7467
- changedDocument: true,
7468
- terminalState: 'needs_review',
7469
- evidence: {
7470
- acceptApplied: true,
7471
- verified: false,
7472
- expectedFileCount: expectedFiles.length,
7473
- trackedChangeCount: Array.isArray(run?.undoTrackedChanges) ? run.undoTrackedChanges.length : 0
7474
- }
7475
- });
7476
- const synthetic = {
7477
- operation: { path: firstPath, type: 'accept' },
7478
- result: {
7479
- ok: false,
7480
- code: 'accept_not_verified',
7481
- reason: failure.userMessage,
7482
- failure
7483
- }
7484
- };
7485
- if (!Array.isArray(result.skipped)) {
7486
- result.skipped = [];
7487
- }
7488
- result.skipped.push(synthetic);
7489
- result.ok = false;
7490
- }
7491
-
7492
- // Mirrors `isUndoResultEffectivelyApplied` for the accept side. Returns
7493
- // true when the page-bridge result carries proof that the run's tracked
7494
- // changes are gone: each tracked-change ref has a matching `applied` entry
7495
- // whose `result.verifiedContent` equals the run's known post-write content
7496
- // for that path. Returns false when proof is missing or contradicted —
7497
- // §9.6 `accept_not_verified`. Falls back to true when there are no
7498
- // expected files (defensive: the page bridge already returned ok, and
7499
- // there is nothing to verify against).
7500
- function isAcceptResultEffectivelyVerified(run, result) {
7501
- if (!result || typeof result !== 'object') return true;
7502
- const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
7503
- if (!expectedFiles.length) return true;
7504
- if (result.verified === true) return true;
7505
- const trackedChanges = Array.isArray(run?.undoTrackedChanges) ? run.undoTrackedChanges : [];
7506
- if (!trackedChanges.length) return true;
7507
- const applied = Array.isArray(result.applied) ? result.applied : [];
7508
- if (!applied.length) return false;
7509
- // Every tracked-change ref must show up in `applied` with a result
7510
- // carrying ok:true. If any tracked change does not appear as applied,
7511
- // proof is missing.
7512
- return trackedChanges.every(change => {
7513
- const key = change && (change.key || change.id || change.label);
7514
- if (!key) return false;
7515
- return applied.some(entry => {
7516
- const ref = entry && entry.trackedChange;
7517
- if (!ref) return false;
7518
- const refKey = ref.key || ref.id || ref.label;
7519
- if (refKey !== key) return false;
7520
- if (entry.result && entry.result.ok === false) return false;
7521
- return true;
7522
- });
7523
- });
7524
- }
7525
-
7526
7014
  // Translates the page bridge's Accept All `diagnostics` array into one
7527
7015
  // run-card event row per step. Each entry is `{ step, info }`; the step
7528
7016
  // names map 1:1 to the runAcceptTrackedStep* i18n keys. We render `info`
@@ -7532,14 +7020,14 @@
7532
7020
  for (const entry of diagnostics || []) {
7533
7021
  const step = entry && entry.step;
7534
7022
  const info = (entry && entry.info) || {};
7535
- const titleKey = ACCEPT_DIAGNOSTIC_TITLE_KEYS[step];
7023
+ const titleKey = WritebackSettlement.ACCEPT_DIAGNOSTIC_TITLE_KEYS[step];
7536
7024
  if (!titleKey) {
7537
7025
  continue;
7538
7026
  }
7539
7027
  const title = titleKey === 'runAcceptTrackedStepReplayStart' || titleKey === 'runAcceptTrackedStepReplayDone'
7540
7028
  ? tr(titleKey, { path: info.path || tr('unknownFile') })
7541
7029
  : tr(titleKey);
7542
- const status = acceptDiagnosticStatus(step, info);
7030
+ const status = WritebackSettlement.acceptDiagnosticStatus(step, info);
7543
7031
  appendRunRecordEvent(runId, {
7544
7032
  title,
7545
7033
  kind: 'activity',
@@ -7549,289 +7037,28 @@
7549
7037
  }
7550
7038
  }
7551
7039
 
7552
- // Maps each diagnostic step name to its i18n title key. Kept out of the
7553
- // function body so any future step rename surfaces as a missing entry.
7554
- const ACCEPT_DIAGNOSTIC_TITLE_KEYS = {
7555
- editorUndo: 'runAcceptTrackedStepEditorUndo',
7556
- modeBefore: 'runAcceptTrackedStepModeBefore',
7557
- forceEditing: 'runAcceptTrackedStepForceEditing',
7558
- replayStart: 'runAcceptTrackedStepReplayStart',
7559
- replayDone: 'runAcceptTrackedStepReplayDone',
7560
- restoreReviewing: 'runAcceptTrackedStepRestoreReviewing'
7561
- };
7562
-
7563
- function acceptDiagnosticStatus(step, info) {
7564
- if (step === 'modeBefore' || step === 'replayStart') {
7565
- // Pre-state reads: never a failure, only context.
7566
- return 'info';
7567
- }
7568
- if (step === 'editorUndo' || step === 'forceEditing' || step === 'restoreReviewing') {
7569
- return info && info.ok === true ? 'info' : 'failed';
7570
- }
7571
- if (step === 'replayDone') {
7572
- return info && info.ok === true ? 'info' : 'failed';
7573
- }
7574
- return 'info';
7575
- }
7576
-
7577
- // Drives a tracked-change-lifecycle run to a decisive terminal status when
7578
- // post-action proof is sufficient. Called by `applyAcceptSettlement` /
7579
- // `applyRejectSettlement` only after the §7 settlement matrix has cleared
7580
- // the run: if the page-layer returned per-op failures that imply unverified
7581
- // post-action state, the settlement helper routes to `needs_review` instead,
7582
- // and this helper is never called. The heavy payload is emptied here so
7583
- // stale refs never re-enter retention.
7584
- function applyTerminalTrackedChangeStatus(runId, status) {
7585
- const run = findRunRecord(runId);
7586
- if (!run || !TERMINAL_TRACKED_CHANGE_STATUS.has(status)) {
7587
- return;
7588
- }
7589
- run.trackedChangeStatus = status;
7590
- run.undoTrackedChanges = [];
7591
- run.undoExpectedFiles = [];
7592
- saveStateSoon();
7593
- refreshRunCardControls(runId);
7594
- }
7595
-
7596
- // Inline mirror of the content-side subset of the FailureReason §9 catalog
7597
- // used by T5 emit sites. Mirrors the page-side `PAGE_FAILURE_CATALOG` in
7598
- // `extension/src/page/writebackRouter.js` so neither runtime has to depend on
7599
- // load order with `shared/failureReasons.js`. Each entry mirrors stage /
7600
- // severity / defaultRetryable / fallbackUserMessage / fallbackNextAction
7601
- // from `FAILURE_CODE_CATALOG`; keep in sync when adding codes.
7602
- const CONTENT_FAILURE_CATALOG = {
7603
- project_snapshot_unavailable: {
7604
- stage: 'context', severity: 'error', defaultRetryable: true,
7605
- fallbackUserMessage: 'Codex could not read the Overleaf project snapshot.',
7606
- fallbackNextAction: 'Refresh Overleaf, then rerun the task.'
7607
- },
7608
- selected_context_unresolved: {
7609
- stage: 'context', severity: 'warning', defaultRetryable: true,
7610
- fallbackUserMessage: 'Codex could not resolve the requested selection or context.',
7611
- fallbackNextAction: 'Select the target again or specify the file/section explicitly.'
7612
- },
7613
- codex_no_usable_result: {
7614
- stage: 'codex', severity: 'error', defaultRetryable: true,
7615
- fallbackUserMessage: 'Local Codex returned no usable final report or operations.',
7616
- fallbackNextAction: 'Open Technical Details and resolve the local Codex error.'
7617
- },
7618
- codex_project_locked: {
7619
- stage: 'codex', severity: 'blocked', defaultRetryable: true,
7620
- fallbackUserMessage: 'Another Codex task is already running for this Overleaf project.',
7621
- fallbackNextAction: 'Wait for the active task to finish, or cancel it before retrying.'
7622
- },
7623
- storage_quota_exceeded: {
7624
- stage: 'storage', severity: 'warning', defaultRetryable: true,
7625
- fallbackUserMessage: 'Browser storage quota was exceeded.',
7626
- fallbackNextAction: 'Clear old run history or reduce attachments.'
7627
- },
7628
- native_bridge_unavailable: {
7629
- stage: 'native', severity: 'blocked', defaultRetryable: true,
7630
- fallbackUserMessage: 'Extension cannot connect to the Codex native host.',
7631
- fallbackNextAction: 'Run install-native or reload the extension.'
7632
- },
7633
- undo_not_verified: {
7634
- stage: 'undo', severity: 'warning', defaultRetryable: true,
7635
- fallbackUserMessage: 'Undo ran, but Codex could not prove the file returned to pre-run content.',
7636
- fallbackNextAction: 'Inspect the file manually before continuing.'
7637
- },
7638
- accept_not_verified: {
7639
- stage: 'accept', severity: 'warning', defaultRetryable: true,
7640
- fallbackUserMessage: 'Accept appeared to run but Codex could not prove final content/state.',
7641
- fallbackNextAction: 'Inspect Overleaf Reviewing before continuing.'
7642
- }
7643
- };
7644
-
7645
- // Build a structured FailureReason record (§7) for emit at the content
7646
- // runtime layer. `overrides` merges into the entry: callers supply `file` /
7647
- // `activeFile` / `userMessage` / `evidence` / `changedDocument` /
7648
- // `terminalState` etc. The `op` argument is the operation context that
7649
- // augments `file` / `operationType` when not explicitly overridden.
7650
- function buildContentFailure(code, op, overrides) {
7651
- // The local catalog holds content-runtime-specific codes; every other
7652
- // code (codex_not_found, codex_timeout, codex_output_limit,
7653
- // stale_source_changed, native_protocol_incompatible, ...) resolves
7654
- // through the shared §9 catalog. Without this fallback the function
7655
- // returned null for every shared-only code, so completion reports
7656
- // carried no structured failure and the recovery-action registry never
7657
- // fired for them (v1.7.6 fleet P1).
7658
- const entry = CONTENT_FAILURE_CATALOG[code] || FailureReasons?.FAILURE_CODE_CATALOG?.[code];
7659
- if (!entry) {
7660
- return null;
7661
- }
7662
- const merged = overrides || {};
7663
- const opCtx = op || {};
7664
- const failure = {
7040
+ function buildContentFailure(code, operation, overrides) {
7041
+ return WritebackSettlement.buildContentFailure(
7665
7042
  code,
7666
- stage: entry.stage,
7667
- severity: entry.severity,
7668
- userMessage: merged.userMessage || entry.fallbackUserMessage,
7669
- retryable: merged.retryable === undefined ? entry.defaultRetryable : merged.retryable === true,
7670
- nextAction: merged.nextAction || entry.fallbackNextAction
7671
- };
7672
- const file = merged.file !== undefined ? merged.file : opCtx.path;
7673
- if (file) failure.file = file;
7674
- const operationType = merged.operationType !== undefined ? merged.operationType : opCtx.type;
7675
- if (operationType) failure.operationType = operationType;
7676
- if (merged.activeFile !== undefined) failure.activeFile = merged.activeFile;
7677
- if (merged.changedDocument !== undefined) failure.changedDocument = merged.changedDocument === true;
7678
- if (merged.terminalState !== undefined) failure.terminalState = merged.terminalState;
7679
- if (merged.technicalMessage !== undefined) failure.technicalMessage = merged.technicalMessage;
7680
- if (merged.evidence !== undefined) failure.evidence = merged.evidence;
7681
- return failure;
7682
- }
7683
-
7684
- // Accept / Undo has a two-state user lifecycle: actionable before the page
7685
- // action, terminal after a successful page action. Warning-class verification
7686
- // evidence can still be recorded in technical details, but it must not keep
7687
- // the primary buttons actionable after Overleaf accepted the operation.
7688
-
7689
- // Codes that imply post-Accept proof is missing or contradicted. A page-side
7690
- // emitter (T4) is expected to fill these in; render-time normalization from
7691
- // the FailureReasons module repairs legacy `{ ok: false, code, reason }`
7692
- // shapes into the same structured failure record so this matrix works
7693
- // uniformly across both shapes.
7694
- const ACCEPT_NEEDS_REVIEW_CODES = new Set([
7695
- 'tracked_changes_remain',
7696
- 'accept_not_verified',
7697
- 'tracked_changes_created_unexpectedly',
7698
- 'accept_replay_created_tracked_changes',
7699
- 'write_observed_mismatch'
7700
- ]);
7701
-
7702
- const REJECT_NEEDS_REVIEW_CODES = new Set([
7703
- 'undo_not_verified',
7704
- 'undo_operation_failed',
7705
- 'undo_reviewing_restore_unverified',
7706
- 'tracked_change_nodes_not_identified',
7707
- 'tracked_changes_remain',
7708
- 'write_observed_mismatch'
7709
- ]);
7710
-
7711
- // Walks the page-layer result's applied/skipped lists, normalizing each
7712
- // item's `result` (whether structured `failure` or legacy `code` + `reason`)
7713
- // into a `FailureReason` record. Used by `applyAcceptSettlement` /
7714
- // `applyRejectSettlement` to decide between `accepted`/`rejected`,
7715
- // `needs_review`, and "stay pending" (blocked).
7716
- function collectFailuresFromResult(result) {
7717
- const failures = [];
7718
- if (!result || typeof result !== 'object') {
7719
- return failures;
7720
- }
7721
- if (FailureReasons && FailureReasons.normalizeFailureReason instanceof Function) {
7722
- for (const entry of Array.isArray(result.skipped) ? result.skipped : []) {
7723
- const inner = (entry && entry.result) || entry;
7724
- if (!inner || inner.ok === true) continue;
7725
- const operation = (entry && entry.operation) || (entry && entry.trackedChange ? { path: entry.trackedChange.path } : undefined);
7726
- failures.push(FailureReasons.normalizeFailureReason(inner, operation));
7727
- }
7728
- for (const entry of Array.isArray(result.applied) ? result.applied : []) {
7729
- const inner = entry && entry.result;
7730
- if (!inner || inner.ok !== false) continue;
7731
- const operation = (entry && entry.operation) || (entry && entry.trackedChange ? { path: entry.trackedChange.path } : undefined);
7732
- failures.push(FailureReasons.normalizeFailureReason(inner, operation));
7733
- }
7734
- } else {
7735
- // Defensive fallback: the FailureReasons module should be present in
7736
- // production (loaded by the content script bundle) but tests that strip
7737
- // it out should still see the legacy code-only path treated as a
7738
- // generic non-blocking failure.
7739
- for (const entry of Array.isArray(result.skipped) ? result.skipped : []) {
7740
- const inner = (entry && entry.result) || entry;
7741
- if (inner && inner.ok !== true) {
7742
- failures.push({
7743
- code: (inner && typeof inner.code === 'string' ? inner.code : '') || 'unknown_legacy_failure',
7744
- severity: 'error'
7745
- });
7746
- }
7747
- }
7748
- }
7749
- return failures;
7750
- }
7751
-
7752
- // Run-card settlement for Accept changes. Three branches:
7753
- // 1. The primary failure is `blocked` (e.g. preflight / navigation refused
7754
- // to touch Overleaf) — stay pending so the user can retry without the
7755
- // card claiming terminal accepted.
7756
- // 2. Any successful page-side accept work — settle as terminal `accepted`.
7757
- // 3. Otherwise, unverified/no-op failures can remain retryable.
7758
- function applyAcceptSettlement(runId, result) {
7759
- const failures = collectFailuresFromResult(result);
7760
- const primary = FailureReasons && FailureReasons.selectPrimaryFailure instanceof Function
7761
- ? FailureReasons.selectPrimaryFailure(failures)
7762
- : (failures[0] || null);
7763
- if (primary && primary.terminalState === 'blocked') {
7764
- // Stay pending — page bridge declined the operation before any document
7765
- // change. The preceding result event already showed the user the reason.
7766
- refreshRunCardControls(runId);
7767
- return;
7768
- }
7769
- if (isSuccessfulTrackedChangeSettlement(result)) {
7770
- applyTerminalTrackedChangeStatus(runId, 'accepted');
7771
- return;
7772
- }
7773
- const needsReview = failures.some(failure =>
7774
- failure.terminalState === 'needs_review' ||
7775
- ACCEPT_NEEDS_REVIEW_CODES.has(failure.code)
7043
+ operation,
7044
+ overrides,
7045
+ FailureReasons
7776
7046
  );
7777
- if (needsReview) {
7778
- applyNeedsReviewTrackedChangeStatus(runId);
7779
- return;
7780
- }
7781
- applyTerminalTrackedChangeStatus(runId, 'accepted');
7782
- }
7783
-
7784
- // Same three-branch shape as `applyAcceptSettlement`, using the undo-side
7785
- // unverified-proof codes only when no successful reject/undo work happened.
7786
- function applyRejectSettlement(runId, result) {
7787
- const failures = collectFailuresFromResult(result);
7788
- const primary = FailureReasons && FailureReasons.selectPrimaryFailure instanceof Function
7789
- ? FailureReasons.selectPrimaryFailure(failures)
7790
- : (failures[0] || null);
7791
- if (primary && primary.terminalState === 'blocked') {
7792
- refreshRunCardControls(runId);
7793
- return;
7794
- }
7795
- if (isSuccessfulTrackedChangeSettlement(result)) {
7796
- applyTerminalTrackedChangeStatus(runId, 'rejected');
7797
- return;
7798
- }
7799
- const needsReview = failures.some(failure =>
7800
- failure.terminalState === 'needs_review' ||
7801
- REJECT_NEEDS_REVIEW_CODES.has(failure.code)
7802
- );
7803
- if (needsReview) {
7804
- applyNeedsReviewTrackedChangeStatus(runId);
7805
- return;
7806
- }
7807
- applyTerminalTrackedChangeStatus(runId, 'rejected');
7808
- }
7809
-
7810
- function isSuccessfulTrackedChangeSettlement(result) {
7811
- if (!result || typeof result !== 'object') {
7812
- return false;
7813
- }
7814
- if (result.ok === true) {
7815
- return true;
7816
- }
7817
- if (!Array.isArray(result.applied)) {
7818
- return false;
7819
- }
7820
- return result.applied.some(entry => {
7821
- const inner = entry && (entry.result || entry);
7822
- return !inner || inner.ok !== false;
7823
- });
7824
7047
  }
7825
7048
 
7826
- // Sets `needs_review` on a run without emptying refs — the user is supposed
7827
- // to retry Accept or Undo after inspecting Overleaf, so the heavy payload
7828
- // (tracked-change refs + expected files) must survive.
7829
- function applyNeedsReviewTrackedChangeStatus(runId) {
7049
+ function applyTrackedChangeSettlement(runId, kind, result) {
7830
7050
  const run = findRunRecord(runId);
7831
- if (!run) {
7051
+ const settlement = WritebackSettlement.settleTrackedChangeLifecycle({
7052
+ kind,
7053
+ run,
7054
+ result,
7055
+ failureReasons: FailureReasons
7056
+ });
7057
+ if (settlement.decision === 'blocked') {
7058
+ refreshRunCardControls(runId);
7832
7059
  return;
7833
7060
  }
7834
- run.trackedChangeStatus = 'needs_review';
7061
+ Object.assign(run, WritebackSettlement.applySettlementTransition(run, settlement));
7835
7062
  saveStateSoon();
7836
7063
  refreshRunCardControls(runId);
7837
7064
  }
@@ -7908,13 +7135,7 @@
7908
7135
  // possibly-divergent base. This keeps wide paragraph patches safe: their
7909
7136
  // whole-paragraph `expected` would otherwise silently fail to re-apply.
7910
7137
  function attachVerifiedContentToOperation(operation, result) {
7911
- if (!operation || typeof operation !== 'object') {
7912
- return operation;
7913
- }
7914
- if (operation.type === 'edit' && typeof result?.verifiedContent === 'string') {
7915
- return { ...operation, verifiedContent: result.verifiedContent };
7916
- }
7917
- return operation;
7138
+ return WritebackSettlement.attachVerifiedContentToOperation(operation, result);
7918
7139
  }
7919
7140
 
7920
7141
  function recordUndoFromApply(project, applyResult) {
@@ -7938,8 +7159,8 @@
7938
7159
  ];
7939
7160
  record.appliedOperations = combinedAppliedOperations;
7940
7161
 
7941
- if (state.requireReviewing === true) {
7942
- const combinedTrackedChanges = mergeTrackedChanges([
7162
+ if ((currentRunView?.executionSnapshot?.requireReviewing ?? state.requireReviewing) === true) {
7163
+ const combinedTrackedChanges = normalizeApplyTrackedChanges([
7943
7164
  ...(Array.isArray(record.undoTrackedChanges) ? record.undoTrackedChanges : []),
7944
7165
  ...trackedChanges
7945
7166
  ]);
@@ -8011,49 +7232,11 @@
8011
7232
  }
8012
7233
 
8013
7234
  function normalizeApplyTrackedChanges(changes = []) {
8014
- const seen = new Set();
8015
- const normalized = [];
8016
- for (const change of changes || []) {
8017
- const key = typeof change?.key === 'string' ? change.key : '';
8018
- if (!key || seen.has(key)) {
8019
- continue;
8020
- }
8021
- seen.add(key);
8022
- normalized.push({
8023
- key,
8024
- id: typeof change.id === 'string' ? change.id : '',
8025
- path: typeof change.path === 'string' ? change.path : '',
8026
- label: typeof change.label === 'string' ? change.label : ''
8027
- });
8028
- }
8029
- return normalized;
8030
- }
8031
-
8032
- function mergeTrackedChanges(changes = []) {
8033
- return normalizeApplyTrackedChanges(changes);
7235
+ return WritebackSettlement.normalizeApplyTrackedChanges(changes);
8034
7236
  }
8035
7237
 
8036
7238
  function selectExpectedFilesForTrackedUndo(project, operations = [], trackedChanges = []) {
8037
- const paths = new Set();
8038
- for (const change of trackedChanges || []) {
8039
- if (change.path) {
8040
- paths.add(change.path);
8041
- }
8042
- }
8043
- for (const operation of operations || []) {
8044
- if (operation?.path) {
8045
- paths.add(operation.path);
8046
- }
8047
- if (operation?.to) {
8048
- paths.add(operation.to);
8049
- }
8050
- }
8051
- return (project?.files || [])
8052
- .filter(file => paths.has(file.path) && typeof file.content === 'string')
8053
- .map(file => ({
8054
- path: file.path,
8055
- content: file.content
8056
- }));
7239
+ return WritebackSettlement.selectExpectedFilesForTrackedUndo(project, operations, trackedChanges);
8057
7240
  }
8058
7241
 
8059
7242
  function buildTrackedUndoPostFiles(run) {
@@ -8136,6 +7319,21 @@
8136
7319
  refreshRunCardControls(runId);
8137
7320
  }
8138
7321
 
7322
+ function applyLegacyUndoSettlement(runId, undoStatus, result) {
7323
+ const run = findRunRecord(runId);
7324
+ if (!run) return;
7325
+ Object.assign(run, WritebackSettlement.applySettlementTransition(
7326
+ run,
7327
+ WritebackSettlement.settleLegacyUndo({
7328
+ run,
7329
+ status: undoStatus,
7330
+ result
7331
+ })
7332
+ ));
7333
+ saveStateSoon();
7334
+ refreshRunCardControls(runId);
7335
+ }
7336
+
8139
7337
  function refreshRunCardControls(runId) {
8140
7338
  const run = findRunRecord(runId);
8141
7339
  const root = panel?.querySelector(`[data-run-id="${cssEscape(runId)}"]`);
@@ -8290,13 +7488,16 @@
8290
7488
  const operations = input.operations || [];
8291
7489
  const applyResults = input.applyResults || [];
8292
7490
  const record = currentRunView?.recordId ? findRunRecord(currentRunView.recordId, currentRunView.sessionId) : null;
8293
- const undoCount = getRunUndoCount(record);
7491
+ const settlementProjection = WritebackSettlement.projectRunSettlement(record || {});
7492
+ const undoCount = settlementProjection.canUndo ? getRunUndoCount(record) : 0;
8294
7493
  const report = buildHumanCompletionReport({
8295
7494
  ...input,
8296
7495
  locale: getLocale(),
8297
7496
  operations,
8298
7497
  applyResults,
8299
7498
  undoCount,
7499
+ settlementProjection,
7500
+ failure: input.failure || settlementProjection.primaryFailure || undefined,
8300
7501
  includeWriteResult: true
8301
7502
  });
8302
7503
 
@@ -8315,7 +7516,7 @@
8315
7516
  // which recovery button to render. (Fixed in v1.7.5: callers passed
8316
7517
  // input.failure since v1.6.2 but it was never forwarded, so recovery
8317
7518
  // buttons on completion reports could never appear.)
8318
- failure: input.failure || undefined,
7519
+ failure: input.failure || settlementProjection.primaryFailure || undefined,
8319
7520
  compileErrors: input.compileErrors || undefined,
8320
7521
  rejectedHunks: input.rejectedHunks || currentRunView?.rejectedHunks || undefined
8321
7522
  });