codex-overleaf-link 2.5.1 → 2.6.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 (93) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +78 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +11 -2
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +920 -276
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsPanel.js +18 -0
  14. package/extension/src/content/generated/content.bundle.js +4496 -924
  15. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  16. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  17. package/extension/src/content/localSkillsPanel.js +2 -2
  18. package/extension/src/content/markdownDomRenderer.js +5 -3
  19. package/extension/src/content/markdownText.js +42 -5
  20. package/extension/src/content/modelPicker.js +1 -0
  21. package/extension/src/content/nativeCompatibilityController.js +29 -10
  22. package/extension/src/content/otWarmMirror.js +1 -1
  23. package/extension/src/content/pageBridgeClient.js +2 -2
  24. package/extension/src/content/panelMaintenance.js +6 -2
  25. package/extension/src/content/panelRenderer.js +51 -27
  26. package/extension/src/content/pendingInputView.js +1 -1
  27. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  28. package/extension/src/content/providerSettingsDialog.js +2 -1
  29. package/extension/src/content/recentProjects.js +4 -3
  30. package/extension/src/content/runActivitySummary.js +122 -14
  31. package/extension/src/content/runCompletionMeta.js +194 -6
  32. package/extension/src/content/runController.js +2 -1
  33. package/extension/src/content/runFailureNotice.js +8 -1
  34. package/extension/src/content/runPresence.js +18 -8
  35. package/extension/src/content/runQueueScheduler.js +9 -5
  36. package/extension/src/content/runRecoveryActions.js +2 -1
  37. package/extension/src/content/runResultActions.js +22 -20
  38. package/extension/src/content/runReviewController.js +94 -35
  39. package/extension/src/content/runTimelineView.js +224 -25
  40. package/extension/src/content/scopedPersistencePanelState.js +92 -23
  41. package/extension/src/content/sensitivePreflight.js +14 -1
  42. package/extension/src/content/sessionManager.js +49 -3
  43. package/extension/src/content/sessionMenuView.js +54 -18
  44. package/extension/src/content/sessionPersistence.js +25 -5
  45. package/extension/src/content/settingsPanel.js +15 -2
  46. package/extension/src/content/settingsWorkbench.js +127 -89
  47. package/extension/src/content/sharedSessionViewSync.js +4 -3
  48. package/extension/src/content/subagentActivityView.js +23 -7
  49. package/extension/src/content/updateNotice.js +60 -6
  50. package/extension/src/content/writebackOrchestrator.js +35 -22
  51. package/extension/src/page/compileBridge.js +193 -180
  52. package/extension/src/page/overleafCapabilities.js +4 -1
  53. package/extension/src/page/textCoordinates.js +17 -1
  54. package/extension/src/page/textFileCreator.js +9 -1
  55. package/extension/src/page/writebackRouter.js +24 -77
  56. package/extension/src/pageBridge.js +107 -66
  57. package/extension/src/popup.js +1 -1
  58. package/extension/src/shared/agentTranscript.js +350 -17
  59. package/extension/src/shared/auditRecords.js +2 -0
  60. package/extension/src/shared/compatibility.js +1 -1
  61. package/extension/src/shared/compileAdapter.js +58 -17
  62. package/extension/src/shared/composerAttachmentDraft.js +6 -1
  63. package/extension/src/shared/contextUsage.js +125 -0
  64. package/extension/src/shared/failureReasons.js +42 -3
  65. package/extension/src/shared/i18n.js +299 -29
  66. package/extension/src/shared/lineReferences.js +38 -31
  67. package/extension/src/shared/pageRpcContract.js +1 -1
  68. package/extension/src/shared/panelIcons.js +39 -0
  69. package/extension/src/shared/reviewing.js +130 -1
  70. package/extension/src/shared/runActivityModel.js +70 -4
  71. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  72. package/extension/src/shared/sessionState.js +63 -4
  73. package/extension/src/shared/sessionTitle.js +14 -1
  74. package/extension/src/shared/sharedSessionBridge.js +81 -13
  75. package/extension/src/shared/staleGuard.js +72 -63
  76. package/extension/src/shared/storageDb.js +35 -3
  77. package/extension/src/shared/storageMigration.js +7 -1
  78. package/extension/src/shared/storageRunActions.js +62 -8
  79. package/extension/src/shared/undoOperations.js +126 -28
  80. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  81. package/extension/src/shared/writebackSettlement.js +21 -3
  82. package/extension/styles/panel.css +1106 -490
  83. package/native-host/src/bootstrapUpdate.js +52 -16
  84. package/native-host/src/chatBridgeRequest.js +60 -6
  85. package/native-host/src/chatBridgeResponse.js +10 -1
  86. package/native-host/src/chatCompletionsBridge.js +29 -2
  87. package/native-host/src/codexPromptAssembly.js +86 -118
  88. package/native-host/src/codexSessionRunner.js +126 -17
  89. package/native-host/src/providerContextError.js +20 -0
  90. package/native-host/src/taskRunnerRuntime.js +18 -6
  91. package/native-host/src/textPatch.js +473 -377
  92. package/native-host/src/updateManager.js +120 -15
  93. package/package.json +5 -8
