codex-overleaf-link 1.8.6 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -21
- package/bin/codex-overleaf-link.mjs +58 -0
- package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
- package/extension/assets/icons/codex-overleaf-icon.png +0 -0
- package/extension/assets/icons/icon128.png +0 -0
- package/extension/assets/icons/icon16.png +0 -0
- package/extension/assets/icons/icon32.png +0 -0
- package/extension/assets/icons/icon48.png +0 -0
- package/extension/bootstrap/background.js +404 -0
- package/extension/bootstrap/manifest.template.json +51 -0
- package/extension/bootstrap/popup.css +26 -0
- package/extension/bootstrap/popup.html +38 -0
- package/extension/bootstrap/popup.js +44 -0
- package/extension/bootstrap/runtimeContext.js +2 -0
- package/extension/runtime-manifest.json +60 -0
- package/extension/src/background.js +532 -0
- package/extension/src/content/applyResultFormatters.js +387 -0
- package/extension/src/content/composerAttachments.js +522 -0
- package/extension/src/content/composerPanel.js +254 -0
- package/extension/src/content/contentRuntime.js +8897 -0
- package/extension/src/content/contextTray.js +686 -0
- package/extension/src/content/diagnosticsController.js +659 -0
- package/extension/src/content/diagnosticsPanel.js +342 -0
- package/extension/src/content/diffReviewPanel.js +797 -0
- package/extension/src/content/localSkillsPanel.js +280 -0
- package/extension/src/content/markdownText.js +606 -0
- package/extension/src/content/mirrorHealth.js +92 -0
- package/extension/src/content/modelPicker.js +489 -0
- package/extension/src/content/nativeChannel.js +62 -0
- package/extension/src/content/otWarmMirror.js +964 -0
- package/extension/src/content/otWarmMirrorController.js +174 -0
- package/extension/src/content/panelMaintenance.js +367 -0
- package/extension/src/content/panelRenderer.js +253 -0
- package/extension/src/content/recentProjects.js +1063 -0
- package/extension/src/content/reviewHunks.js +247 -0
- package/extension/src/content/runController.js +242 -0
- package/extension/src/content/runTimelineView.js +1131 -0
- package/extension/src/content/sessionManager.js +487 -0
- package/extension/src/content/sessionPanel.js +263 -0
- package/extension/src/content/settingsPanel.js +527 -0
- package/extension/src/content/themeController.js +91 -0
- package/extension/src/content/updateIdle.js +67 -0
- package/extension/src/content/writebackController.js +300 -0
- package/extension/src/content/writebackOrchestrator.js +850 -0
- package/extension/src/contentScript.js +8 -0
- package/extension/src/page/compileBridge.js +460 -0
- package/extension/src/page/overleafCapabilities.js +100 -0
- package/extension/src/page/overleafEditor.js +282 -0
- package/extension/src/page/overleafProjectSnapshot.js +234 -0
- package/extension/src/page/overleafRealtimeObserver.js +491 -0
- package/extension/src/page/pageBridgeCapability.js +71 -0
- package/extension/src/page/snapshotRouter.js +948 -0
- package/extension/src/page/trackedChangesLifecycle.js +1506 -0
- package/extension/src/page/treeOperations.js +1584 -0
- package/extension/src/page/writeGuard.js +142 -0
- package/extension/src/page/writebackRouter.js +1934 -0
- package/extension/src/pageBridge.js +2224 -0
- package/extension/src/popup.js +247 -0
- package/extension/src/shared/agentTranscript.js +18 -4
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/styles/panel.css +4048 -0
- package/native-host/src/index.js +9 -2
- package/native-host/src/managedInstall.js +393 -0
- package/native-host/src/managedLauncherRuntime.js +71 -0
- package/native-host/src/nativeHostPlatform.js +16 -0
- package/native-host/src/runtimeInstaller.js +6 -1
- package/native-host/src/taskRunnerRuntime.js +8 -0
- package/native-host/src/updateArchive.js +253 -0
- package/native-host/src/updateManager.js +548 -0
- package/native-host/src/updateTrust.js +156 -0
- package/package.json +11 -2
- package/scripts/install-managed.mjs +64 -0
- package/scripts/uninstall-managed.mjs +48 -0
|
@@ -0,0 +1,1934 @@
|
|
|
1
|
+
(function initCodexOverleafWritebackRouter(root, factory) {
|
|
2
|
+
if (typeof module === 'object' && module.exports) {
|
|
3
|
+
module.exports = factory();
|
|
4
|
+
} else {
|
|
5
|
+
root.CodexOverleafWritebackRouter = factory();
|
|
6
|
+
}
|
|
7
|
+
})(typeof window !== 'undefined' ? window : globalThis, function writebackRouterFactory() {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
function create(deps = {}) {
|
|
11
|
+
const window = deps.window || (typeof globalThis !== 'undefined' ? globalThis : {});
|
|
12
|
+
const treeOperations = deps.treeOperations || {};
|
|
13
|
+
const compileBridge = deps.compileBridge || { markSourceEdited() {} };
|
|
14
|
+
const projectSnapshotBridge = deps.projectSnapshotBridge || {};
|
|
15
|
+
const normalizeSafeProjectPath = deps.normalizeSafeProjectPath || fallbackNormalizeSafeProjectPath;
|
|
16
|
+
const writebackOpenSettleMs = Number.isFinite(Number(deps.writebackOpenSettleMs))
|
|
17
|
+
? Math.max(0, Number(deps.writebackOpenSettleMs))
|
|
18
|
+
: 1200;
|
|
19
|
+
const diagnosticsRevision = String(deps.diagnosticsRevision || '');
|
|
20
|
+
// Cross-world cancel signal reader. Returns the current page-bridge
|
|
21
|
+
// sequence number, which monotonically increments when content-side
|
|
22
|
+
// calls pageBridge.cancelActiveWrite. applyOperationsCore captures the
|
|
23
|
+
// baseline at start and re-reads between ops; any bump means the user
|
|
24
|
+
// cancelled and the remaining ops should be skipped with codex_cancelled.
|
|
25
|
+
const readWriteCancellationSequence = typeof deps.readWriteCancellationSequence === 'function'
|
|
26
|
+
? deps.readWriteCancellationSequence
|
|
27
|
+
: () => 0;
|
|
28
|
+
|
|
29
|
+
function invalidProjectPathResult(label = 'path') {
|
|
30
|
+
return deps.invalidProjectPathResult?.(label) || {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: 'invalid_project_path',
|
|
33
|
+
reason: 'Invalid ' + label + '.'
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ensureReviewing(params = {}) {
|
|
38
|
+
return deps.ensureReviewing?.(params) || Promise.resolve({ ok: false, reason: 'Reviewing controls are unavailable.' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ensureEditing(params = {}) {
|
|
42
|
+
return deps.ensureEditing?.(params) || Promise.resolve({ ok: false, reason: 'Editing controls are unavailable.' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getReviewingState(params = {}) {
|
|
46
|
+
return deps.getReviewingState?.(params) || { reviewing: { ok: false }, signals: {} };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isEditingConfirmedForNoTraceUndo(state = {}) {
|
|
50
|
+
return deps.isEditingConfirmedForNoTraceUndo?.(state) === true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Authoritative "is Reviewing / Track Changes positively ON" check, wired to
|
|
54
|
+
// the page bridge's aria-aware isReviewingConfirmedForWrite. Unlike a bare
|
|
55
|
+
// reviewing.ok read this does not treat a control merely *labelled*
|
|
56
|
+
// "Reviewing" (with no active aria) as proof that Track Changes is on, so it
|
|
57
|
+
// is the correct strict gate for the Accept replay's Editing-mode switch.
|
|
58
|
+
function isReviewingConfirmedForWrite(state = {}) {
|
|
59
|
+
return deps.isReviewingConfirmedForWrite?.(state) === true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function setReviewingEnabled(enabled, params = {}) {
|
|
63
|
+
return deps.setReviewingEnabled?.(enabled, params) || Promise.resolve({ ok: false, enabled });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function summarizeReviewingToggleResult(result = {}) {
|
|
67
|
+
return deps.summarizeReviewingToggleResult?.(result) || {
|
|
68
|
+
ok: result.ok === true,
|
|
69
|
+
changed: result.changed === true,
|
|
70
|
+
enabled: result.enabled === true,
|
|
71
|
+
code: result.code || '',
|
|
72
|
+
reason: result.reason || ''
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getActiveFilePath() {
|
|
77
|
+
return treeOperations.getActiveFilePath?.() || '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getActiveEditorIdentity() {
|
|
81
|
+
return deps.getActiveEditorIdentity?.() ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function activeEditorIdentityChanged(previous) {
|
|
85
|
+
// When no detector is wired (test/early-init), assume identity changed
|
|
86
|
+
// so callers re-run their per-op editor checks rather than silently
|
|
87
|
+
// reusing stale state.
|
|
88
|
+
return deps.activeEditorIdentityChanged
|
|
89
|
+
? deps.activeEditorIdentityChanged(previous)
|
|
90
|
+
: true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function openFileByPath(filePath, options = {}) {
|
|
94
|
+
return treeOperations.openFileByPath?.(filePath, options) || Promise.resolve({ ok: false, reason: 'Tree operations are unavailable' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function projectPathExists(filePath) {
|
|
98
|
+
return treeOperations.projectPathExists?.(filePath) === true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Distinct from `projectPathExists` (which boolean-collapses an
|
|
102
|
+
// unavailable implementation): returns true only when the underlying
|
|
103
|
+
// treeOperations actually exposes a projectPathExists hook. Used by
|
|
104
|
+
// emit sites that only want to fire target_file_not_found when we have
|
|
105
|
+
// a real path-existence check — older test harnesses do not stub the
|
|
106
|
+
// hook, and treating "no hook" as "path missing" would break them.
|
|
107
|
+
function hasProjectPathExistsHook() {
|
|
108
|
+
return treeOperations.projectPathExists instanceof Function;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function findFileTreeManager() {
|
|
112
|
+
return treeOperations.findFileTreeManager?.() || null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readActiveEditorText() {
|
|
116
|
+
return deps.readActiveEditorText?.() || '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function waitForActiveEditorText(filePath, timeoutMs, options = {}) {
|
|
120
|
+
if (treeOperations.waitForActiveEditorText) {
|
|
121
|
+
return treeOperations.waitForActiveEditorText(filePath, timeoutMs, options);
|
|
122
|
+
}
|
|
123
|
+
return Promise.resolve({
|
|
124
|
+
ok: true,
|
|
125
|
+
path: filePath || getActiveFilePath(),
|
|
126
|
+
text: readActiveEditorText()
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function contentSignature(content) {
|
|
131
|
+
if (treeOperations.contentSignature) {
|
|
132
|
+
return treeOperations.contentSignature(content);
|
|
133
|
+
}
|
|
134
|
+
const text = String(content ?? '');
|
|
135
|
+
return `${text.length}:${text.slice(0, 80)}:${text.slice(-80)}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function contentFingerprint(content) {
|
|
139
|
+
const text = String(content ?? '');
|
|
140
|
+
let hash = 2166136261;
|
|
141
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
142
|
+
hash ^= text.charCodeAt(index);
|
|
143
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
length: text.length,
|
|
147
|
+
normalizedLength: normalizeEditorTextForComparison(text).length,
|
|
148
|
+
hash: hash.toString(16).padStart(8, '0'),
|
|
149
|
+
blank: text.trim().length === 0
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function replaceActiveEditorText(text) {
|
|
154
|
+
return deps.replaceActiveEditorText?.(text) || { ok: false, reason: 'Editor adapter is unavailable' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function replaceActiveEditorPatches(patches, nextContent) {
|
|
158
|
+
return deps.replaceActiveEditorPatches?.(patches, nextContent) || { ok: false, reason: 'Editor adapter is unavailable' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function collectElements(selector, limit) {
|
|
162
|
+
return deps.collectElements?.(selector, limit) || [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function uniqueNodes(nodes) {
|
|
166
|
+
return deps.uniqueNodes?.(nodes) || Array.from(new Set(nodes || []));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readNodeSignalText(node) {
|
|
170
|
+
return deps.readNodeSignalText?.(node) || '';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isInsideCodexPanel(node) {
|
|
174
|
+
return deps.isInsideCodexPanel?.(node) === true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeReviewingSignalText(value) {
|
|
178
|
+
return deps.normalizeReviewingSignalText?.(value) || String(value || '').replace(/\s+/g, ' ').trim();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function clickNode(node) {
|
|
182
|
+
if (deps.clickNode) {
|
|
183
|
+
deps.clickNode(node);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (node?.click) {
|
|
187
|
+
node.click();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
node?.dispatchEvent?.(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function delay(ms) {
|
|
194
|
+
if (deps.delay) {
|
|
195
|
+
return deps.delay(ms);
|
|
196
|
+
}
|
|
197
|
+
return new Promise(resolve => window.setTimeout(resolve, ms));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function compact(value, limit) {
|
|
201
|
+
if (deps.compact) {
|
|
202
|
+
return deps.compact(value, limit);
|
|
203
|
+
}
|
|
204
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
205
|
+
return text.length > limit ? text.slice(0, limit - 3) + '...' : text;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildWritebackDebug(stage, operation, baseFileLookup, currentText, extra = {}) {
|
|
209
|
+
const filePath = normalizeSafeProjectPath(operation?.path || '');
|
|
210
|
+
const baseKnown = Boolean(baseFileLookup && filePath && baseFileLookup.has(filePath));
|
|
211
|
+
const baseContent = baseKnown ? baseFileLookup.get(filePath) : '';
|
|
212
|
+
return {
|
|
213
|
+
stage,
|
|
214
|
+
revision: diagnosticsRevision,
|
|
215
|
+
operationPath: filePath,
|
|
216
|
+
operationType: operation?.type || '',
|
|
217
|
+
activePath: getActiveFilePath(),
|
|
218
|
+
current: contentFingerprint(currentText),
|
|
219
|
+
baseKnown,
|
|
220
|
+
base: baseKnown ? contentFingerprint(baseContent) : null,
|
|
221
|
+
...extra
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function attachWritebackDebug(result, stage, operation, baseFileLookup, currentText, extra = {}) {
|
|
226
|
+
if (!result || typeof result !== 'object') {
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
...result,
|
|
231
|
+
debug: buildWritebackDebug(stage, operation, baseFileLookup, currentText, extra)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Inline mirror of the page-side subset of the FailureReason §9 catalog used by
|
|
236
|
+
// T4 emit sites. Keeping it inline avoids an extra script-load dependency for
|
|
237
|
+
// the page-injected bundle; the catalog itself is duplicated in
|
|
238
|
+
// shared/failureReasons.js (FAILURE_CODE_CATALOG) — keep in sync when adding
|
|
239
|
+
// codes. Each entry mirrors stage / severity / defaultRetryable /
|
|
240
|
+
// fallbackUserMessage / fallbackNextAction.
|
|
241
|
+
const PAGE_FAILURE_CATALOG = {
|
|
242
|
+
target_file_not_found: {
|
|
243
|
+
stage: 'navigation', severity: 'blocked', defaultRetryable: true,
|
|
244
|
+
fallbackUserMessage: 'Codex could not find the target file in this Overleaf project.',
|
|
245
|
+
fallbackNextAction: 'Check the file name/path in Overleaf and retry.'
|
|
246
|
+
},
|
|
247
|
+
target_file_open_failed: {
|
|
248
|
+
stage: 'navigation', severity: 'blocked', defaultRetryable: true,
|
|
249
|
+
fallbackUserMessage: 'Codex could not open the target file in Overleaf.',
|
|
250
|
+
fallbackNextAction: 'Expand the folder or manually open the file, then retry.'
|
|
251
|
+
},
|
|
252
|
+
target_file_not_active: {
|
|
253
|
+
stage: 'navigation', severity: 'blocked', defaultRetryable: true,
|
|
254
|
+
fallbackUserMessage: 'Codex tried to write the target file, but another file was active at write time.',
|
|
255
|
+
fallbackNextAction: 'Open the target file in Overleaf, then retry this run.'
|
|
256
|
+
},
|
|
257
|
+
target_editor_not_ready: {
|
|
258
|
+
stage: 'navigation', severity: 'blocked', defaultRetryable: true,
|
|
259
|
+
fallbackUserMessage: 'Target file is active but the editor was not ready before timeout.',
|
|
260
|
+
fallbackNextAction: 'Wait for Overleaf to finish loading, then retry.'
|
|
261
|
+
},
|
|
262
|
+
stale_source_changed: {
|
|
263
|
+
stage: 'preflight', severity: 'blocked', defaultRetryable: true,
|
|
264
|
+
fallbackUserMessage: 'The file changed while Codex was working.',
|
|
265
|
+
fallbackNextAction: 'Review the current file, then rerun the task.'
|
|
266
|
+
},
|
|
267
|
+
patch_anchor_not_found: {
|
|
268
|
+
stage: 'preflight', severity: 'blocked', defaultRetryable: true,
|
|
269
|
+
fallbackUserMessage: 'The edit anchor no longer matches current Overleaf content.',
|
|
270
|
+
fallbackNextAction: 'Rerun the task against the current document.'
|
|
271
|
+
},
|
|
272
|
+
write_observed_mismatch: {
|
|
273
|
+
stage: 'verify', severity: 'error', defaultRetryable: false,
|
|
274
|
+
fallbackUserMessage: 'Codex attempted to write, but the content read back from Overleaf did not match the approved change.',
|
|
275
|
+
fallbackNextAction: 'Open Technical Details and compare expected vs observed.'
|
|
276
|
+
},
|
|
277
|
+
editing_not_confirmed: {
|
|
278
|
+
stage: 'reviewing', severity: 'blocked', defaultRetryable: true,
|
|
279
|
+
fallbackUserMessage: 'Editing mode could not be proven stable.',
|
|
280
|
+
fallbackNextAction: 'Do not write; check the mode selector and retry.'
|
|
281
|
+
},
|
|
282
|
+
tracked_changes_remain: {
|
|
283
|
+
stage: 'reviewing', severity: 'warning', defaultRetryable: true,
|
|
284
|
+
fallbackUserMessage: 'Accept/reject operation finished but tracked changes remain.',
|
|
285
|
+
fallbackNextAction: 'Open Overleaf Reviewing and inspect remaining changes.'
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Build a structured FailureReason record (§7) for emit at the page layer.
|
|
290
|
+
// `overrides` merges into the entry: callers supply `file` / `activeFile` /
|
|
291
|
+
// `userMessage` / `evidence` / `changedDocument` / `terminalState` etc.
|
|
292
|
+
function buildPageFailure(code, overrides) {
|
|
293
|
+
const entry = PAGE_FAILURE_CATALOG[code];
|
|
294
|
+
if (!entry) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const merged = overrides || {};
|
|
298
|
+
const failure = {
|
|
299
|
+
code,
|
|
300
|
+
stage: entry.stage,
|
|
301
|
+
severity: entry.severity,
|
|
302
|
+
userMessage: merged.userMessage || entry.fallbackUserMessage,
|
|
303
|
+
retryable: merged.retryable === undefined ? entry.defaultRetryable : merged.retryable === true,
|
|
304
|
+
nextAction: merged.nextAction || entry.fallbackNextAction
|
|
305
|
+
};
|
|
306
|
+
if (merged.file !== undefined) failure.file = merged.file;
|
|
307
|
+
if (merged.activeFile !== undefined) failure.activeFile = merged.activeFile;
|
|
308
|
+
if (merged.operationType !== undefined) failure.operationType = merged.operationType;
|
|
309
|
+
if (merged.changedDocument !== undefined) failure.changedDocument = merged.changedDocument === true;
|
|
310
|
+
if (merged.terminalState !== undefined) failure.terminalState = merged.terminalState;
|
|
311
|
+
if (merged.technicalMessage !== undefined) failure.technicalMessage = merged.technicalMessage;
|
|
312
|
+
if (merged.evidence !== undefined) failure.evidence = merged.evidence;
|
|
313
|
+
return failure;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeTextPatches(patches, length) {
|
|
317
|
+
if (deps.normalizeTextPatches) {
|
|
318
|
+
return deps.normalizeTextPatches(patches, length);
|
|
319
|
+
}
|
|
320
|
+
const normalized = [];
|
|
321
|
+
let previousTo = 0;
|
|
322
|
+
for (const rawPatch of patches || []) {
|
|
323
|
+
const from = Number(rawPatch?.from);
|
|
324
|
+
const to = Number(rawPatch?.to);
|
|
325
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to > length) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
code: 'invalid_patch',
|
|
329
|
+
reason: 'Codex generated an invalid patch range.'
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (from < previousTo) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
code: 'invalid_patch',
|
|
336
|
+
reason: 'Codex generated overlapping patch ranges.'
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
previousTo = to;
|
|
340
|
+
normalized.push({
|
|
341
|
+
from,
|
|
342
|
+
to,
|
|
343
|
+
expected: String(rawPatch.expected ?? ''),
|
|
344
|
+
insert: String(rawPatch.insert ?? '')
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
patches: normalized
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function applyOperations(operations, options = {}) {
|
|
354
|
+
if (options.reviewingPolicy === 'no-trace-undo') {
|
|
355
|
+
return applyOperationsWithNoTraceUndo(operations, options);
|
|
356
|
+
}
|
|
357
|
+
const hasOperations = (operations || []).length > 0;
|
|
358
|
+
const trackReviewingChanges = options.requireReviewing === true && hasOperations;
|
|
359
|
+
let reviewingPolicy = null;
|
|
360
|
+
if (options.requireReviewing === true && hasOperations) {
|
|
361
|
+
const reviewing = await ensureReviewing({ waitMs: 1800 });
|
|
362
|
+
if (!reviewing.ok) {
|
|
363
|
+
return buildReviewingRequiredBlockedResult(operations, reviewing);
|
|
364
|
+
}
|
|
365
|
+
} else if (options.requireEditing === true && hasOperations) {
|
|
366
|
+
const editing = await ensureEditing({ waitMs: 1800 });
|
|
367
|
+
if (!editing.ok) {
|
|
368
|
+
return buildEditingRequiredBlockedResult(operations, editing);
|
|
369
|
+
}
|
|
370
|
+
reviewingPolicy = {
|
|
371
|
+
policy: 'editing-write',
|
|
372
|
+
disabled: editing.activated === true,
|
|
373
|
+
leftEditing: true,
|
|
374
|
+
reason: editing.activated ? 'switched_to_editing_before_write' : 'editing_already_confirmed',
|
|
375
|
+
disable: summarizeReviewingToggleResult({
|
|
376
|
+
ok: true,
|
|
377
|
+
changed: editing.activated === true,
|
|
378
|
+
enabled: false,
|
|
379
|
+
reason: editing.activated ? 'Switched to Editing before untracked write.' : 'Editing already confirmed before untracked write.'
|
|
380
|
+
})
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const result = await applyOperationsCore(operations, {
|
|
384
|
+
...options,
|
|
385
|
+
trackReviewingChanges
|
|
386
|
+
});
|
|
387
|
+
return reviewingPolicy
|
|
388
|
+
? {
|
|
389
|
+
...result,
|
|
390
|
+
reviewingPolicy
|
|
391
|
+
}
|
|
392
|
+
: result;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function applyOperationsWithNoTraceUndo(operations, options = {}) {
|
|
396
|
+
const initial = getReviewingState({});
|
|
397
|
+
if (isEditingConfirmedForNoTraceUndo(initial)) {
|
|
398
|
+
const result = await applyOperationsCore(operations, options);
|
|
399
|
+
return {
|
|
400
|
+
...result,
|
|
401
|
+
reviewingPolicy: {
|
|
402
|
+
policy: 'no-trace-undo',
|
|
403
|
+
disabled: false,
|
|
404
|
+
restored: false,
|
|
405
|
+
reason: 'editing_already_confirmed'
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const disabled = await setReviewingEnabled(false, { waitMs: 1800 });
|
|
411
|
+
if (!disabled.ok) {
|
|
412
|
+
return buildNoTraceUndoBlockedResult(operations, disabled);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let result;
|
|
416
|
+
let applyError = null;
|
|
417
|
+
try {
|
|
418
|
+
result = await applyOperationsCore(operations, options);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
applyError = error;
|
|
421
|
+
}
|
|
422
|
+
if (applyError) {
|
|
423
|
+
throw applyError;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
...result,
|
|
428
|
+
reviewingPolicy: {
|
|
429
|
+
policy: 'no-trace-undo',
|
|
430
|
+
disabled: true,
|
|
431
|
+
restored: false,
|
|
432
|
+
leftEditing: true,
|
|
433
|
+
reason: 'left_editing_after_undo',
|
|
434
|
+
disable: summarizeReviewingToggleResult(disabled)
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function applyOperationsCore(operations, options = {}) {
|
|
440
|
+
const applied = [];
|
|
441
|
+
const skipped = [];
|
|
442
|
+
const trackedChanges = [];
|
|
443
|
+
const safeBaseFiles = normalizeBaseFilesForSafety(options.baseFiles);
|
|
444
|
+
const baseFileLookup = window.CodexOverleafStaleGuard?.buildBaseFileLookup(safeBaseFiles);
|
|
445
|
+
const baseBinaryFileLookup = buildBaseBinaryFileLookup(safeBaseFiles);
|
|
446
|
+
// Mid-run project-change guard. The pageBridge wrapper already ran the
|
|
447
|
+
// writeGuard once at entry, but each per-op step here can take 0.5-5s
|
|
448
|
+
// (openFileByPath, waitForActiveEditorText, save-state verification),
|
|
449
|
+
// so a multi-op write can span 10-30 seconds total. If the user SPA-
|
|
450
|
+
// navigates to a different project mid-flight, the remaining operations
|
|
451
|
+
// would land in the wrong project. Re-check the editor project before
|
|
452
|
+
// each op and short-circuit the rest of the queue when it diverges.
|
|
453
|
+
const runProjectId = typeof options.runProjectId === 'string' ? options.runProjectId : '';
|
|
454
|
+
const writeGuardSurface = window.CodexOverleafWriteGuard?.create({
|
|
455
|
+
window,
|
|
456
|
+
document: window.document,
|
|
457
|
+
treeOperations
|
|
458
|
+
});
|
|
459
|
+
// Cross-world cancel baseline. The user clicks cancel during writeback →
|
|
460
|
+
// content-side calls pageBridge.cancelActiveWrite → the sequence number
|
|
461
|
+
// bumps. Two layers of responsiveness:
|
|
462
|
+
// 1. Between-ops check — short-circuits the next iteration so the
|
|
463
|
+
// remaining ops never start.
|
|
464
|
+
// 2. Race the in-flight op against a cancellation poller (50ms
|
|
465
|
+
// interval) so a click DURING a slow op (e.g. inside a 5s
|
|
466
|
+
// waitForActiveFile poll) is observed within ~50ms instead of
|
|
467
|
+
// having to wait out the op's internal timeout. The op promise
|
|
468
|
+
// keeps running in the page world after the race resolves — its
|
|
469
|
+
// eventual result is ignored. This is "user-perceived instant
|
|
470
|
+
// cancel": the spinner stops, the run settles to cancelled, and
|
|
471
|
+
// any DOM mutation already in flight on the op cannot be undone
|
|
472
|
+
// from here anyway.
|
|
473
|
+
const cancelBaselineSequence = readWriteCancellationSequence();
|
|
474
|
+
const cancelledSkipResult = {
|
|
475
|
+
ok: false,
|
|
476
|
+
code: 'codex_cancelled',
|
|
477
|
+
reason: 'The Codex run was cancelled by the user while the writeback was in flight; remaining operations were not applied.',
|
|
478
|
+
failure: {
|
|
479
|
+
code: 'codex_run_cancelled',
|
|
480
|
+
stage: 'write',
|
|
481
|
+
severity: 'info',
|
|
482
|
+
userMessage: 'The Codex run was cancelled by the user during writeback; remaining file operations were skipped.',
|
|
483
|
+
nextAction: 'Start a new run if you want to apply these changes.',
|
|
484
|
+
retryable: true,
|
|
485
|
+
terminalState: 'cancelled',
|
|
486
|
+
changedDocument: false
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
const cancelledInFlightResult = {
|
|
490
|
+
...cancelledSkipResult,
|
|
491
|
+
reason: 'The Codex run was cancelled while an Overleaf write operation was already in flight. The editor may have changed; review the current file before starting a new run.',
|
|
492
|
+
failure: {
|
|
493
|
+
...cancelledSkipResult.failure,
|
|
494
|
+
severity: 'warning',
|
|
495
|
+
userMessage: 'The Codex run was cancelled during an in-flight write. The editor may already contain part of that write.',
|
|
496
|
+
nextAction: 'Review the current Overleaf file before starting a new run.',
|
|
497
|
+
changedDocument: true
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
// 50ms-poller racer using recursive setTimeout. setInterval is NOT
|
|
501
|
+
// exposed in the test VM context that hosts pageBridge (only setTimeout /
|
|
502
|
+
// clearTimeout are wired), so this implementation must avoid it. Each
|
|
503
|
+
// tick re-schedules itself only while the racer has not resolved;
|
|
504
|
+
// dispose() flips a flag so any tick that fires after dispose is a no-op.
|
|
505
|
+
// Resolves the first time the cancellation sequence bumps past baseline.
|
|
506
|
+
function createCancellationRacer() {
|
|
507
|
+
let resolved = false;
|
|
508
|
+
let resolveFn;
|
|
509
|
+
const promise = new Promise(resolve => { resolveFn = resolve; });
|
|
510
|
+
let timer = null;
|
|
511
|
+
function tick() {
|
|
512
|
+
if (resolved) return;
|
|
513
|
+
if (readWriteCancellationSequence() !== cancelBaselineSequence) {
|
|
514
|
+
resolved = true;
|
|
515
|
+
resolveFn();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
timer = window.setTimeout(tick, 50);
|
|
519
|
+
}
|
|
520
|
+
timer = window.setTimeout(tick, 50);
|
|
521
|
+
return {
|
|
522
|
+
promise,
|
|
523
|
+
dispose() {
|
|
524
|
+
resolved = true;
|
|
525
|
+
if (timer) window.clearTimeout(timer);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
const CANCELLED_RACE_SENTINEL = Symbol('writebackRouter.cancelled');
|
|
530
|
+
async function raceOpAgainstCancellation(opPromise) {
|
|
531
|
+
const racer = createCancellationRacer();
|
|
532
|
+
try {
|
|
533
|
+
return await Promise.race([
|
|
534
|
+
opPromise,
|
|
535
|
+
racer.promise.then(() => CANCELLED_RACE_SENTINEL)
|
|
536
|
+
]);
|
|
537
|
+
} finally {
|
|
538
|
+
racer.dispose();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
for (const rawOperation of operations) {
|
|
543
|
+
// Cross-world cancel check. Cheap (synchronous read of a counter),
|
|
544
|
+
// runs before the writeGuard re-check so a click-cancel that arrived
|
|
545
|
+
// during the previous op's await microtasks short-circuits before we
|
|
546
|
+
// burn time on guard / open-file work for the next op.
|
|
547
|
+
if (readWriteCancellationSequence() !== cancelBaselineSequence) {
|
|
548
|
+
skipped.push({ operation: normalizeOperationPaths(rawOperation), result: cancelledSkipResult });
|
|
549
|
+
const remainingForCancel = operations.slice(operations.indexOf(rawOperation) + 1);
|
|
550
|
+
for (const tailOperation of remainingForCancel) {
|
|
551
|
+
skipped.push({ operation: normalizeOperationPaths(tailOperation), result: cancelledSkipResult });
|
|
552
|
+
}
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
// Per-op re-check: if a runProjectId was supplied and the page-side
|
|
556
|
+
// guard is available, verify the editor still shows the same project
|
|
557
|
+
// before committing the next write. Mismatch → push aborted_project_changed
|
|
558
|
+
// for THIS op and every remaining op in the queue, then return.
|
|
559
|
+
if (runProjectId && writeGuardSurface) {
|
|
560
|
+
const guardBlock = await writeGuardSurface.runWriteGuard({ runProjectId });
|
|
561
|
+
if (guardBlock) {
|
|
562
|
+
// First push the guard's structured skip for the current op so the
|
|
563
|
+
// caller can attribute the abort to a real operation path, then
|
|
564
|
+
// pad the remaining ops with the same failure shape.
|
|
565
|
+
const guardFailure = guardBlock.skipped[0].result;
|
|
566
|
+
skipped.push({ operation: normalizeOperationPaths(rawOperation), result: guardFailure });
|
|
567
|
+
const remaining = operations.slice(operations.indexOf(rawOperation) + 1);
|
|
568
|
+
for (const tailOperation of remaining) {
|
|
569
|
+
skipped.push({ operation: normalizeOperationPaths(tailOperation), result: guardFailure });
|
|
570
|
+
}
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const operation = normalizeOperationPaths(rawOperation);
|
|
575
|
+
const pathSafety = validateOperationProjectPaths(operation);
|
|
576
|
+
if (!pathSafety.ok) {
|
|
577
|
+
skipped.push({ operation, result: pathSafety });
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
let raceResult;
|
|
581
|
+
if (operation.type === 'edit') {
|
|
582
|
+
raceResult = await raceOpAgainstCancellation(applyEditOperation(operation, {
|
|
583
|
+
baseFileLookup,
|
|
584
|
+
trackReviewingChanges: options.trackReviewingChanges === true
|
|
585
|
+
}));
|
|
586
|
+
} else if (['binary-create', 'overwrite-binary'].includes(operation.type)) {
|
|
587
|
+
raceResult = await raceOpAgainstCancellation(applyBinaryAssetOperation(operation, { baseFileLookup, baseBinaryFileLookup }));
|
|
588
|
+
} else if (['create', 'rename', 'move', 'delete'].includes(operation.type)) {
|
|
589
|
+
raceResult = await raceOpAgainstCancellation(applyFileTreeOperation(operation, { baseFileLookup }));
|
|
590
|
+
} else {
|
|
591
|
+
skipped.push({
|
|
592
|
+
operation,
|
|
593
|
+
result: {
|
|
594
|
+
ok: false,
|
|
595
|
+
reason: `Unsupported operation type: ${operation.type}`
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
// Cancellation race winner: skip current op + the rest of the queue.
|
|
601
|
+
// The op promise above keeps running in background; its eventual
|
|
602
|
+
// result is discarded. The user perceives instant cancel because we
|
|
603
|
+
// settle the loop here instead of waiting for the op to finish.
|
|
604
|
+
if (raceResult === CANCELLED_RACE_SENTINEL) {
|
|
605
|
+
skipped.push({ operation, result: cancelledInFlightResult });
|
|
606
|
+
const remainingAfterCancel = operations.slice(operations.indexOf(rawOperation) + 1);
|
|
607
|
+
for (const tailOperation of remainingAfterCancel) {
|
|
608
|
+
skipped.push({ operation: normalizeOperationPaths(tailOperation), result: cancelledSkipResult });
|
|
609
|
+
}
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
const result = raceResult;
|
|
613
|
+
if (operation.type === 'edit' && result.ok && Array.isArray(result.trackedChanges)) {
|
|
614
|
+
trackedChanges.push(...result.trackedChanges);
|
|
615
|
+
}
|
|
616
|
+
(result.ok ? applied : skipped).push({ operation, result });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (applied.length > 0) {
|
|
620
|
+
compileBridge.markSourceEdited();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
ok: skipped.length === 0,
|
|
625
|
+
applied,
|
|
626
|
+
skipped,
|
|
627
|
+
trackedChanges: mergeTrackedChangeRefs(trackedChanges)
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
async function applyEditOperation(operation, options = {}) {
|
|
656
|
+
const currentPath = getActiveFilePath();
|
|
657
|
+
const editorReady = await ensureEditorReadyForOperation(operation, options.baseFileLookup, currentPath);
|
|
658
|
+
if (!editorReady.ok) {
|
|
659
|
+
return editorReady;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const trackReviewingChanges = options.trackReviewingChanges === true;
|
|
663
|
+
const forbidTrackedChanges = options.forbidTrackedChanges === true;
|
|
664
|
+
const observeTrackedChanges = trackReviewingChanges || forbidTrackedChanges;
|
|
665
|
+
const operationPaths = collectOperationPaths([operation]);
|
|
666
|
+
const trackedBefore = observeTrackedChanges
|
|
667
|
+
? collectTrackedChangeRefsForPaths(operationPaths)
|
|
668
|
+
: [];
|
|
669
|
+
let current = editorReady.text;
|
|
670
|
+
let freshness = window.CodexOverleafStaleGuard?.checkOperationFreshness(
|
|
671
|
+
operation,
|
|
672
|
+
current,
|
|
673
|
+
options.baseFileLookup
|
|
674
|
+
) || { ok: true };
|
|
675
|
+
if (!freshness.ok) {
|
|
676
|
+
const ready = await waitForFreshEditorTextForOperation(operation, options.baseFileLookup, 1500);
|
|
677
|
+
if (!ready.ok) {
|
|
678
|
+
const decorated = attachWritebackDebug(freshness, 'stale_guard', operation, options.baseFileLookup, current, {
|
|
679
|
+
initialActivePath: currentPath,
|
|
680
|
+
editorReadyDebug: editorReady.debug || null,
|
|
681
|
+
waitFresh: ready
|
|
682
|
+
});
|
|
683
|
+
// §9.2: legacy stale_snapshot / stale_patch_range / missing_base_file
|
|
684
|
+
// map to canonical preflight failure codes. The structured failure
|
|
685
|
+
// attaches the per-emit-site evidence so the run card / final report
|
|
686
|
+
// can render with file context without round-tripping through the
|
|
687
|
+
// normalizer's legacy mapping.
|
|
688
|
+
const staleCode = freshness.code === 'missing_base_file'
|
|
689
|
+
? 'missing_base_file'
|
|
690
|
+
: freshness.code === 'stale_patch_range'
|
|
691
|
+
? 'patch_anchor_not_found'
|
|
692
|
+
: 'stale_source_changed';
|
|
693
|
+
if (staleCode === 'stale_source_changed' || staleCode === 'patch_anchor_not_found') {
|
|
694
|
+
decorated.failure = buildPageFailure(staleCode, {
|
|
695
|
+
file: operation?.path || '',
|
|
696
|
+
operationType: operation?.type || 'edit',
|
|
697
|
+
changedDocument: false,
|
|
698
|
+
userMessage: staleCode === 'stale_source_changed'
|
|
699
|
+
? `${operation?.path || 'The target file'} changed while Codex was working, so Codex did not overwrite it.`
|
|
700
|
+
: `${operation?.path || 'The target file'} no longer contains the text Codex expected to edit, so Codex did not overwrite it.`,
|
|
701
|
+
technicalMessage: freshness.reason || '',
|
|
702
|
+
evidence: {
|
|
703
|
+
originalCode: freshness.code || '',
|
|
704
|
+
baselineMatched: false,
|
|
705
|
+
writeStarted: false
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
return decorated;
|
|
710
|
+
}
|
|
711
|
+
current = ready.text;
|
|
712
|
+
freshness = { ok: true };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
let nextContent = null;
|
|
716
|
+
if (Array.isArray(operation.patches) && operation.patches.length) {
|
|
717
|
+
const patched = applyTextPatches(current, operation.patches);
|
|
718
|
+
if (!patched.ok) {
|
|
719
|
+
const decorated = attachWritebackDebug(patched, 'apply_text_patches', operation, options.baseFileLookup, current, {
|
|
720
|
+
initialActivePath: currentPath,
|
|
721
|
+
editorReadyDebug: editorReady.debug || null
|
|
722
|
+
});
|
|
723
|
+
// §9.2: stale_patch (a patch `expected` slice did not match the
|
|
724
|
+
// current document) is the canonical patch_anchor_not_found case.
|
|
725
|
+
// invalid_patch (range out of bounds / overlapping) also belongs to
|
|
726
|
+
// patch_anchor_not_found per §9.2: the anchor cannot be aligned to
|
|
727
|
+
// current content.
|
|
728
|
+
if (patched.code === 'stale_patch' || patched.code === 'invalid_patch') {
|
|
729
|
+
decorated.failure = buildPageFailure('patch_anchor_not_found', {
|
|
730
|
+
file: operation?.path || '',
|
|
731
|
+
operationType: operation?.type || 'edit',
|
|
732
|
+
changedDocument: false,
|
|
733
|
+
userMessage: `Codex's edit anchors no longer match ${operation?.path || 'the current document'}, so Codex did not overwrite it.`,
|
|
734
|
+
technicalMessage: patched.reason || '',
|
|
735
|
+
evidence: {
|
|
736
|
+
originalCode: patched.code || '',
|
|
737
|
+
writeStarted: false
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
return decorated;
|
|
742
|
+
}
|
|
743
|
+
nextContent = patched.text;
|
|
744
|
+
} else if (typeof operation.replaceAll === 'string') {
|
|
745
|
+
nextContent = operation.replaceAll;
|
|
746
|
+
} else if (typeof operation.find === 'string' && typeof operation.replace === 'string') {
|
|
747
|
+
if (!current.includes(operation.find)) {
|
|
748
|
+
return {
|
|
749
|
+
ok: false,
|
|
750
|
+
reason: 'Find text was not present in the active editor'
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
nextContent = current.split(operation.find).join(operation.replace);
|
|
754
|
+
} else {
|
|
755
|
+
return {
|
|
756
|
+
ok: false,
|
|
757
|
+
reason: 'Edit operation must provide patches, replaceAll, or find/replace fields'
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const result = Array.isArray(operation.patches) && operation.patches.length
|
|
762
|
+
? replaceActiveEditorPatches(operation.patches, nextContent)
|
|
763
|
+
: replaceActiveEditorText(nextContent);
|
|
764
|
+
if (!result.ok) {
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const verified = await verifyActiveEditorText(nextContent, operation.path);
|
|
769
|
+
if (!verified.ok) {
|
|
770
|
+
const decorated = attachWritebackDebug(verified, 'write_verification', operation, options.baseFileLookup, readActiveEditorText(), {
|
|
771
|
+
initialActivePath: currentPath,
|
|
772
|
+
editorReadyDebug: editorReady.debug || null
|
|
773
|
+
});
|
|
774
|
+
// §9.3 write_observed_mismatch: the write call landed but the readback
|
|
775
|
+
// does not match. changedDocument:true — the editor state moved, just
|
|
776
|
+
// not as Codex expected.
|
|
777
|
+
decorated.failure = buildPageFailure('write_observed_mismatch', {
|
|
778
|
+
file: operation?.path || '',
|
|
779
|
+
operationType: operation?.type || 'edit',
|
|
780
|
+
changedDocument: true,
|
|
781
|
+
userMessage: `${operation?.path || 'The target file'} did not read back the content Codex wrote; the document state may differ from what was approved.`,
|
|
782
|
+
technicalMessage: verified.reason || '',
|
|
783
|
+
evidence: {
|
|
784
|
+
originalCode: verified.code || '',
|
|
785
|
+
expectedLength: verified.expectedLength,
|
|
786
|
+
actualLength: verified.actualLength,
|
|
787
|
+
writeStarted: true
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
return decorated;
|
|
791
|
+
}
|
|
792
|
+
const trackedChanges = [];
|
|
793
|
+
if (observeTrackedChanges) {
|
|
794
|
+
const trackedDiff = forbidTrackedChanges
|
|
795
|
+
? await waitForTrackedChangeDiff(trackedBefore, operationPaths, {
|
|
796
|
+
waitMs: 3600,
|
|
797
|
+
intervalMs: 180
|
|
798
|
+
})
|
|
799
|
+
: (() => null)();
|
|
800
|
+
if (trackedDiff) {
|
|
801
|
+
trackedChanges.push(...trackedDiff.trackedChanges);
|
|
802
|
+
} else {
|
|
803
|
+
await delay(120);
|
|
804
|
+
const trackedAfter = collectTrackedChangeRefsForPaths(operationPaths);
|
|
805
|
+
trackedChanges.push(...diffTrackedChangeRefs(trackedBefore, trackedAfter));
|
|
806
|
+
}
|
|
807
|
+
if (forbidTrackedChanges && trackedChanges.length > 0) {
|
|
808
|
+
return {
|
|
809
|
+
ok: false,
|
|
810
|
+
code: 'accept_replay_created_tracked_changes',
|
|
811
|
+
reason: 'Accept All replay wrote the expected text, but Overleaf created fresh tracked changes during the replay. Codex tried to roll back this replay and left the run pending instead of marking it accepted.',
|
|
812
|
+
// §9.4 tracked_changes_remain: warning severity, needs_review terminal
|
|
813
|
+
// state. The replay wrote (changedDocument:true), but Overleaf left
|
|
814
|
+
// tracked-change nodes behind, so Accept cannot be proven clean.
|
|
815
|
+
failure: buildPageFailure('tracked_changes_remain', {
|
|
816
|
+
file: operation?.path || '',
|
|
817
|
+
operationType: operation?.type || 'edit',
|
|
818
|
+
changedDocument: true,
|
|
819
|
+
terminalState: 'needs_review',
|
|
820
|
+
userMessage: `${operation?.path || 'The target file'} still has tracked changes after the Accept All replay, so the run could not be marked accepted.`,
|
|
821
|
+
evidence: {
|
|
822
|
+
originalCode: 'accept_replay_created_tracked_changes',
|
|
823
|
+
trackedChangeCount: trackedChanges.length,
|
|
824
|
+
writeStarted: true
|
|
825
|
+
}
|
|
826
|
+
}),
|
|
827
|
+
verified: true,
|
|
828
|
+
verifiedContent: nextContent,
|
|
829
|
+
trackedChanges: mergeTrackedChangeRefs(trackedChanges)
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
window.CodexOverleafStaleGuard?.updateExpectedFileContent(
|
|
834
|
+
options.baseFileLookup,
|
|
835
|
+
operation.path,
|
|
836
|
+
nextContent
|
|
837
|
+
);
|
|
838
|
+
return {
|
|
839
|
+
...result,
|
|
840
|
+
verified: true,
|
|
841
|
+
verifiedContent: nextContent,
|
|
842
|
+
trackedChanges: mergeTrackedChangeRefs(trackedChanges)
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
async function verifyActiveEditorText(expected, filePath, waitMs = 1000) {
|
|
848
|
+
const deadline = Date.now() + waitMs;
|
|
849
|
+
let actual = readActiveEditorText();
|
|
850
|
+
while (actual !== expected && Date.now() < deadline) {
|
|
851
|
+
await delay(50);
|
|
852
|
+
actual = readActiveEditorText();
|
|
853
|
+
}
|
|
854
|
+
if (actual === expected) {
|
|
855
|
+
return {
|
|
856
|
+
ok: true
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
ok: false,
|
|
861
|
+
code: 'write_verification_failed',
|
|
862
|
+
reason: `${filePath || '当前文件'} 写入后读回内容和 Codex 预期不一致,已停止把这次操作标记为成功。请刷新 Overleaf 后重试。`,
|
|
863
|
+
expectedLength: String(expected || '').length,
|
|
864
|
+
actualLength: String(actual || '').length
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function ensureEditorReadyForOperation(operation, baseFileLookup, initialActivePath) {
|
|
869
|
+
if (!operation?.path) {
|
|
870
|
+
return {
|
|
871
|
+
ok: true,
|
|
872
|
+
text: readActiveEditorText()
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
const filePath = normalizeSafeProjectPath(operation.path);
|
|
876
|
+
if (!filePath) {
|
|
877
|
+
return invalidProjectPathResult('operation path');
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const currentText = readActiveEditorText();
|
|
881
|
+
const previousEditorIdentity = getActiveEditorIdentity();
|
|
882
|
+
const previousSignature = contentSignature(currentText);
|
|
883
|
+
|
|
884
|
+
// §9.1 target_file_not_found: if the target path is not visible in the
|
|
885
|
+
// Overleaf project file tree, refuse the write before attempting to open.
|
|
886
|
+
// openFileByPath itself would fail anyway, but emitting the canonical
|
|
887
|
+
// path-not-found code (rather than a generic open failure) gives the run
|
|
888
|
+
// card / final report a more actionable presentation per §17.3.1.
|
|
889
|
+
// Only fires when the tree-operations layer actually exposes a
|
|
890
|
+
// projectPathExists hook; older harnesses without the hook fall through
|
|
891
|
+
// to the openFileByPath path and surface a generic open failure.
|
|
892
|
+
if (operation?.type === 'edit' && hasProjectPathExistsHook() && !projectPathExists(filePath)) {
|
|
893
|
+
return {
|
|
894
|
+
ok: false,
|
|
895
|
+
code: 'path_not_found',
|
|
896
|
+
reason: `Cannot edit ${filePath}; the path is not in the Overleaf project file tree.`,
|
|
897
|
+
failure: buildPageFailure('target_file_not_found', {
|
|
898
|
+
file: filePath,
|
|
899
|
+
activeFile: initialActivePath || '',
|
|
900
|
+
operationType: operation?.type || 'edit',
|
|
901
|
+
changedDocument: false,
|
|
902
|
+
userMessage: `Codex could not find ${filePath} in this Overleaf project.`,
|
|
903
|
+
evidence: {
|
|
904
|
+
originalCode: 'path_not_found',
|
|
905
|
+
initialActivePath: initialActivePath || '',
|
|
906
|
+
writeStarted: false
|
|
907
|
+
}
|
|
908
|
+
})
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const opened = await openFileByPath(filePath, { force: initialActivePath === filePath });
|
|
913
|
+
if (!opened.ok) {
|
|
914
|
+
const reasonText = `Cannot edit ${filePath}; active file is ${initialActivePath || 'unknown'}; ${opened.reason}`;
|
|
915
|
+
return {
|
|
916
|
+
ok: false,
|
|
917
|
+
code: 'file_open_failed',
|
|
918
|
+
debug: buildWritebackDebug('open_file_failed', operation, baseFileLookup, currentText, {
|
|
919
|
+
initialActivePath,
|
|
920
|
+
opened
|
|
921
|
+
}),
|
|
922
|
+
reason: reasonText,
|
|
923
|
+
failure: buildPageFailure('target_file_open_failed', {
|
|
924
|
+
file: filePath,
|
|
925
|
+
activeFile: initialActivePath || '',
|
|
926
|
+
operationType: operation?.type || 'edit',
|
|
927
|
+
changedDocument: false,
|
|
928
|
+
userMessage: `Codex could not open ${filePath} in Overleaf.`,
|
|
929
|
+
technicalMessage: opened.reason || '',
|
|
930
|
+
evidence: {
|
|
931
|
+
initialActivePath: initialActivePath || '',
|
|
932
|
+
openMethod: opened.method || '',
|
|
933
|
+
writeStarted: false
|
|
934
|
+
}
|
|
935
|
+
})
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return waitForEditorContentForPath(filePath, previousEditorIdentity, baseFileLookup, 9000, {
|
|
940
|
+
initialActivePath,
|
|
941
|
+
previousSignature,
|
|
942
|
+
forceSettledOpen: true,
|
|
943
|
+
openedMethod: opened.method || ''
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function waitForEditorContentForPath(filePath, previousEditorIdentity, baseFileLookup, timeoutMs, options = {}) {
|
|
948
|
+
const deadline = Date.now() + timeoutMs;
|
|
949
|
+
const startedAt = Date.now();
|
|
950
|
+
const minSettleMs = options.forceSettledOpen ? writebackOpenSettleMs : 0;
|
|
951
|
+
let lastText = readActiveEditorText();
|
|
952
|
+
let lastActivePath = getActiveFilePath();
|
|
953
|
+
while (Date.now() < deadline) {
|
|
954
|
+
lastActivePath = getActiveFilePath();
|
|
955
|
+
lastText = readActiveEditorText();
|
|
956
|
+
const activeFileMatches = lastActivePath === filePath;
|
|
957
|
+
const identityChanged = Boolean(previousEditorIdentity) && activeEditorIdentityChanged(previousEditorIdentity);
|
|
958
|
+
const baseComparison = compareEditorTextToBase(filePath, lastText, baseFileLookup);
|
|
959
|
+
const baseMatches = baseComparison.matches;
|
|
960
|
+
const settledLongEnough = Date.now() - startedAt >= minSettleMs;
|
|
961
|
+
const contentChangedFromPrevious = !options.previousSignature
|
|
962
|
+
|| contentSignature(lastText) !== options.previousSignature;
|
|
963
|
+
// Switch confirmation must not depend on any single fragile signal
|
|
964
|
+
// (v1.6: identity comparison proved unreliable against newer Overleaf
|
|
965
|
+
// view lifecycles, which made every write to a non-active file time
|
|
966
|
+
// out as target_editor_not_ready). Acceptors, strongest first:
|
|
967
|
+
// 1. editor identity visibly changed (fast path when it works);
|
|
968
|
+
// 2. the editor shows EXACTLY the target file's expected base —
|
|
969
|
+
// the strongest possible proof the right document is loaded;
|
|
970
|
+
// 3. same-file reopen settled with unchanged content;
|
|
971
|
+
// 4. base UNKNOWN only: the content moved off the previous document
|
|
972
|
+
// and the settle window elapsed. When the base IS known, content
|
|
973
|
+
// must match it (acceptor 2) — a weakly-anchored patch (e.g. an
|
|
974
|
+
// insert into an empty file, expected:'') would otherwise anchor
|
|
975
|
+
// trivially into a wrong-but-different document.
|
|
976
|
+
const switchConfirmed = identityChanged
|
|
977
|
+
|| baseMatches
|
|
978
|
+
|| (options.initialActivePath === filePath
|
|
979
|
+
&& options.previousSignature
|
|
980
|
+
&& contentSignature(lastText) === options.previousSignature
|
|
981
|
+
&& settledLongEnough)
|
|
982
|
+
|| (!baseComparison.known && contentChangedFromPrevious && settledLongEnough);
|
|
983
|
+
const contentNoLongerLooksLikePreviousDocument = contentChangedFromPrevious
|
|
984
|
+
|| baseMatches
|
|
985
|
+
|| options.initialActivePath === filePath;
|
|
986
|
+
if (activeFileMatches && switchConfirmed && settledLongEnough && contentNoLongerLooksLikePreviousDocument) {
|
|
987
|
+
return {
|
|
988
|
+
ok: true,
|
|
989
|
+
text: lastText
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
await delay(100);
|
|
993
|
+
}
|
|
994
|
+
// §9.1: if the active file ended up on a different document, this is
|
|
995
|
+
// target_file_not_active; if the file did become active but the editor
|
|
996
|
+
// document never settled, this is target_editor_not_ready.
|
|
997
|
+
const activeMismatched = Boolean(lastActivePath) && lastActivePath !== filePath;
|
|
998
|
+
const canonicalCode = activeMismatched ? 'target_file_not_active' : 'target_editor_not_ready';
|
|
999
|
+
return {
|
|
1000
|
+
ok: false,
|
|
1001
|
+
code: 'editor_document_not_switched',
|
|
1002
|
+
reason: `Cannot edit ${filePath}; Overleaf selected ${lastActivePath || 'unknown file'} but the editor document did not load the target file. Codex did not write to avoid editing the wrong file.`,
|
|
1003
|
+
lastActivePath,
|
|
1004
|
+
lastLength: String(lastText || '').length,
|
|
1005
|
+
debug: buildWritebackDebug('editor_document_not_switched', { type: 'edit', path: filePath }, baseFileLookup, lastText, {
|
|
1006
|
+
initialActivePath: options.initialActivePath || '',
|
|
1007
|
+
lastActivePath,
|
|
1008
|
+
previous: options.previousSignature || '',
|
|
1009
|
+
currentSignature: contentSignature(lastText),
|
|
1010
|
+
openedMethod: options.openedMethod || '',
|
|
1011
|
+
elapsedMs: Date.now() - startedAt
|
|
1012
|
+
}),
|
|
1013
|
+
failure: buildPageFailure(canonicalCode, {
|
|
1014
|
+
file: filePath,
|
|
1015
|
+
activeFile: lastActivePath || '',
|
|
1016
|
+
operationType: 'edit',
|
|
1017
|
+
changedDocument: false,
|
|
1018
|
+
userMessage: activeMismatched
|
|
1019
|
+
? `Codex tried to write ${filePath}, but ${lastActivePath || 'a different file'} was active at write time.`
|
|
1020
|
+
: `${filePath} became active in Overleaf, but the editor was not ready before timeout.`,
|
|
1021
|
+
evidence: {
|
|
1022
|
+
initialActivePath: options.initialActivePath || '',
|
|
1023
|
+
lastActivePath: lastActivePath || '',
|
|
1024
|
+
openedMethod: options.openedMethod || '',
|
|
1025
|
+
elapsedMs: Date.now() - startedAt,
|
|
1026
|
+
writeStarted: false
|
|
1027
|
+
}
|
|
1028
|
+
})
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
async function waitForEditorDocumentForPath(filePath, previousEditorIdentity, timeoutMs, options = {}) {
|
|
1033
|
+
const deadline = Date.now() + timeoutMs;
|
|
1034
|
+
const startedAt = Date.now();
|
|
1035
|
+
const minSettleMs = options.forceSettledOpen ? writebackOpenSettleMs : 0;
|
|
1036
|
+
let lastText = readActiveEditorText();
|
|
1037
|
+
let lastActivePath = getActiveFilePath();
|
|
1038
|
+
while (Date.now() < deadline) {
|
|
1039
|
+
lastActivePath = getActiveFilePath();
|
|
1040
|
+
lastText = readActiveEditorText();
|
|
1041
|
+
const activeFileMatches = lastActivePath === filePath;
|
|
1042
|
+
const identityChanged = Boolean(previousEditorIdentity) && activeEditorIdentityChanged(previousEditorIdentity);
|
|
1043
|
+
const settledLongEnough = Date.now() - startedAt >= minSettleMs;
|
|
1044
|
+
const sameFileReopenSettled = options.initialActivePath === filePath && settledLongEnough;
|
|
1045
|
+
if (activeFileMatches && settledLongEnough && (identityChanged || sameFileReopenSettled)) {
|
|
1046
|
+
return {
|
|
1047
|
+
ok: true,
|
|
1048
|
+
text: lastText
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
await delay(100);
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
ok: false,
|
|
1055
|
+
code: 'editor_document_not_switched',
|
|
1056
|
+
reason: `Cannot verify ${filePath}; Overleaf selected ${lastActivePath || 'unknown file'} but the editor document did not confirm the target file.`,
|
|
1057
|
+
lastActivePath,
|
|
1058
|
+
lastLength: String(lastText || '').length
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function editorContentMatchesBase(filePath, content, baseFileLookup) {
|
|
1063
|
+
return compareEditorTextToBase(filePath, content, baseFileLookup).matches;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function compareEditorTextToBase(filePath, content, baseFileLookup) {
|
|
1067
|
+
const normalizedPath = normalizeSafeProjectPath(filePath);
|
|
1068
|
+
const current = normalizeEditorTextForComparison(content);
|
|
1069
|
+
if (!baseFileLookup || !normalizedPath || !baseFileLookup.has(normalizedPath)) {
|
|
1070
|
+
return {
|
|
1071
|
+
known: false,
|
|
1072
|
+
hasContent: false,
|
|
1073
|
+
matches: false,
|
|
1074
|
+
baseLength: null,
|
|
1075
|
+
currentLength: current.length
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
const base = normalizeEditorTextForComparison(baseFileLookup.get(normalizedPath));
|
|
1079
|
+
const matches = current === base;
|
|
1080
|
+
if (!matches && base.trim().length > 0) {
|
|
1081
|
+
console.warn('[codex-overleaf] editorContentMatchesBase MISMATCH', filePath,
|
|
1082
|
+
'base.length:', base.length, 'current.length:', current.length,
|
|
1083
|
+
'base[0..80]:', JSON.stringify(base.slice(0, 80)),
|
|
1084
|
+
'current[0..80]:', JSON.stringify(current.slice(0, 80)));
|
|
1085
|
+
}
|
|
1086
|
+
return {
|
|
1087
|
+
known: true,
|
|
1088
|
+
hasContent: base.trim().length > 0,
|
|
1089
|
+
matches,
|
|
1090
|
+
baseLength: base.length,
|
|
1091
|
+
currentLength: current.length
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function hasBaseFileContent(filePath, baseFileLookup) {
|
|
1096
|
+
if (!baseFileLookup || !filePath || !baseFileLookup.has(filePath)) {
|
|
1097
|
+
return false;
|
|
1098
|
+
}
|
|
1099
|
+
const content = baseFileLookup.get(filePath);
|
|
1100
|
+
return typeof content === 'string' && content.trim().length > 0;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function normalizeEditorTextForComparison(value) {
|
|
1104
|
+
const normalized = String(value ?? '').replace(/\r\n/g, '\n');
|
|
1105
|
+
return normalized.trim().length === 0 ? '' : normalized;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function waitForFreshEditorTextForOperation(operation, baseFileLookup, waitMs = 1500) {
|
|
1109
|
+
const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
|
|
1110
|
+
while (Date.now() < deadline) {
|
|
1111
|
+
if (operation?.path && getActiveFilePath() !== operation.path) {
|
|
1112
|
+
await delay(60);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
const text = readActiveEditorText();
|
|
1116
|
+
const freshness = window.CodexOverleafStaleGuard?.checkOperationFreshness(
|
|
1117
|
+
operation,
|
|
1118
|
+
text,
|
|
1119
|
+
baseFileLookup
|
|
1120
|
+
) || { ok: true };
|
|
1121
|
+
if (freshness.ok) {
|
|
1122
|
+
return {
|
|
1123
|
+
ok: true,
|
|
1124
|
+
text
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
await delay(60);
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
ok: false
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function applyTextPatches(text, patches) {
|
|
1135
|
+
const normalized = normalizeTextPatches(patches, text.length);
|
|
1136
|
+
if (!normalized.ok) {
|
|
1137
|
+
return normalized;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
let next = text;
|
|
1141
|
+
for (const patch of normalized.patches.slice().sort((left, right) => right.from - left.from)) {
|
|
1142
|
+
if (next.slice(patch.from, patch.to) !== patch.expected) {
|
|
1143
|
+
return {
|
|
1144
|
+
ok: false,
|
|
1145
|
+
code: 'stale_patch',
|
|
1146
|
+
reason: '这处内容已经和 Codex 读取时不同,所以没有写入。请重新运行,让 Codex 先读取你的最新 Overleaf 内容。'
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
next = next.slice(0, patch.from) + patch.insert + next.slice(patch.to);
|
|
1150
|
+
}
|
|
1151
|
+
return {
|
|
1152
|
+
ok: true,
|
|
1153
|
+
text: next,
|
|
1154
|
+
patches: normalized.patches
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
async function applyBinaryAssetOperation(operation, options = {}) {
|
|
1160
|
+
const freshness = await checkBinaryAssetFreshness(operation, options);
|
|
1161
|
+
if (!freshness.ok) {
|
|
1162
|
+
return freshness;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const bytes = decodeBase64Bytes(operation.contentBase64);
|
|
1166
|
+
if (!bytes.ok) {
|
|
1167
|
+
return bytes;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const manager = findFileTreeManager();
|
|
1171
|
+
const file = createAssetFile(operation.path, bytes.bytes);
|
|
1172
|
+
const methodNames = ['uploadFile', 'uploadAsset', 'createBinaryFile', 'createFile', 'addFile'];
|
|
1173
|
+
for (const methodName of methodNames) {
|
|
1174
|
+
const method = manager?.[methodName];
|
|
1175
|
+
if (typeof method !== 'function') {
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
try {
|
|
1179
|
+
if (operation.type === 'overwrite-binary' && projectPathExists(operation.path)) {
|
|
1180
|
+
await method.call(manager, operation.path, file, { overwrite: true });
|
|
1181
|
+
} else {
|
|
1182
|
+
await method.call(manager, operation.path, file);
|
|
1183
|
+
}
|
|
1184
|
+
await delay(120);
|
|
1185
|
+
if (!projectPathExists(operation.path)) {
|
|
1186
|
+
return {
|
|
1187
|
+
ok: false,
|
|
1188
|
+
code: 'binary_asset_verification_failed',
|
|
1189
|
+
reason: `${operation.path} did not appear in the Overleaf file tree after binary asset upload.`
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
ok: true,
|
|
1194
|
+
method: `fileTreeManager.${methodName}`,
|
|
1195
|
+
verified: true,
|
|
1196
|
+
binaryAsset: true
|
|
1197
|
+
};
|
|
1198
|
+
} catch (_error) {
|
|
1199
|
+
// Try the next known Overleaf file upload method.
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
return {
|
|
1204
|
+
ok: false,
|
|
1205
|
+
code: 'binary_asset_write_unsupported',
|
|
1206
|
+
reason: 'No supported Overleaf binary asset upload method was detected. The asset was not written.'
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function checkBinaryAssetFreshness(operation, options = {}) {
|
|
1211
|
+
const baseFileLookup = options.baseFileLookup;
|
|
1212
|
+
const baseBinaryFileLookup = options.baseBinaryFileLookup;
|
|
1213
|
+
if (!operation?.path) {
|
|
1214
|
+
return {
|
|
1215
|
+
ok: false,
|
|
1216
|
+
code: 'missing_operation_path',
|
|
1217
|
+
reason: 'Cannot safely write a binary asset without a target path.'
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
if (operation.type === 'binary-create' && projectPathExists(operation.path)) {
|
|
1221
|
+
return {
|
|
1222
|
+
ok: false,
|
|
1223
|
+
code: 'path_created_since_snapshot',
|
|
1224
|
+
reason: `${operation.path} already exists in Overleaf. Codex did not overwrite it as a new asset.`
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
if (operation.type === 'overwrite-binary' && baseFileLookup && !baseFileLookup.has(operation.path)) {
|
|
1228
|
+
return {
|
|
1229
|
+
ok: false,
|
|
1230
|
+
code: 'missing_base_file',
|
|
1231
|
+
reason: `${operation.path} was not present in the run baseline. Codex did not overwrite it.`
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
if (operation.type === 'overwrite-binary' && !projectPathExists(operation.path)) {
|
|
1235
|
+
return {
|
|
1236
|
+
ok: false,
|
|
1237
|
+
code: 'missing_current_binary_asset',
|
|
1238
|
+
reason: `${operation.path} no longer exists in Overleaf. Codex did not overwrite it.`
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
if (operation.type === 'overwrite-binary') {
|
|
1242
|
+
const baseBinaryFile = baseBinaryFileLookup?.get(operation.path);
|
|
1243
|
+
if (!baseBinaryFile) {
|
|
1244
|
+
return {
|
|
1245
|
+
ok: false,
|
|
1246
|
+
code: 'missing_binary_base_content',
|
|
1247
|
+
reason: `${operation.path} did not have binary baseline content. Codex did not overwrite it.`
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
const currentBinaryFile = await getCurrentBinaryAssetFile(operation.path);
|
|
1251
|
+
if (!currentBinaryFile.ok) {
|
|
1252
|
+
return currentBinaryFile;
|
|
1253
|
+
}
|
|
1254
|
+
if (!binaryAssetMatchesBaseline(baseBinaryFile, currentBinaryFile.file)) {
|
|
1255
|
+
return {
|
|
1256
|
+
ok: false,
|
|
1257
|
+
code: 'stale_binary_asset',
|
|
1258
|
+
reason: `${operation.path} changed in Overleaf after the baseline. Codex did not overwrite it.`
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return { ok: true };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function buildBaseBinaryFileLookup(files) {
|
|
1266
|
+
if (!Array.isArray(files) || !files.length) {
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
const lookup = new Map();
|
|
1270
|
+
for (const file of files) {
|
|
1271
|
+
if (!file?.path || typeof file.contentBase64 !== 'string') {
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
const filePath = normalizeSafeProjectPath(file.path);
|
|
1275
|
+
if (!filePath) {
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
lookup.set(filePath, {
|
|
1279
|
+
contentBase64: normalizeBase64(file.contentBase64),
|
|
1280
|
+
size: Number.isFinite(Number(file.size)) ? Number(file.size) : null
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
return lookup.size ? lookup : null;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
async function getCurrentBinaryAssetFile(filePath) {
|
|
1287
|
+
try {
|
|
1288
|
+
const project = await projectSnapshotBridge.getProjectSnapshot({
|
|
1289
|
+
zipOnly: true,
|
|
1290
|
+
includeBinaryFiles: true,
|
|
1291
|
+
force: true,
|
|
1292
|
+
maxAgeMs: 0
|
|
1293
|
+
});
|
|
1294
|
+
const normalizedPath = normalizeSafeProjectPath(filePath);
|
|
1295
|
+
const file = (project?.files || []).find(item =>
|
|
1296
|
+
normalizeSafeProjectPath(item?.path) === normalizedPath
|
|
1297
|
+
);
|
|
1298
|
+
if (!file || typeof file.contentBase64 !== 'string') {
|
|
1299
|
+
return {
|
|
1300
|
+
ok: false,
|
|
1301
|
+
code: 'current_binary_asset_unavailable',
|
|
1302
|
+
reason: `${filePath} could not be verified from a fresh Overleaf ZIP snapshot. Codex did not overwrite it.`
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
return { ok: true, file };
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
return {
|
|
1308
|
+
ok: false,
|
|
1309
|
+
code: 'current_binary_asset_unavailable',
|
|
1310
|
+
reason: `${filePath} could not be verified before overwrite: ${error.message}`
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function binaryAssetMatchesBaseline(baseFile, currentFile) {
|
|
1316
|
+
if (!baseFile || !currentFile) {
|
|
1317
|
+
return false;
|
|
1318
|
+
}
|
|
1319
|
+
const baseContent = normalizeBase64(baseFile.contentBase64);
|
|
1320
|
+
const currentContent = normalizeBase64(currentFile.contentBase64);
|
|
1321
|
+
if (!baseContent || baseContent !== currentContent) {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
const currentSize = Number.isFinite(Number(currentFile.size)) ? Number(currentFile.size) : null;
|
|
1325
|
+
return baseFile.size == null || currentSize == null || baseFile.size === currentSize;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function normalizeBase64(value) {
|
|
1329
|
+
return String(value || '').replace(/\s+/g, '');
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function decodeBase64Bytes(contentBase64) {
|
|
1333
|
+
try {
|
|
1334
|
+
const binary = window.atob(String(contentBase64 || ''));
|
|
1335
|
+
const bytes = new Uint8Array(binary.length);
|
|
1336
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
1337
|
+
bytes[index] = binary.charCodeAt(index);
|
|
1338
|
+
}
|
|
1339
|
+
return { ok: true, bytes };
|
|
1340
|
+
} catch (_error) {
|
|
1341
|
+
return {
|
|
1342
|
+
ok: false,
|
|
1343
|
+
code: 'invalid_binary_asset_content',
|
|
1344
|
+
reason: 'Binary asset content was not valid base64.'
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function createAssetFile(filePath, bytes) {
|
|
1350
|
+
const name = String(filePath || '').split('/').filter(Boolean).pop() || 'asset';
|
|
1351
|
+
const type = inferAssetMimeType(filePath);
|
|
1352
|
+
if (typeof File === 'function') {
|
|
1353
|
+
return new File([bytes], name, { type });
|
|
1354
|
+
}
|
|
1355
|
+
return new Blob([bytes], { type });
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function inferAssetMimeType(filePath) {
|
|
1359
|
+
const normalized = String(filePath || '').toLowerCase();
|
|
1360
|
+
if (normalized.endsWith('.pdf')) {
|
|
1361
|
+
return 'application/pdf';
|
|
1362
|
+
}
|
|
1363
|
+
if (normalized.endsWith('.png')) {
|
|
1364
|
+
return 'image/png';
|
|
1365
|
+
}
|
|
1366
|
+
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) {
|
|
1367
|
+
return 'image/jpeg';
|
|
1368
|
+
}
|
|
1369
|
+
if (normalized.endsWith('.svg')) {
|
|
1370
|
+
return 'image/svg+xml';
|
|
1371
|
+
}
|
|
1372
|
+
return 'application/octet-stream';
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async function applyFileTreeOperation(operation, options = {}) {
|
|
1376
|
+
const freshness = await checkFileTreeOperationFreshness(operation, options.baseFileLookup);
|
|
1377
|
+
if (!freshness.ok) {
|
|
1378
|
+
return freshness;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const manager = findFileTreeManager();
|
|
1382
|
+
const methodNames = fileTreeMethodNames(operation.type);
|
|
1383
|
+
|
|
1384
|
+
for (const methodName of methodNames) {
|
|
1385
|
+
const method = manager?.[methodName];
|
|
1386
|
+
if (typeof method !== 'function') {
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
let mutationAttempted = false;
|
|
1391
|
+
try {
|
|
1392
|
+
mutationAttempted = true;
|
|
1393
|
+
if (operation.type === 'create') {
|
|
1394
|
+
await method.call(manager, operation.path, operation.content || '');
|
|
1395
|
+
} else if (operation.type === 'rename' || operation.type === 'move') {
|
|
1396
|
+
await method.call(manager, operation.path, operation.to);
|
|
1397
|
+
} else if (operation.type === 'delete') {
|
|
1398
|
+
await method.call(manager, operation.path);
|
|
1399
|
+
}
|
|
1400
|
+
const verified = await verifyFileTreeOperation(operation);
|
|
1401
|
+
if (!verified.ok) {
|
|
1402
|
+
return verified;
|
|
1403
|
+
}
|
|
1404
|
+
recordFileTreeOperationSuccess(operation, options.baseFileLookup);
|
|
1405
|
+
return {
|
|
1406
|
+
ok: true,
|
|
1407
|
+
method: `fileTreeManager.${methodName}`,
|
|
1408
|
+
verified: true
|
|
1409
|
+
};
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
if (mutationAttempted) {
|
|
1412
|
+
return {
|
|
1413
|
+
ok: false,
|
|
1414
|
+
code: 'file_tree_operation_unverified',
|
|
1415
|
+
reason: `Overleaf file-tree method ${methodName} did not complete cleanly after it was invoked. Codex stopped instead of trying another method that could stack a second partial change.`,
|
|
1416
|
+
method: `fileTreeManager.${methodName}`,
|
|
1417
|
+
error: error?.message || String(error || '')
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
// Try the next known method name.
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
return {
|
|
1425
|
+
ok: false,
|
|
1426
|
+
reason: 'No supported Overleaf file-tree method was detected'
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
async function verifyFileTreeOperation(operation) {
|
|
1431
|
+
await delay(120);
|
|
1432
|
+
const sourcePath = operation.path;
|
|
1433
|
+
const targetPath = operation.to;
|
|
1434
|
+
|
|
1435
|
+
if (operation.type === 'create') {
|
|
1436
|
+
if (!projectPathExists(sourcePath)) {
|
|
1437
|
+
return fileTreeVerificationFailed(operation, `${sourcePath} 没有出现在 Overleaf 文件树中。`);
|
|
1438
|
+
}
|
|
1439
|
+
if (typeof operation.content === 'string' && window.CodexOverleafProjectFiles.isTextProjectPath(sourcePath)) {
|
|
1440
|
+
const opened = await openFileByPath(sourcePath);
|
|
1441
|
+
if (!opened.ok) {
|
|
1442
|
+
return fileTreeVerificationFailed(operation, `${sourcePath} 创建后无法打开验证内容。`);
|
|
1443
|
+
}
|
|
1444
|
+
const verified = await verifyActiveEditorText(operation.content, sourcePath, 600);
|
|
1445
|
+
if (!verified.ok) {
|
|
1446
|
+
return fileTreeVerificationFailed(operation, `${sourcePath} 创建后内容没有和 Codex 预期一致。`);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return { ok: true };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
if (operation.type === 'delete') {
|
|
1453
|
+
return projectPathExists(sourcePath)
|
|
1454
|
+
? fileTreeVerificationFailed(operation, `${sourcePath} 仍然存在。`)
|
|
1455
|
+
: { ok: true };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
if (operation.type === 'rename' || operation.type === 'move') {
|
|
1459
|
+
if (!targetPath || !projectPathExists(targetPath)) {
|
|
1460
|
+
return fileTreeVerificationFailed(operation, `${targetPath || '目标路径'} 没有出现在 Overleaf 文件树中。`);
|
|
1461
|
+
}
|
|
1462
|
+
if (projectPathExists(sourcePath)) {
|
|
1463
|
+
return fileTreeVerificationFailed(operation, `${sourcePath} 仍然存在。`);
|
|
1464
|
+
}
|
|
1465
|
+
return { ok: true };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
return { ok: true };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function fileTreeVerificationFailed(operation, reason) {
|
|
1472
|
+
return {
|
|
1473
|
+
ok: false,
|
|
1474
|
+
code: 'file_tree_verification_failed',
|
|
1475
|
+
reason: `Overleaf 文件树操作没有被确认:${reason} Codex 已停止把这次操作标记为成功。`,
|
|
1476
|
+
operationType: operation?.type || '',
|
|
1477
|
+
path: operation?.path || '',
|
|
1478
|
+
to: operation?.to || ''
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
async function checkFileTreeOperationFreshness(operation, baseFileLookup) {
|
|
1483
|
+
if (!baseFileLookup) {
|
|
1484
|
+
return { ok: true };
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
if (!operation.path) {
|
|
1488
|
+
return {
|
|
1489
|
+
ok: false,
|
|
1490
|
+
code: 'missing_operation_path',
|
|
1491
|
+
reason: `Cannot safely ${operation.type || 'modify'} a file without a target path.`
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
if (operation.type === 'create') {
|
|
1496
|
+
if (baseFileLookup.has(operation.path)) {
|
|
1497
|
+
return {
|
|
1498
|
+
ok: false,
|
|
1499
|
+
code: 'path_exists_in_snapshot',
|
|
1500
|
+
reason: `${operation.path} 在任务开始前已经存在。Codex 没有覆盖它;请改用修改文件或换一个文件名。`
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
if (projectPathExists(operation.path)) {
|
|
1504
|
+
return {
|
|
1505
|
+
ok: false,
|
|
1506
|
+
code: 'path_created_since_snapshot',
|
|
1507
|
+
reason: `${operation.path} 在任务执行期间被你或协作者新建了,Codex 没有覆盖它。请查看差异后重试。`
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
return { ok: true };
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (!baseFileLookup.has(operation.path)) {
|
|
1514
|
+
return {
|
|
1515
|
+
ok: false,
|
|
1516
|
+
code: 'missing_base_file',
|
|
1517
|
+
reason: `${operation.path} 在任务开始时没有被 Codex 读到。Codex 没有覆盖它;请刷新项目内容后重试。`
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const current = await readCurrentTextFileForFreshness(operation.path);
|
|
1522
|
+
if (!current.ok) {
|
|
1523
|
+
return current;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const freshness = window.CodexOverleafStaleGuard?.checkOperationFreshness(
|
|
1527
|
+
{ type: 'edit', path: operation.path },
|
|
1528
|
+
current.text,
|
|
1529
|
+
baseFileLookup
|
|
1530
|
+
) || { ok: true };
|
|
1531
|
+
if (!freshness.ok) {
|
|
1532
|
+
return freshness;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if ((operation.type === 'rename' || operation.type === 'move') && operation.to) {
|
|
1536
|
+
if (baseFileLookup.has(operation.to) || projectPathExists(operation.to)) {
|
|
1537
|
+
return {
|
|
1538
|
+
ok: false,
|
|
1539
|
+
code: 'destination_exists',
|
|
1540
|
+
reason: `Cannot safely ${operation.type} ${operation.path} to ${operation.to} because the destination already exists.`
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
return { ok: true };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
async function readCurrentTextFileForFreshness(filePath) {
|
|
1549
|
+
const normalizedPath = normalizeSafeProjectPath(filePath);
|
|
1550
|
+
if (!normalizedPath) {
|
|
1551
|
+
return invalidProjectPathResult('freshness path');
|
|
1552
|
+
}
|
|
1553
|
+
const currentPath = getActiveFilePath();
|
|
1554
|
+
const previousEditorIdentity = getActiveEditorIdentity();
|
|
1555
|
+
const opened = await openFileByPath(normalizedPath, { force: true });
|
|
1556
|
+
if (!opened.ok) {
|
|
1557
|
+
return {
|
|
1558
|
+
ok: false,
|
|
1559
|
+
code: 'cannot_verify_current_file',
|
|
1560
|
+
reason: `Cannot safely verify ${normalizedPath}; ${opened.reason || 'open failed'}. Re-run Codex on a fresh snapshot.`
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
const ready = await waitForEditorDocumentForPath(normalizedPath, previousEditorIdentity, 9000, {
|
|
1564
|
+
initialActivePath: currentPath,
|
|
1565
|
+
forceSettledOpen: true
|
|
1566
|
+
});
|
|
1567
|
+
if (!ready.ok) {
|
|
1568
|
+
return {
|
|
1569
|
+
...ready,
|
|
1570
|
+
code: 'cannot_verify_current_file'
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
return {
|
|
1575
|
+
ok: true,
|
|
1576
|
+
text: ready.text
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function recordFileTreeOperationSuccess(operation, baseFileLookup) {
|
|
1581
|
+
if (!baseFileLookup) {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (operation.type === 'create') {
|
|
1586
|
+
window.CodexOverleafStaleGuard?.updateExpectedFileContent(
|
|
1587
|
+
baseFileLookup,
|
|
1588
|
+
operation.path,
|
|
1589
|
+
operation.content || ''
|
|
1590
|
+
);
|
|
1591
|
+
} else if (operation.type === 'delete') {
|
|
1592
|
+
window.CodexOverleafStaleGuard?.removeExpectedFile(baseFileLookup, operation.path);
|
|
1593
|
+
} else if (operation.type === 'rename' || operation.type === 'move') {
|
|
1594
|
+
window.CodexOverleafStaleGuard?.moveExpectedFile(baseFileLookup, operation.path, operation.to);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function fileTreeMethodNames(type) {
|
|
1599
|
+
return {
|
|
1600
|
+
create: ['createDoc', 'createFile', 'addDoc', 'addFile'],
|
|
1601
|
+
rename: ['renameEntity', 'renameFile', 'renameDoc'],
|
|
1602
|
+
move: ['moveEntity', 'moveFile', 'moveDoc'],
|
|
1603
|
+
delete: ['deleteEntity', 'deleteFile', 'deleteDoc', 'removeEntity']
|
|
1604
|
+
}[type] || [];
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function normalizeOperationPaths(operation) {
|
|
1608
|
+
if (!operation || typeof operation !== 'object') {
|
|
1609
|
+
return operation;
|
|
1610
|
+
}
|
|
1611
|
+
const normalized = {
|
|
1612
|
+
...operation,
|
|
1613
|
+
path: typeof operation.path === 'string'
|
|
1614
|
+
? normalizeSafeProjectPath(operation.path)
|
|
1615
|
+
: operation.path,
|
|
1616
|
+
to: typeof operation.to === 'string'
|
|
1617
|
+
? normalizeSafeProjectPath(operation.to)
|
|
1618
|
+
: operation.to
|
|
1619
|
+
};
|
|
1620
|
+
if (typeof operation.path === 'string' && !normalized.path) {
|
|
1621
|
+
normalized.invalidProjectPath = true;
|
|
1622
|
+
}
|
|
1623
|
+
if (typeof operation.to === 'string' && !normalized.to) {
|
|
1624
|
+
normalized.invalidProjectDestinationPath = true;
|
|
1625
|
+
}
|
|
1626
|
+
return normalized;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function validateOperationProjectPaths(operation) {
|
|
1630
|
+
if (!operation || typeof operation !== 'object') {
|
|
1631
|
+
return { ok: true };
|
|
1632
|
+
}
|
|
1633
|
+
if (operation.invalidProjectPath || (requiresOperationPath(operation) && !operation.path)) {
|
|
1634
|
+
return invalidProjectPathResult('operation path');
|
|
1635
|
+
}
|
|
1636
|
+
if (operation.invalidProjectDestinationPath || (requiresOperationDestinationPath(operation) && !operation.to)) {
|
|
1637
|
+
return invalidProjectPathResult('operation destination path');
|
|
1638
|
+
}
|
|
1639
|
+
return { ok: true };
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function requiresOperationPath(operation) {
|
|
1643
|
+
return ['edit', 'create', 'delete', 'rename', 'move', 'binary-create', 'overwrite-binary'].includes(operation.type);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function requiresOperationDestinationPath(operation) {
|
|
1647
|
+
return operation.type === 'rename' || operation.type === 'move';
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function normalizeBaseFilesForSafety(files) {
|
|
1651
|
+
if (!Array.isArray(files)) {
|
|
1652
|
+
return files;
|
|
1653
|
+
}
|
|
1654
|
+
return files
|
|
1655
|
+
.map(file => {
|
|
1656
|
+
if (!file || typeof file.path !== 'string') {
|
|
1657
|
+
return null;
|
|
1658
|
+
}
|
|
1659
|
+
const filePath = normalizeSafeProjectPath(file.path);
|
|
1660
|
+
return filePath ? { ...file, path: filePath } : null;
|
|
1661
|
+
})
|
|
1662
|
+
.filter(Boolean);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function collectOperationPaths(operations = []) {
|
|
1666
|
+
const paths = new Set();
|
|
1667
|
+
for (const rawOperation of operations || []) {
|
|
1668
|
+
const operation = normalizeOperationPaths(rawOperation);
|
|
1669
|
+
for (const value of [operation?.path, operation?.to]) {
|
|
1670
|
+
if (typeof value === 'string' && value) {
|
|
1671
|
+
paths.add(value);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return Array.from(paths);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
function normalizeTrackedChangeRefs(refs = []) {
|
|
1684
|
+
const seen = new Set();
|
|
1685
|
+
const normalized = [];
|
|
1686
|
+
for (const ref of refs || []) {
|
|
1687
|
+
if (!ref || typeof ref !== 'object') {
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
const key = typeof ref.key === 'string' ? ref.key : '';
|
|
1691
|
+
if (!key || seen.has(key)) {
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
seen.add(key);
|
|
1695
|
+
const hasPath = typeof ref.path === 'string';
|
|
1696
|
+
const hasExplicitPath = hasPath && ref.path.trim() !== '';
|
|
1697
|
+
const path = hasPath ? normalizeSafeProjectPath(ref.path) : '';
|
|
1698
|
+
normalized.push({
|
|
1699
|
+
key,
|
|
1700
|
+
id: typeof ref.id === 'string' ? ref.id : '',
|
|
1701
|
+
path,
|
|
1702
|
+
invalidProjectPath: hasExplicitPath && !path,
|
|
1703
|
+
label: compact(String(ref.label || ''), 180)
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
return normalized;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
function diffTrackedChangeRefs(before = [], after = []) {
|
|
1711
|
+
const beforeKeys = new Set((before || []).map(ref => ref.key).filter(Boolean));
|
|
1712
|
+
const seen = new Set();
|
|
1713
|
+
const added = [];
|
|
1714
|
+
for (const ref of after || []) {
|
|
1715
|
+
if (!ref?.key || beforeKeys.has(ref.key) || seen.has(ref.key)) {
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
seen.add(ref.key);
|
|
1719
|
+
added.push(ref);
|
|
1720
|
+
}
|
|
1721
|
+
return added;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function mergeTrackedChangeRefs(refs = []) {
|
|
1725
|
+
return normalizeTrackedChangeRefs(refs);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
function isEditorUndoControl(node) {
|
|
1735
|
+
if (!node || isInsideCodexPanel(node) || node.disabled) {
|
|
1736
|
+
return false;
|
|
1737
|
+
}
|
|
1738
|
+
if (/^(true|disabled)$/i.test(node.getAttribute?.('aria-disabled') || '')) {
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
const signal = normalizeReviewingSignalText(readNodeSignalText(node));
|
|
1742
|
+
if (/\b(?:redo|重做)\b/i.test(signal)) {
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
return /\bundo\b|撤销/i.test(signal);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
async function applyOperationsForBridge(operationsOrOptions, options = {}) {
|
|
1750
|
+
if (Array.isArray(operationsOrOptions)) {
|
|
1751
|
+
// Welcome-panel + write-guard: defense-in-depth
|
|
1752
|
+
// also runs on the array-shaped entry. T2 only guarded the
|
|
1753
|
+
// payload-object branch; if any future caller goes straight to the
|
|
1754
|
+
// array entry without `options.runProjectId`, the router would have
|
|
1755
|
+
// silently dispatched. Mirror the same `editor_project_id_unavailable`
|
|
1756
|
+
// failure shape the payload branch and the page-side guard already
|
|
1757
|
+
// emit.
|
|
1758
|
+
const writeGuardBlock = checkWritebackRunProjectId(options);
|
|
1759
|
+
if (writeGuardBlock) return writeGuardBlock;
|
|
1760
|
+
return applyOperations(operationsOrOptions, options);
|
|
1761
|
+
}
|
|
1762
|
+
const payload = operationsOrOptions || {};
|
|
1763
|
+
// Welcome-panel + write-guard: defense-in-depth.
|
|
1764
|
+
// The pageBridge wrapper already runs the runProjectId guard, but a
|
|
1765
|
+
// future caller could reach the router directly. A missing or empty
|
|
1766
|
+
// `runProjectId` on the payload-shaped call blocks the write with the
|
|
1767
|
+
// same `editor_project_id_unavailable` shape the page-side guard uses.
|
|
1768
|
+
const writeGuardBlock = checkWritebackRunProjectId(payload);
|
|
1769
|
+
if (writeGuardBlock) return writeGuardBlock;
|
|
1770
|
+
return applyOperations(payload.operations || [], payload);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function checkWritebackRunProjectId(params) {
|
|
1774
|
+
const runProjectId = params && typeof params.runProjectId === 'string' ? params.runProjectId : '';
|
|
1775
|
+
if (runProjectId) return null;
|
|
1776
|
+
// operation: {} (not null) — batch-level guard fired before any per-op
|
|
1777
|
+
// dispatch; matches the abortDispatchResult shape in pageBridge.js and
|
|
1778
|
+
// avoids crashing downstream audit / transcript code that reads
|
|
1779
|
+
// operation.path without a null guard.
|
|
1780
|
+
return {
|
|
1781
|
+
ok: false,
|
|
1782
|
+
applied: [],
|
|
1783
|
+
skipped: [{
|
|
1784
|
+
operation: {},
|
|
1785
|
+
result: {
|
|
1786
|
+
ok: false,
|
|
1787
|
+
code: 'editor_project_id_unavailable',
|
|
1788
|
+
reason: 'Writeback request did not carry runProjectId.',
|
|
1789
|
+
failure: {
|
|
1790
|
+
code: 'editor_project_id_unavailable',
|
|
1791
|
+
stage: 'write',
|
|
1792
|
+
severity: 'blocked',
|
|
1793
|
+
userMessage: 'Codex could not confirm which Overleaf project the editor is showing, so it did not write.',
|
|
1794
|
+
nextAction: 'Refresh Overleaf and retry; if it persists, reload the extension.',
|
|
1795
|
+
retryable: true,
|
|
1796
|
+
terminalState: 'blocked',
|
|
1797
|
+
changedDocument: false,
|
|
1798
|
+
evidence: { runProjectId: null }
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}]
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
async function verifySaveState(paths = [], timeoutMs = 5000) {
|
|
1806
|
+
const result = deps.waitForSaveState
|
|
1807
|
+
? await deps.waitForSaveState({ deadlineMs: timeoutMs })
|
|
1808
|
+
: { ok: false, state: 'unavailable' };
|
|
1809
|
+
const verified = result.ok === true && result.state === 'verified_saved';
|
|
1810
|
+
return {
|
|
1811
|
+
verified,
|
|
1812
|
+
unverifiedPaths: verified ? [] : (paths || [])
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function buildNoTraceUndoBlockedResult(operations, disabled) {
|
|
1817
|
+
return {
|
|
1818
|
+
ok: false,
|
|
1819
|
+
applied: [],
|
|
1820
|
+
skipped: (operations || []).map(rawOperation => ({
|
|
1821
|
+
operation: normalizeOperationPaths(rawOperation),
|
|
1822
|
+
result: {
|
|
1823
|
+
ok: false,
|
|
1824
|
+
code: disabled?.code || 'reviewing_disable_failed',
|
|
1825
|
+
reason: disabled?.reason || '无法确认 Overleaf Reviewing/Track Changes 已关闭;为避免撤销也留下新的留痕,Codex 没有执行撤销。'
|
|
1826
|
+
}
|
|
1827
|
+
})),
|
|
1828
|
+
reviewingPolicy: {
|
|
1829
|
+
policy: 'no-trace-undo',
|
|
1830
|
+
disabled: false,
|
|
1831
|
+
restored: false,
|
|
1832
|
+
disable: summarizeReviewingToggleResult(disabled)
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
function buildReviewingRequiredBlockedResult(operations, reviewing) {
|
|
1838
|
+
return {
|
|
1839
|
+
ok: false,
|
|
1840
|
+
applied: [],
|
|
1841
|
+
skipped: (operations || []).map(rawOperation => ({
|
|
1842
|
+
operation: normalizeOperationPaths(rawOperation),
|
|
1843
|
+
result: {
|
|
1844
|
+
ok: false,
|
|
1845
|
+
code: reviewing?.code || 'reviewing_not_enabled',
|
|
1846
|
+
reason: reviewing?.reason || 'Overleaf Reviewing/Track Changes was not confirmed before writing. Codex did not change this file.',
|
|
1847
|
+
reviewing: reviewing?.reviewing || null
|
|
1848
|
+
}
|
|
1849
|
+
})),
|
|
1850
|
+
reviewing
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function buildEditingRequiredBlockedResult(operations, editing) {
|
|
1855
|
+
return {
|
|
1856
|
+
ok: false,
|
|
1857
|
+
applied: [],
|
|
1858
|
+
skipped: (operations || []).map(rawOperation => ({
|
|
1859
|
+
operation: normalizeOperationPaths(rawOperation),
|
|
1860
|
+
result: {
|
|
1861
|
+
ok: false,
|
|
1862
|
+
code: editing?.code || 'editing_not_confirmed',
|
|
1863
|
+
reason: editing?.reason || 'Overleaf Editing mode was not confirmed before writing. Codex did not change this file.',
|
|
1864
|
+
reviewing: editing?.reviewing || null
|
|
1865
|
+
}
|
|
1866
|
+
})),
|
|
1867
|
+
reviewing: editing
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// Tracked-changes lifecycle — carved to trackedChangesLifecycle.js in
|
|
1872
|
+
// v1.8.0 (structural-debt phase 7, #117). Loaded just before this file
|
|
1873
|
+
// in the manifest's page-script list; the Node test path requires it.
|
|
1874
|
+
const trackedChangesLifecycleModule = (typeof window !== 'undefined' && window.CodexOverleafTrackedChangesLifecycle)
|
|
1875
|
+
|| (typeof module === 'object' && module.exports ? require('./trackedChangesLifecycle.js') : null)
|
|
1876
|
+
|| (typeof globalThis !== 'undefined' ? globalThis.CodexOverleafTrackedChangesLifecycle : null);
|
|
1877
|
+
const {
|
|
1878
|
+
acceptTrackedChanges,
|
|
1879
|
+
rejectTrackedChanges,
|
|
1880
|
+
collectTrackedChangeRefsForPaths,
|
|
1881
|
+
waitForTrackedChangeDiff
|
|
1882
|
+
} = trackedChangesLifecycleModule.create({
|
|
1883
|
+
window,
|
|
1884
|
+
checkWritebackRunProjectId,
|
|
1885
|
+
buildNoTraceUndoBlockedResult,
|
|
1886
|
+
buildReviewingRequiredBlockedResult,
|
|
1887
|
+
buildEditingRequiredBlockedResult,
|
|
1888
|
+
compileBridge,
|
|
1889
|
+
applyOperationsCore,
|
|
1890
|
+
clickNode,
|
|
1891
|
+
compact,
|
|
1892
|
+
invalidProjectPathResult,
|
|
1893
|
+
isEditingConfirmedForNoTraceUndo,
|
|
1894
|
+
isReviewingConfirmedForWrite,
|
|
1895
|
+
uniqueNodes,
|
|
1896
|
+
normalizeTrackedChangeRefs,
|
|
1897
|
+
applyOperationsWithNoTraceUndo,
|
|
1898
|
+
applyTextPatches,
|
|
1899
|
+
buildPageFailure,
|
|
1900
|
+
delay,
|
|
1901
|
+
getActiveFilePath,
|
|
1902
|
+
getReviewingState,
|
|
1903
|
+
isEditorUndoControl,
|
|
1904
|
+
isInsideCodexPanel,
|
|
1905
|
+
normalizeOperationPaths,
|
|
1906
|
+
normalizeReviewingSignalText,
|
|
1907
|
+
normalizeSafeProjectPath,
|
|
1908
|
+
openFileByPath,
|
|
1909
|
+
readActiveEditorText,
|
|
1910
|
+
readNodeSignalText,
|
|
1911
|
+
setReviewingEnabled,
|
|
1912
|
+
summarizeReviewingToggleResult,
|
|
1913
|
+
verifyActiveEditorText,
|
|
1914
|
+
collectElements,
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
return {
|
|
1918
|
+
applyOperations: applyOperationsForBridge,
|
|
1919
|
+
rejectTrackedChanges,
|
|
1920
|
+
acceptTrackedChanges,
|
|
1921
|
+
verifySaveState
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function fallbackNormalizeSafeProjectPath(value) {
|
|
1926
|
+
const text = String(value || '').replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '').trim();
|
|
1927
|
+
if (!text || text.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
1928
|
+
return '';
|
|
1929
|
+
}
|
|
1930
|
+
return text;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
return { create };
|
|
1934
|
+
});
|