Skip to content

Commit d262177

Browse files
committed
feat(sync): add manual rollback
1 parent 105730c commit d262177

6 files changed

Lines changed: 699 additions & 7 deletions

File tree

lib/codex-cli/sync.ts

Lines changed: 224 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
1216
import {
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+
75101
export 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

101129
export 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+
145197
function 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

206259
function 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

215271
function 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+
300507
function hasConflictingIdentity(
301508
accounts: AccountMetadataV3[],
302509
snapshot: CodexCliAccountSnapshot,
@@ -763,14 +970,19 @@ export async function previewCodexCliSync(
763970
*/
764971
export 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

Comments
 (0)