@@ -19,6 +19,7 @@
19
19
  'experimentalOtByProject',
20
20
  'customInstructionsByProject',
21
21
  'governanceRulesByProject',
22
+ 'reviewingAvailabilityByProject',
22
23
  'selectedLocalSkillIdsByProject'
23
24
  ];
24
25
  var projectPreferenceLockTails = {};
@@ -130,7 +131,12 @@
130
131
  ? incoming[mapName]
131
132
  : {};
132
133
  if (Object.prototype.hasOwnProperty.call(incomingMap, projectId)) {
133
- storedMap[scopedKey] = copyPreferenceValue(incomingMap[projectId]);
134
+ const previous = storedMap[scopedKey], observation = incomingMap[projectId];
135
+ if (mapName !== 'reviewingAvailabilityByProject'
136
+ || !(Number(previous?.observedAt) > Number(observation?.observedAt)
137
+ || (previous?.observedAt === observation?.observedAt && previous?.status === 'available'))) {
138
+ storedMap[scopedKey] = copyPreferenceValue(observation);
139
+ }
134
140
  }
135
141
  delete storedMap[projectId];
136
142
  next[mapName] = storedMap;
@@ -1,10 +1,10 @@
1
1
  (function initStorageRunActions(root, factory) {
2
2
  if (typeof module === 'object' && module.exports) {
3
- module.exports = factory(require('./selectionContext'), require('./writebackIntent'), require('./writingStyle'), require('./directoryCleanupProof'));
3
+ module.exports = factory(require('./selectionContext'), require('./writebackIntent'), require('./writingStyle'), require('./directoryCleanupProof'), require('./undoOperations'));
4
4
  } else {
5
- root.CodexOverleafModuleRegistry.define('StorageRunActions', ['SelectionContext', 'WritebackIntent', 'WritingStyle', 'DirectoryCleanupProof'], factory);
5
+ root.CodexOverleafModuleRegistry.define('StorageRunActions', ['SelectionContext', 'WritebackIntent', 'WritingStyle', 'DirectoryCleanupProof', 'UndoOperations'], factory);
6
6
  }
7
- })(typeof globalThis !== 'undefined' ? globalThis : window, function storageRunActionsFactory(SelectionContext, WritebackIntent, WritingStyle, DirectoryCleanupProof) {
7
+ })(typeof globalThis !== 'undefined' ? globalThis : window, function storageRunActionsFactory(SelectionContext, WritebackIntent, WritingStyle, DirectoryCleanupProof, UndoOperations) {
8
8
  'use strict';
9
9
 
10
10
  // Share a bounded budget across the retained history, rather than expiring
@@ -129,7 +129,7 @@
129
129
  && Array.isArray(run.undoExpectedFiles) && run.undoExpectedFiles.length > 0
130
130
  && Array.isArray(run.appliedOperations) && run.appliedOperations.length > 0;
131
131
  var legacyUndo = Array.isArray(run && run.undoOperations) && run.undoOperations.length > 0;
132
- return trackedLifecycle || reviewingEditorUndo || legacyUndo;
132
+ return trackedLifecycle || reviewingEditorUndo || legacyUndo || UndoOperations.getUndoUnavailableFiles(run).length > 0;
133
133
  }
134
134
 
135
135
  function runDirectoryCleanup(run) {
@@ -186,7 +186,7 @@
186
186
  }
187
187
 
188
188
  function compactCoreActionPayload(run, keepActionPayload, options = {}) {
189
- var empty = emptyActionPayload();
189
+ var empty = { ...emptyActionPayload(), appliedOperations: UndoOperations.compactUnavailableUndoOperations(run) };
190
190
  if (!keepActionPayload || !hasReloadableRunActionPayload(run)) {
191
191
  if (hasReloadableRunActionPayload(run)) empty.recoveryRetention = omitted('fallback');
192
192
  else if (run?.recoveryRetention?.state === 'omitted' && !run.forkSnapshot
@@ -308,11 +308,48 @@
308
308
  return out;
309
309
  }
310
310
 
311
- // Review terminal states are monotonic even when a later session snapshot is stale.
312
- function mergeSessionReviewState(incoming, existing, reviewRunIds) {
311
+ const REVIEW_FIELDS = ['trackedChangeStatus', 'undoStatus', 'undoOwnerProtocol', 'undoTrackedChanges', 'undoExpectedFiles',
312
+ 'undoOperations', 'undoBaseFiles', 'appliedOperations', 'trackedChangeCaptures', 'recoveryRetention',
313
+ 'interruptedDraft', 'retryWriteback', 'saveCheck'];
314
+ const REVIEW_FACT_FIELDS = ['documentEffect', 'fileSettlements', 'failures', 'recoveryAvailability', 'recoveryDisposition'];
315
+ const terminalReview = run => ['accepted', 'rejected'].includes(run?.trackedChangeStatus) || run?.undoStatus === 'applied';
316
+ const factsOf = run => run?.settlement || run?.settlementFacts || {};
317
+
318
+ function copyRunReviewState(target, source) {
319
+ const merged = { ...target };
320
+ const copyFields = (into, from, fields) => {
321
+ for (const field of fields) {
322
+ if (Object.hasOwn(from, field)) into[field] = JSON.parse(JSON.stringify(from[field]));
323
+ else delete into[field];
324
+ }
325
+ };
326
+ copyFields(merged, source, REVIEW_FIELDS);
327
+ const sourceFacts = factsOf(source), targetFacts = factsOf(target);
328
+ const facts = { ...targetFacts };
329
+ copyFields(facts, sourceFacts, REVIEW_FACT_FIELDS);
330
+ const sourceEvidence = sourceFacts.evidence || {};
331
+ const evidence = { ...(targetFacts.evidence || {}) };
332
+ copyFields(evidence, sourceEvidence, [...new Set([...Object.keys(evidence), ...Object.keys(sourceEvidence)])]
333
+ .filter(key => !['mirrored', 'compiled'].includes(key)));
334
+ if (Object.keys(evidence).length) facts.evidence = evidence; else delete facts.evidence;
335
+ delete merged.settlementFacts;
336
+ if (Object.keys(facts).length) merged.settlement = facts; else delete merged.settlement;
337
+ return merged;
338
+ }
339
+
340
+ function reviewStateSnapshot(run) {
341
+ const facts = factsOf(run);
342
+ return JSON.stringify([REVIEW_FIELDS.map(key => run?.[key]), REVIEW_FACT_FIELDS.map(key => facts[key]),
343
+ Object.entries(facts.evidence || {}).filter(([key]) => !['mirrored', 'compiled'].includes(key))
344
+ .sort(([a], [b]) => a.localeCompare(b))]);
345
+ }
346
+
347
+ // Shared submissions retain the caller's envelope and private baseline. The
348
+ // legacy site-local path explicitly keeps its historical timestamp merge.
349
+ function mergeSessionReviewState(incoming, existing, reviewRunIds, options = {}) {
313
350
  if (!existing || incoming.id !== existing.id || incoming.projectId !== existing.projectId
314
351
  || !incoming.accountScopeId || incoming.accountScopeId !== existing.accountScopeId) return incoming;
315
- var terminal = run => ['accepted', 'rejected'].includes(run?.trackedChangeStatus) || run?.undoStatus === 'applied';
352
+ var terminal = terminalReview;
316
353
  var incomingRuns = new Map((incoming.runs || []).map(run => [run.id, run]));
317
354
  var existingRuns = new Map((existing.runs || []).map(run => [run.id, run]));
318
355
  var requestedIds = new Set(reviewRunIds || []);
@@ -321,6 +358,21 @@
321
358
  return ['accepted', 'rejected', 'needs_review'].includes(run?.trackedChangeStatus)
322
359
  || ['applied', 'partial'].includes(run?.undoStatus);
323
360
  });
361
+ if (options.shared !== false) {
362
+ const runs = (incoming.runs || []).map(run => {
363
+ const previous = existingRuns.get(run.id);
364
+ const source = terminal(previous) ? previous : null;
365
+ if (!source || (source.runProjectId && source.runProjectId !== incoming.projectId)) return run;
366
+ // The same decision can still gain save/recovery evidence. Let the
367
+ // three-way merge retain those edits and reject genuine collisions.
368
+ if (run.trackedChangeStatus === source.trackedChangeStatus && run.undoStatus === source.undoStatus) return run;
369
+ // An absent local settlement has no edits to its metadata or auxiliary
370
+ // evidence. Retain the stored container as well as the terminal fields.
371
+ const target = !run.settlement && !run.settlementFacts ? { ...run, settlement: factsOf(source) } : run;
372
+ return copyRunReviewState(target, source);
373
+ });
374
+ return { ...incoming, runs };
375
+ }
324
376
  var newer = (Date.parse(incoming.updatedAt) || 0) >= (Date.parse(existing.updatedAt) || 0);
325
377
  if (!newer && !explicitReview) return incoming;
326
378
  var base = newer ? incoming : existing;
@@ -404,6 +456,8 @@
404
456
  compactSessionBatchActions: compactSessionBatchActions,
405
457
  compactStructuredEventValue: compactStructuredEventValue,
406
458
  mergeSessionReviewState: mergeSessionReviewState,
459
+ copyRunReviewState: copyRunReviewState,
460
+ reviewStateSnapshot: reviewStateSnapshot,
407
461
  compactRunsForStorage: compactRunsForStorage,
408
462
  compactRunActionPayload: compactRunActionPayload,
409
463
  compactProviderSnapshot: compactProviderSnapshot,
@@ -8,6 +8,41 @@
8
8
  'use strict';
9
9
 
10
10
  function buildUndoOperations(project, appliedOperations) {
11
+ return buildUndoPlan(project, appliedOperations).undoOperations;
12
+ }
13
+
14
+ function getUndoUnavailableFiles(run = {}) {
15
+ const payload = run.recoveryPayload || run;
16
+ const files = new Map();
17
+ for (const operation of payload.appliedOperations || []) {
18
+ const reason = operation?.undoUnavailable;
19
+ if (typeof reason?.path === 'string' && reason.path === operation?.path
20
+ && ['stale_source_changed', 'snapshot_undo_unavailable'].includes(reason.code)) {
21
+ files.set(reason.path, { path: reason.path, code: reason.code });
22
+ }
23
+ }
24
+ return [...files.values()];
25
+ }
26
+
27
+ function withAvailableUndo(run = {}) {
28
+ const unavailable = new Set(getUndoUnavailableFiles(run).map(item => item.path));
29
+ if (!unavailable.size) return run;
30
+ const result = { ...run };
31
+ for (const field of ['undoOperations', 'undoBaseFiles', 'undoExpectedFiles', 'undoTrackedChanges', 'trackedChangeCaptures']) {
32
+ result[field] = (run[field] || []).filter(item => !unavailable.has(item?.path) && !unavailable.has(item?.to));
33
+ }
34
+ return result;
35
+ }
36
+
37
+ // Fallback history keeps only bounded reasons, never document bytes or
38
+ // authority to reconstruct a withheld checkpoint.
39
+ function compactUnavailableUndoOperations(run = {}) {
40
+ if (run.forkSnapshot || run.undoStatus === 'applied' || ['accepted', 'rejected'].includes(run.trackedChangeStatus)) return [];
41
+ return getUndoUnavailableFiles(run).filter(item => item.path.length <= 1024).slice(0, 100)
42
+ .map(item => ({ type: 'edit', path: item.path, undoUnavailable: item }));
43
+ }
44
+
45
+ function buildUndoPlan(project, appliedOperations) {
11
46
  const workingFilesByPath = new Map();
12
47
  for (const file of project?.files || []) {
13
48
  if (typeof file?.path === 'string' && typeof file.content === 'string') {
@@ -17,6 +52,10 @@
17
52
 
18
53
  const sourceFilesByPath = new Map(workingFilesByPath);
19
54
  const undo = [];
55
+ const inverseBases = new Map();
56
+ const mappedPaths = new Set((appliedOperations || []).filter(operation => Array.isArray(operation?.appliedPatches))
57
+ .map(operation => operation.path));
58
+ const unavailable = new Map(getUndoUnavailableFiles({ appliedOperations }).map(item => [item.path, item.code]));
20
59
  const fullRestoreEditPaths = new Set();
21
60
 
22
61
  for (const operation of appliedOperations || []) {
@@ -30,23 +69,33 @@
30
69
  }
31
70
  const currentBeforeEdit = workingFilesByPath.get(operation.path);
32
71
  if (!fullRestoreEditPaths.has(operation.path)) {
33
- const inversePatches = buildInverseTextPatches(sourceFilesByPath.get(operation.path), operation.patches,
34
- operation.verifiedContent, operation.path);
72
+ const hasAppliedPatches = Array.isArray(operation.appliedPatches) && typeof operation.verifiedContent === 'string';
73
+ const inversePatches = hasAppliedPatches
74
+ ? buildVerifiedInversePatches(operation.appliedPatches, operation.verifiedContent)
75
+ : buildInverseTextPatches(sourceFilesByPath.get(operation.path), operation.patches,
76
+ operation.verifiedContent, operation.path);
77
+ let inverse;
35
78
  if (inversePatches.length) {
36
- undo.push(normalizeUndoOperation({
79
+ inverse = normalizeUndoOperation({
37
80
  type: 'edit',
38
81
  path: operation.path,
39
82
  patches: inversePatches,
40
83
  reason: 'Undo edit'
41
- }));
42
- } else {
84
+ });
85
+ } else if (!hasAppliedPatches) {
43
86
  fullRestoreEditPaths.add(operation.path);
44
- undo.push(normalizeUndoOperation({
87
+ inverse = normalizeUndoOperation({
45
88
  type: 'edit',
46
89
  path: operation.path,
47
90
  replaceAll: currentBeforeEdit,
48
91
  reason: 'Undo edit'
49
- }));
92
+ });
93
+ } else {
94
+ unavailable.set(operation.path, 'snapshot_undo_unavailable');
95
+ }
96
+ if (inverse) {
97
+ undo.push(inverse);
98
+ if (typeof operation.verifiedContent === 'string') inverseBases.set(inverse, operation.verifiedContent);
50
99
  }
51
100
  }
52
101
  } else if (operation.type === 'create') {
@@ -97,7 +146,51 @@
97
146
  applyOperationToFiles(sourceFilesByPath, { ...operation, verifiedContent: undefined });
98
147
  }
99
148
 
100
- return undo.reverse();
149
+ // Each inverse was measured in its own verified post-image. Compose in
150
+ // reverse order against the latest image, preserving changes between
151
+ // entries. Empty inverse insertions must never reuse an older offset.
152
+ const composed = [];
153
+ for (const inverse of undo.reverse()) {
154
+ let operation = inverse;
155
+ if (mappedPaths.has(inverse.path) && inverseBases.has(inverse)) {
156
+ const current = workingFilesByPath.get(inverse.path);
157
+ const base = StaleGuard.buildBaseFileLookup([{ path: inverse.path, content: inverseBases.get(inverse) }]);
158
+ const fresh = StaleGuard.checkOperationFreshness(inverse, current, base);
159
+ const prepared = fresh.ok && inverse.patches
160
+ ? fresh.preparedPatches || StaleGuard.prepareOperationPatches(inverse, current, base) : fresh;
161
+ if (typeof current !== 'string' || !prepared.ok) {
162
+ unavailable.set(inverse.path, 'stale_source_changed');
163
+ continue;
164
+ }
165
+ if (inverse.patches) operation = { ...inverse, patches: prepared.patches };
166
+ }
167
+ composed.push(operation);
168
+ applyOperationToFiles(workingFilesByPath, operation);
169
+ }
170
+ return { undoOperations: composed.filter(operation => !unavailable.has(operation.path)),
171
+ undoUnavailable: [...unavailable].map(([path, code]) => ({ path, code })) };
172
+ }
173
+
174
+ // Invert the ranges actually dispatched to C, not the original Native
175
+ // coordinates in B. Verify against the observed post-image before storing
176
+ // recovery; invalid evidence must not fall back to overwriting C with B.
177
+ function buildVerifiedInversePatches(patches, postImage) {
178
+ const beforeLength = patches.reduce((length, patch) =>
179
+ length + Number(patch?.to) - Number(patch?.from) - String(patch?.insert ?? '').length, postImage.length);
180
+ if (!Number.isSafeInteger(beforeLength) || beforeLength < 0) return [];
181
+ const normalized = normalizeTextPatches(patches, beforeLength);
182
+ if (!normalized.ok || normalized.patches.some(patch => patch.expected.length !== patch.to - patch.from)) return [];
183
+ const inverse = invertPatches(normalized.patches);
184
+ return applyTextPatches(postImage, inverse).ok ? inverse : [];
185
+ }
186
+
187
+ function invertPatches(patches) {
188
+ let offset = 0;
189
+ return patches.map(patch => {
190
+ const from = patch.from + offset;
191
+ offset += patch.insert.length - (patch.to - patch.from);
192
+ return { from, to: from + patch.insert.length, expected: patch.insert, insert: patch.expected };
193
+ });
101
194
  }
102
195
 
103
196
  function buildInverseTextPatches(textBeforeEdit, patches, verifiedContent, path) {
@@ -113,28 +206,20 @@
113
206
  return [];
114
207
  }
115
208
 
116
- let offset = 0;
117
- const inverse = normalized.patches.map(patch => {
118
- const from = patch.from + offset;
119
- const to = from + patch.insert.length;
120
- offset += patch.insert.length - (patch.to - patch.from);
121
- return {
122
- from,
123
- to,
124
- expected: patch.insert,
125
- insert: patch.expected
126
- };
127
- });
209
+ const inverse = invertPatches(normalized.patches);
128
210
  if (typeof verifiedContent !== 'string' || verifiedContent === applied.text) return inverse;
129
211
  // The stored checkpoint uses the verified editor post-image. Its inverse
130
212
  // must use those same offsets, while Native patches retain their source
131
213
  // representation for later operations in the batch.
132
- const prepared = StaleGuard.prepareOperationPatches({ type: 'edit', path, patches: inverse }, verifiedContent,
214
+ const prepared = StaleGuard.prepareStoredInversePatches({ type: 'edit', path, patches: inverse }, verifiedContent,
133
215
  StaleGuard.buildBaseFileLookup([{ path, content: applied.text }]));
134
216
  return prepared.ok && applyTextPatches(verifiedContent, prepared.patches).ok ? prepared.patches : [];
135
217
  }
136
218
 
137
219
  function selectExpectedFilesForTrackedUndo(project, operations = [], trackedChanges = [], previousExpectedFiles = []) {
220
+ const checkpoint = buildUndoCheckpoint(project, operations);
221
+ const unavailable = new Set(checkpoint.undoUnavailable.map(item => item.path));
222
+ const mappedPaths = new Set(operations.filter(operation => Array.isArray(operation?.appliedPatches)).map(operation => operation.path));
138
223
  const paths = new Set();
139
224
  for (const change of trackedChanges || []) if (change?.path) paths.add(change.path);
140
225
  for (const operation of operations || []) {
@@ -143,7 +228,8 @@
143
228
  }
144
229
  const expectedByPath = new Map();
145
230
  for (const file of previousExpectedFiles || []) {
146
- if (paths.has(file?.path) && typeof file?.content === 'string' && !expectedByPath.has(file.path)) {
231
+ if (paths.has(file?.path) && !unavailable.has(file.path) && !mappedPaths.has(file.path)
232
+ && typeof file?.content === 'string' && !expectedByPath.has(file.path)) {
147
233
  expectedByPath.set(file.path, { path: file.path, content: file.content });
148
234
  }
149
235
  }
@@ -152,10 +238,9 @@
152
238
  // content, including when the ZIP baseline used a different representation.
153
239
  const hasVerifiedPatches = operations.some(operation => typeof operation?.verifiedContent === 'string'
154
240
  && Array.isArray(operation.patches) && operation.patches.length);
155
- const checkpoint = hasVerifiedPatches ? buildUndoCheckpoint(project, operations) : null;
156
- const restored = checkpoint ? buildExpectedFilesAfterOperations({ files: checkpoint.undoBaseFiles }, checkpoint.undoOperations) : new Map();
241
+ const restored = hasVerifiedPatches ? buildExpectedFilesAfterOperations({ files: checkpoint.undoBaseFiles }, checkpoint.undoOperations) : new Map();
157
242
  for (const file of project?.files || []) {
158
- if (paths.has(file?.path) && typeof file?.content === 'string' && !expectedByPath.has(file.path)) {
243
+ if (paths.has(file?.path) && !unavailable.has(file.path) && typeof file?.content === 'string' && !expectedByPath.has(file.path)) {
159
244
  expectedByPath.set(file.path, { path: file.path, content: restored.get(file.path) ?? file.content });
160
245
  }
161
246
  }
@@ -163,11 +248,15 @@
163
248
  }
164
249
 
165
250
  function buildUndoCheckpoint(project, appliedOperations) {
166
- const undoOperations = buildUndoOperations(project, appliedOperations);
251
+ const { undoOperations, undoUnavailable } = buildUndoPlan(project, appliedOperations);
252
+ const unavailable = new Map(undoUnavailable.map(item => [item.path, item]));
167
253
  const expectedFiles = buildExpectedFilesAfterOperations(project, appliedOperations);
168
254
  return {
169
255
  undoOperations,
170
- undoBaseFiles: filesMapToList(selectUndoBaseFiles(expectedFiles, undoOperations))
256
+ undoBaseFiles: filesMapToList(selectUndoBaseFiles(expectedFiles, undoOperations)),
257
+ undoUnavailable,
258
+ appliedOperations: (appliedOperations || []).map(operation => unavailable.has(operation?.path)
259
+ ? { ...operation, undoUnavailable: unavailable.get(operation.path) } : operation)
171
260
  };
172
261
  }
173
262
 
@@ -236,6 +325,7 @@
236
325
  // file creation, backed by both the forward delete and the captured original.
237
326
  // This also recovers older Track-on records that omitted that inverse.
238
327
  function getFileUndoOperations(run = {}) {
328
+ run = withAvailableUndo(run);
239
329
  const operations = (run.undoOperations || []).filter(isFileUndoOperation);
240
330
  const before = filesListToMap(run.undoExpectedFiles);
241
331
  const after = buildExpectedFilesAfterOperations({ files: run.undoExpectedFiles }, run.appliedOperations);
@@ -246,6 +336,8 @@
246
336
  }
247
337
 
248
338
  function prepareRunUndo(run = {}) {
339
+ const original = run;
340
+ run = withAvailableUndo(run);
249
341
  const files = getFileUndoOperations(run);
250
342
  const restored = new Set(files.filter(operation => operation.type === 'create').map(operation => operation.path));
251
343
  const pending = [...files], operations = [];
@@ -255,10 +347,12 @@
255
347
  if (index >= 0) operations.push(...pending.splice(index, 1));
256
348
  } else if (!restored.has(operation.path)) operations.push(operation);
257
349
  }
258
- return { ...run, undoOperations: [...operations, ...pending] };
350
+ return { ...original, undoOperations: [...operations, ...pending],
351
+ ...(run !== original ? { undoBaseFiles: run.undoBaseFiles, undoExpectedFiles: run.undoExpectedFiles } : {}) };
259
352
  }
260
353
 
261
354
  function textUndoRun(run = {}) {
355
+ run = withAvailableUndo(run);
262
356
  const restored = new Set(getFileUndoOperations(run).flatMap(operation => operation.type === 'create' ? [operation.path]
263
357
  : operation.type === 'move' ? [operation.path, operation.to] : []));
264
358
  return { ...run,
@@ -332,6 +426,7 @@
332
426
  }
333
427
 
334
428
  function buildSnapshotRestoreUndo(run = {}) {
429
+ run = withAvailableUndo(run);
335
430
  const undoOperations = Array.isArray(run.undoOperations) ? run.undoOperations : [];
336
431
  const originalByPath = buildOriginalFilesForSnapshotUndo(run, undoOperations);
337
432
  if (!originalByPath.size) {
@@ -571,6 +666,9 @@
571
666
  }
572
667
 
573
668
  return {
669
+ getUndoUnavailableFiles,
670
+ withAvailableUndo,
671
+ compactUnavailableUndoOperations,
574
672
  isFileUndoOperation,
575
673
  getFileUndoOperations,
576
674
  prepareRunUndo,
@@ -12,6 +12,18 @@
12
12
  return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
13
13
  }
14
14
 
15
+ function verifiedRollback(status = {}) {
16
+ const transaction = status.transaction;
17
+ const pair = status.restoredPair;
18
+ const digest = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
19
+ return Boolean(transaction?.id && transaction.state === 'rolled_back'
20
+ && version(transaction.sourceVersion) && version(transaction.targetVersion)
21
+ && status.installedAligned === true && status.activeVersion === transaction.sourceVersion
22
+ && pair?.extensionVersion === transaction.sourceVersion && pair.nativeVersion === transaction.sourceVersion
23
+ && digest(pair.extensionSha256) && digest(pair.nativeSha256)
24
+ && (!transaction.bootstrapRevision || digest(pair.bootstrapSha256)));
25
+ }
26
+
15
27
  function inspectInstalledRuntime(status = {}, options = {}) {
16
28
  const result = { installedVersion: version(status.activeVersion), nativeVersion: version(status.runtimeVersion),
17
29
  extensionVersion: version(options.extensionVersion), runtimeVersion: version(options.runtimeVersion), state: 'unknown' };
@@ -24,9 +36,11 @@
24
36
  if (status.installedAligned !== true) return { ...result, state: 'repair_required' };
25
37
  const loaded = [result.nativeVersion, result.extensionVersion, result.runtimeVersion];
26
38
  if (loaded.every(value => value === result.installedVersion)) return { ...result, state: 'aligned' };
27
- return { ...result, state: loaded.every(value => compare(result.installedVersion, value) >= 0)
39
+ const rollbackReload = verifiedRollback(status) && loaded.every(value =>
40
+ value === status.transaction.sourceVersion || value === status.transaction.targetVersion);
41
+ return { ...result, state: rollbackReload || loaded.every(value => compare(result.installedVersion, value) >= 0)
28
42
  ? 'reload_required' : 'repair_required' };
29
43
  }
30
44
 
31
- return Object.freeze({ inspectInstalledRuntime });
45
+ return Object.freeze({ inspectInstalledRuntime, verifiedRollback });
32
46
  });
@@ -28,7 +28,18 @@
28
28
  const SETTLEMENT_FACTS_MAX_BYTES = SettlementFacts.SETTLEMENT_FACTS_MAX_BYTES;
29
29
  const RECOVERY_FIELDS = SettlementFacts.RECOVERY_FIELDS;
30
30
  const compactSettlementFacts = SettlementFacts.compactSettlementFacts;
31
- const projectRunSettlement = SettlementFacts.projectRunSettlement;
31
+ function projectRunSettlement(run = {}) {
32
+ const projection = SettlementFacts.projectRunSettlement(run);
33
+ const undoUnavailable = UndoOperations.getUndoUnavailableFiles(run);
34
+ if (!undoUnavailable.length) return projection;
35
+ const payload = UndoOperations.withAvailableUndo(run.recoveryPayload || run);
36
+ // Forward receipts remain audit evidence, not fallback Undo authority for
37
+ // a run whose inverse checkpoint could not be safely composed.
38
+ const available = SettlementFacts.projectRunSettlement({ ...run,
39
+ recoveryPayload: { ...payload, appliedOperations: [] } });
40
+ return { ...projection, canUndo: available.canUndo, undoUnavailable,
41
+ actions: { ...projection.actions, undo: available.canUndo } };
42
+ }
32
43
  const deriveLegacyProjection = SettlementFacts.deriveLegacyProjection;
33
44
  const aggregateFileSettlements = EvidenceProjection.aggregateFileSettlements;
34
45
  const deriveAppliedEvidence = EvidenceProjection.deriveAppliedEvidence;
@@ -297,7 +308,8 @@
297
308
  // Both actions cover every targeted file. Partial success must retain
298
309
  // the original recovery material for unresolved siblings.
299
310
  const incomplete = collectRunResultSkipped(input.result).length > 0
300
- || input.result?.ok === false;
311
+ || input.result?.ok === false
312
+ || (kind === 'reject' && UndoOperations.getUndoUnavailableFiles(input.run).length > 0);
301
313
  const needsReview = incomplete || !successful || failures.some(failure =>
302
314
  failure.terminalState === 'needs_review' || reviewCodes.has(failure.code)
303
315
  );
@@ -340,6 +352,7 @@
340
352
 
341
353
  function settleLegacyUndo(input = {}) {
342
354
  const status = input.status === 'applied'
355
+ && !UndoOperations.getUndoUnavailableFiles(input.run).length
343
356
  && isUndoResultEffectivelyApplied(input.run, input.result, input.run?.undoOperations) ? 'applied' : 'partial';
344
357
  const recovery = preserveRecovery();
345
358
  return {
@@ -650,7 +663,12 @@
650
663
  ? { v: 1, kind: rawMove.kind, sha256: rawMove.sha256.toLowerCase(), sourceEntityId: rawMove.sourceEntityId,
651
664
  targetEntityId: rawMove.targetEntityId, ...(['doc', 'file'].includes(rawMove.entityType) ? { entityType: rawMove.entityType } : {}), ...(createdDirectories.length ? { createdDirectories } : {}) } : null;
652
665
  if (!(operation.type === 'edit' && typeof result?.verifiedContent === 'string') && !createdDirectories.length && !moveProof) return operation;
653
- return { ...operation, ...(operation.type === 'edit' && typeof result?.verifiedContent === 'string' ? { verifiedContent: result.verifiedContent } : {}),
666
+ return { ...operation, ...(operation.type === 'edit' && typeof result?.verifiedContent === 'string' ? {
667
+ verifiedContent: result.verifiedContent,
668
+ ...(result.ok === true && result.verified === true && Array.isArray(result.appliedPatches)
669
+ ? { appliedPatches: result.appliedPatches.map(patch => ({ from: patch.from, to: patch.to,
670
+ expected: String(patch.expected ?? ''), insert: String(patch.insert ?? '') })) } : {})
671
+ } : {}),
654
672
  ...(createdDirectories.length ? { createdDirectories } : {}), ...(moveProof ? { moveProof } : {}) };
655
673
  }
656
674