@@ -7,7 +7,11 @@ import {
77 findMatchingAccountIndex ,
88 getLastAccountsSaveTimestamp ,
99 getStoragePath ,
10+ type NamedBackupMetadata ,
11+ normalizeAccountStorage ,
1012 normalizeEmailKey ,
13+ saveAccounts ,
14+ snapshotAccountStorage ,
1115} from "../storage.js" ;
1216import {
1317 incrementCodexCliMetric ,
@@ -72,6 +76,28 @@ export interface CodexCliSyncSummary {
7276 selectionChanged : boolean ;
7377}
7478
79+ export type CodexCliSyncTrigger = "manual" | "automatic" ;
80+
81+ export interface CodexCliSyncRollbackSnapshot {
82+ name : string ;
83+ path : string ;
84+ }
85+
86+ export interface CodexCliSyncRollbackPlan {
87+ status : "ready" | "unavailable" ;
88+ reason : string ;
89+ snapshot : CodexCliSyncRollbackSnapshot | null ;
90+ accountCount ?: number ;
91+ storage ?: AccountStorageV3 ;
92+ }
93+
94+ export interface CodexCliSyncRollbackResult {
95+ status : "restored" | "unavailable" | "error" ;
96+ reason : string ;
97+ snapshot : CodexCliSyncRollbackSnapshot | null ;
98+ accountCount ?: number ;
99+ }
100+
75101export interface CodexCliSyncBackupContext {
76102 enabled : boolean ;
77103 targetPath : string ;
@@ -96,6 +122,8 @@ export interface CodexCliSyncRun {
96122 targetPath : string ;
97123 summary : CodexCliSyncSummary ;
98124 message ?: string ;
125+ trigger : CodexCliSyncTrigger ;
126+ rollbackSnapshot : CodexCliSyncRollbackSnapshot | null ;
99127}
100128
101129export interface PendingCodexCliSyncRun {
@@ -135,13 +163,37 @@ function createEmptySyncSummary(): CodexCliSyncSummary {
135163 } ;
136164}
137165
138- function cloneCodexCliSyncRun ( run : CodexCliSyncRun ) : CodexCliSyncRun {
166+ function normalizeRollbackSnapshot (
167+ snapshot : CodexCliSyncRollbackSnapshot | null | undefined ,
168+ ) : CodexCliSyncRollbackSnapshot | null {
169+ if ( ! snapshot || typeof snapshot !== "object" ) {
170+ return null ;
171+ }
172+ if ( typeof snapshot . name !== "string" || typeof snapshot . path !== "string" ) {
173+ return null ;
174+ }
175+ return {
176+ name : snapshot . name ,
177+ path : snapshot . path ,
178+ } ;
179+ }
180+
181+ function normalizeCodexCliSyncRun (
182+ run : CodexCliSyncRun | null ,
183+ ) : CodexCliSyncRun | null {
184+ if ( ! run ) return null ;
139185 return {
140186 ...run ,
141187 summary : { ...run . summary } ,
188+ trigger : run . trigger === "manual" ? "manual" : "automatic" ,
189+ rollbackSnapshot : normalizeRollbackSnapshot ( run . rollbackSnapshot ) ,
142190 } ;
143191}
144192
193+ function cloneCodexCliSyncRun ( run : CodexCliSyncRun ) : CodexCliSyncRun {
194+ return normalizeCodexCliSyncRun ( run ) ?? run ;
195+ }
196+
145197function normalizeIndexCandidate ( value : number , fallback : number ) : number {
146198 if ( ! Number . isFinite ( value ) ) {
147199 return Number . isFinite ( fallback ) ? Math . trunc ( fallback ) : 0 ;
@@ -200,16 +252,20 @@ function buildSyncRunError(
200252 ...run ,
201253 outcome : "error" ,
202254 message : error instanceof Error ? error . message : String ( error ) ,
255+ rollbackSnapshot : null ,
203256 } ;
204257}
205258
206259function createSyncRun (
207- run : Omit < CodexCliSyncRun , "runAt" > ,
260+ run : Omit < CodexCliSyncRun , "runAt" | "trigger" | "rollbackSnapshot" > &
261+ Partial < Pick < CodexCliSyncRun , "trigger" | "rollbackSnapshot" > > ,
208262) : CodexCliSyncRun {
209- return {
263+ return cloneCodexCliSyncRun ( {
210264 ...run ,
211265 runAt : Date . now ( ) ,
212- } ;
266+ trigger : run . trigger ?? "automatic" ,
267+ rollbackSnapshot : run . rollbackSnapshot ?? null ,
268+ } ) ;
213269}
214270
215271function hasSourceStateOverride ( options : {
@@ -264,6 +320,9 @@ export function commitPendingCodexCliSyncRun(
264320 {
265321 ...pendingRun . run ,
266322 runAt : Date . now ( ) ,
323+ rollbackSnapshot : normalizeRollbackSnapshot (
324+ pendingRun . run . rollbackSnapshot ,
325+ ) ,
267326 } ,
268327 allocateCodexCliSyncRunRevision ( ) ,
269328 ) ;
@@ -297,6 +356,154 @@ export function __resetLastCodexCliSyncRunForTests(): void {
297356 lastCodexCliSyncHistoryLoadAttempted = false ;
298357}
299358
359+ async function captureRollbackSnapshot ( ) : Promise < CodexCliSyncRollbackSnapshot | null > {
360+ const snapshot : NamedBackupMetadata | null = await snapshotAccountStorage ( {
361+ reason : "codex-cli-sync" ,
362+ failurePolicy : "warn" ,
363+ } ) ;
364+ if ( ! snapshot ) return null ;
365+ return {
366+ name : snapshot . name ,
367+ path : snapshot . path ,
368+ } ;
369+ }
370+
371+ function isManualChangedSyncRun ( run : CodexCliSyncRun | null ) : run is CodexCliSyncRun {
372+ return Boolean ( run && run . outcome === "changed" && run . trigger === "manual" ) ;
373+ }
374+
375+ async function findLatestManualRollbackRun ( ) : Promise <
376+ CodexCliSyncRun | null
377+ > {
378+ const history = await readSyncHistory ( { kind : "codex-cli-sync" } ) ;
379+ for ( let index = history . length - 1 ; index >= 0 ; index -= 1 ) {
380+ const entry = history [ index ] ;
381+ if ( ! entry || entry . kind !== "codex-cli-sync" ) continue ;
382+ const run = normalizeCodexCliSyncRun ( entry . run ) ;
383+ if ( isManualChangedSyncRun ( run ) ) {
384+ return run ;
385+ }
386+ }
387+ return null ;
388+ }
389+
390+ async function loadRollbackSnapshot (
391+ snapshot : CodexCliSyncRollbackSnapshot | null ,
392+ ) : Promise < CodexCliSyncRollbackPlan > {
393+ if ( ! snapshot ) {
394+ return {
395+ status : "unavailable" ,
396+ reason : "No rollback checkpoint is available for the last manual apply." ,
397+ snapshot : null ,
398+ } ;
399+ }
400+ if ( ! snapshot . name . trim ( ) ) {
401+ return {
402+ status : "unavailable" ,
403+ reason : "Rollback checkpoint is missing its snapshot name." ,
404+ snapshot : null ,
405+ } ;
406+ }
407+ if ( ! snapshot . path . trim ( ) ) {
408+ return {
409+ status : "unavailable" ,
410+ reason : "Rollback checkpoint is missing its snapshot path." ,
411+ snapshot : null ,
412+ } ;
413+ }
414+
415+ try {
416+ const raw = await fs . readFile ( snapshot . path , "utf-8" ) ;
417+ const parsed = JSON . parse ( raw ) as unknown ;
418+ const normalized = normalizeAccountStorage ( parsed ) ;
419+ if ( ! normalized ) {
420+ return {
421+ status : "unavailable" ,
422+ reason : "Rollback checkpoint is invalid or empty." ,
423+ snapshot,
424+ } ;
425+ }
426+ return {
427+ status : "ready" ,
428+ reason : `Rollback checkpoint ready (${ normalized . accounts . length } account(s)).` ,
429+ snapshot,
430+ accountCount : normalized . accounts . length ,
431+ storage : normalized ,
432+ } ;
433+ } catch ( error ) {
434+ const reason =
435+ ( error as NodeJS . ErrnoException ) . code === "ENOENT"
436+ ? `Rollback checkpoint is missing at ${ snapshot . path } .`
437+ : `Failed to read rollback checkpoint: ${
438+ error instanceof Error ? error . message : String ( error )
439+ } `;
440+ return {
441+ status : "unavailable" ,
442+ reason,
443+ snapshot,
444+ } ;
445+ }
446+ }
447+
448+ export async function getLatestCodexCliSyncRollbackPlan ( ) : Promise < CodexCliSyncRollbackPlan > {
449+ const lastManualRun = await findLatestManualRollbackRun ( ) ;
450+ if ( ! lastManualRun ) {
451+ return {
452+ status : "unavailable" ,
453+ reason : "No manual Codex CLI apply with a rollback checkpoint is available." ,
454+ snapshot : null ,
455+ } ;
456+ }
457+ return loadRollbackSnapshot ( lastManualRun . rollbackSnapshot ) ;
458+ }
459+
460+ export async function rollbackLatestCodexCliSync (
461+ plan ?: CodexCliSyncRollbackPlan ,
462+ ) : Promise < CodexCliSyncRollbackResult > {
463+ const resolvedPlan =
464+ plan && plan . status === "ready"
465+ ? plan
466+ : await getLatestCodexCliSyncRollbackPlan ( ) ;
467+ if ( resolvedPlan . status !== "ready" || ! resolvedPlan . storage ) {
468+ return {
469+ status : "unavailable" ,
470+ reason : resolvedPlan . reason ,
471+ snapshot : resolvedPlan . snapshot ,
472+ } ;
473+ }
474+
475+ try {
476+ await saveAccounts ( resolvedPlan . storage ) ;
477+ return {
478+ status : "restored" ,
479+ reason : resolvedPlan . reason ,
480+ snapshot : resolvedPlan . snapshot ,
481+ accountCount :
482+ resolvedPlan . accountCount ?? resolvedPlan . storage . accounts . length ,
483+ } ;
484+ } catch ( error ) {
485+ return {
486+ status : "error" ,
487+ reason : error instanceof Error ? error . message : String ( error ) ,
488+ snapshot : resolvedPlan . snapshot ,
489+ } ;
490+ }
491+ }
492+
493+ export async function rollbackLastCodexCliSync ( ) : Promise <
494+ CodexCliSyncRollbackResult & { status : "restored" ; snapshot : CodexCliSyncRollbackSnapshot }
495+ > {
496+ const result = await rollbackLatestCodexCliSync ( ) ;
497+ if ( result . status !== "restored" || ! result . snapshot ) {
498+ throw new Error ( result . reason ) ;
499+ }
500+ return {
501+ ...result ,
502+ status : "restored" ,
503+ snapshot : result . snapshot ,
504+ } ;
505+ }
506+
300507function hasConflictingIdentity (
301508 accounts : AccountMetadataV3 [ ] ,
302509 snapshot : CodexCliAccountSnapshot ,
@@ -763,14 +970,19 @@ export async function previewCodexCliSync(
763970 */
764971export async function applyCodexCliSyncToStorage (
765972 current : AccountStorageV3 | null ,
766- options : { forceRefresh ?: boolean ; sourceState ?: CodexCliState | null } = { } ,
973+ options : {
974+ forceRefresh ?: boolean ;
975+ sourceState ?: CodexCliState | null ;
976+ trigger ?: CodexCliSyncTrigger ;
977+ } = { } ,
767978) : Promise < {
768979 storage : AccountStorageV3 | null ;
769980 changed : boolean ;
770981 pendingRun : PendingCodexCliSyncRun | null ;
771982} > {
772983 incrementCodexCliMetric ( "reconcileAttempts" ) ;
773984 const targetPath = getStoragePath ( ) ;
985+ const trigger : CodexCliSyncTrigger = options . trigger ?? "automatic" ;
774986 try {
775987 if ( ! isCodexCliSyncEnabled ( ) ) {
776988 incrementCodexCliMetric ( "reconcileNoops" ) ;
@@ -785,6 +997,7 @@ export async function applyCodexCliSyncToStorage(
785997 targetAccountCountAfter : current ?. accounts . length ?? 0 ,
786998 } ,
787999 message : "Codex CLI sync disabled by environment override." ,
1000+ trigger,
7881001 } ) ,
7891002 allocateCodexCliSyncRunRevision ( ) ,
7901003 ) ;
@@ -808,6 +1021,7 @@ export async function applyCodexCliSyncToStorage(
8081021 targetAccountCountAfter : current ?. accounts . length ?? 0 ,
8091022 } ,
8101023 message : "No Codex CLI sync source was available." ,
1024+ trigger,
8111025 } ) ,
8121026 allocateCodexCliSyncRunRevision ( ) ,
8131027 ) ;
@@ -821,11 +1035,15 @@ export async function applyCodexCliSyncToStorage(
8211035 const changed = reconciled . changed ;
8221036 const storage =
8231037 next . accounts . length === 0 ? ( current ?? next ) : next ;
1038+ const rollbackSnapshot =
1039+ trigger === "manual" && changed ? await captureRollbackSnapshot ( ) : null ;
8241040 const syncRun = createSyncRun ( {
8251041 outcome : changed ? "changed" : "noop" ,
8261042 sourcePath : state . path ,
8271043 targetPath,
8281044 summary : reconciled . summary ,
1045+ trigger,
1046+ rollbackSnapshot,
8291047 } ) ;
8301048
8311049 if ( ! changed ) {
@@ -868,6 +1086,7 @@ export async function applyCodexCliSyncToStorage(
8681086 targetAccountCountAfter : current ?. accounts . length ?? 0 ,
8691087 } ,
8701088 message : error instanceof Error ? error . message : String ( error ) ,
1089+ trigger,
8711090 } ) ,
8721091 allocateCodexCliSyncRunRevision ( ) ,
8731092 ) ;
0 commit comments