@bitkyc08/opencodex 2.7.42 → 2.7.43
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 +8 -1
- package/bin/ocx.mjs +13 -14
- package/gui/dist/assets/index-Czw-jpTU.css +1 -0
- package/gui/dist/assets/index-cmds12BG.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +49 -6
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/inbound.ts +26 -9
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/claude.ts +3 -3
- package/src/cli/doctor.ts +23 -18
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +88 -19
- package/src/cli/interactive-confirm.ts +129 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +55 -16
- package/src/cli/status.ts +47 -13
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/parsing.ts +16 -1
- package/src/codex/catalog/provider-fetch.ts +48 -12
- package/src/codex/catalog/sync.ts +18 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +15 -0
- package/src/codex/routing.ts +695 -106
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +566 -25
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +122 -4
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +164 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +283 -72
- package/src/oauth/key-providers.ts +17 -6
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +322 -10
- package/src/oauth/kiro.ts +423 -43
- package/src/oauth/login-cli.ts +32 -5
- package/src/oauth/store.ts +54 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +11 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +14 -4
- package/src/server/auth-cors.ts +110 -22
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/management/agent-settings-routes.ts +28 -4
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +5 -2
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +35 -7
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +2 -2
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +583 -152
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +49 -2
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +366 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +242 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/tray/windows.ts +2 -0
- package/src/types.ts +96 -1
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +103 -12
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/log.ts +4 -0
- package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
package/src/storage/cleanup.ts
CHANGED
|
@@ -12,21 +12,29 @@
|
|
|
12
12
|
* satellite rows before staged files. Success never carries soft `dbWarning` /
|
|
13
13
|
* `failedPaths`.
|
|
14
14
|
*/
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
16
|
import {
|
|
17
|
+
closeSync,
|
|
17
18
|
existsSync,
|
|
19
|
+
fsyncSync,
|
|
20
|
+
linkSync,
|
|
18
21
|
mkdirSync,
|
|
22
|
+
openSync,
|
|
19
23
|
readdirSync,
|
|
24
|
+
readFileSync,
|
|
20
25
|
renameSync,
|
|
21
26
|
rmSync,
|
|
22
27
|
statSync,
|
|
23
28
|
unlinkSync,
|
|
24
29
|
writeFileSync,
|
|
30
|
+
writeSync,
|
|
25
31
|
chmodSync,
|
|
26
32
|
} from "node:fs";
|
|
27
33
|
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
28
34
|
import { Database } from "bun:sqlite";
|
|
29
35
|
import { resolveCodexHomeDir } from "../codex/home";
|
|
36
|
+
import { readThreadFieldsFromRollout } from "../codex/history-provider";
|
|
37
|
+
import { renameAtomicFile } from "../config";
|
|
30
38
|
|
|
31
39
|
export const ARCHIVED_SESSIONS_DIR = "archived_sessions";
|
|
32
40
|
export const TRASH_DIR = ".trash";
|
|
@@ -39,9 +47,11 @@ export type CleanupErrorCode =
|
|
|
39
47
|
| "invalid_digest"
|
|
40
48
|
| "stale_preview"
|
|
41
49
|
| "codex_busy"
|
|
50
|
+
| "storage_mutation_busy"
|
|
42
51
|
| "fs_failed"
|
|
43
52
|
| "db_reconcile_failed"
|
|
44
53
|
| "referenced_history"
|
|
54
|
+
| "restore_pending_overlap"
|
|
45
55
|
| "cleanup_failed";
|
|
46
56
|
|
|
47
57
|
export interface ArchivedCandidate {
|
|
@@ -233,9 +243,8 @@ export function normalizeArchivedRolloutPath(rolloutPath: string, codexHome: str
|
|
|
233
243
|
return logical;
|
|
234
244
|
}
|
|
235
245
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const lines = candidates
|
|
246
|
+
function candidateDigestLines(candidates: ArchivedCandidate[]): string[] {
|
|
247
|
+
return candidates
|
|
239
248
|
.map(c => {
|
|
240
249
|
const physical = [...c.physicalFiles]
|
|
241
250
|
.sort((a, b) => a.relPath.localeCompare(b.relPath))
|
|
@@ -244,8 +253,22 @@ export function computePreviewDigest(candidates: ArchivedCandidate[], percent: n
|
|
|
244
253
|
return `${c.relPath}|${c.bytes}|${Math.trunc(c.mtimeMs)}|${physical}`;
|
|
245
254
|
})
|
|
246
255
|
.sort();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Content digest of the exact previewed candidate set (paths + size + mtime). */
|
|
259
|
+
export function computePreviewDigest(candidates: ArchivedCandidate[], percent: number): string {
|
|
260
|
+
return createHash("sha256")
|
|
261
|
+
.update(`${clampPercent(percent)}\n${candidateDigestLines(candidates).join("\n")}`)
|
|
262
|
+
.digest("hex");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Digest bound to an explicit candidate list (not a percent selection).
|
|
267
|
+
* Used when reduceToBytes needs an exact count that percent rounding cannot represent.
|
|
268
|
+
*/
|
|
269
|
+
export function computeExactPreviewDigest(candidates: ArchivedCandidate[]): string {
|
|
247
270
|
return createHash("sha256")
|
|
248
|
-
.update(
|
|
271
|
+
.update(`exact\n${candidateDigestLines(candidates).join("\n")}`)
|
|
249
272
|
.digest("hex");
|
|
250
273
|
}
|
|
251
274
|
|
|
@@ -314,29 +337,161 @@ export function selectOldestPercent(candidates: ArchivedCandidate[], percent: nu
|
|
|
314
337
|
const pct = clampPercent(percent);
|
|
315
338
|
if (pct <= 0 || candidates.length === 0) return [];
|
|
316
339
|
if (pct >= 100) return [...candidates];
|
|
317
|
-
const n =
|
|
340
|
+
const n = percentSelectionTargetCount(candidates.length, pct);
|
|
318
341
|
return candidates.slice(0, n);
|
|
319
342
|
}
|
|
320
343
|
|
|
344
|
+
/** Count implied by percent selection over the full candidate list. */
|
|
345
|
+
export function percentSelectionTargetCount(totalCount: number, percent: number): number {
|
|
346
|
+
const pct = clampPercent(percent);
|
|
347
|
+
if (pct <= 0 || totalCount === 0) return 0;
|
|
348
|
+
if (pct >= 100) return totalCount;
|
|
349
|
+
return Math.max(1, Math.floor((totalCount * pct) / 100));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function candidateOverlapsPendingRestore(
|
|
353
|
+
candidate: ArchivedCandidate,
|
|
354
|
+
pendingDestRels: ReadonlySet<string>,
|
|
355
|
+
): boolean {
|
|
356
|
+
if (pendingDestRels.size === 0) return false;
|
|
357
|
+
for (const rel of candidate.physicalRelPaths) {
|
|
358
|
+
if (pendingDestRels.has(rel)) return true;
|
|
359
|
+
}
|
|
360
|
+
return pendingDestRels.has(candidate.relPath);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Drop cleanup candidates whose physical paths overlap an in-progress restore. */
|
|
364
|
+
export function filterCandidatesExcludingPendingRestore(
|
|
365
|
+
candidates: ArchivedCandidate[],
|
|
366
|
+
codexHome: string = resolveCodexHomeDir(),
|
|
367
|
+
): ArchivedCandidate[] {
|
|
368
|
+
const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
|
|
369
|
+
if (pendingDestRels.size === 0) return candidates;
|
|
370
|
+
return candidates.filter(c => !candidateOverlapsPendingRestore(c, pendingDestRels));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Oldest-first percent selection that skips pending-restore destinations without
|
|
375
|
+
* consuming the percent budget, backfilling with the next oldest safe candidates.
|
|
376
|
+
*/
|
|
377
|
+
export function selectOldestPercentSkippingPendingRestore(
|
|
378
|
+
candidates: ArchivedCandidate[],
|
|
379
|
+
percent: number,
|
|
380
|
+
codexHome: string = resolveCodexHomeDir(),
|
|
381
|
+
): ArchivedCandidate[] {
|
|
382
|
+
const target = percentSelectionTargetCount(candidates.length, percent);
|
|
383
|
+
if (target === 0) return [];
|
|
384
|
+
const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
|
|
385
|
+
const out: ArchivedCandidate[] = [];
|
|
386
|
+
for (const c of candidates) {
|
|
387
|
+
if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue;
|
|
388
|
+
out.push(c);
|
|
389
|
+
if (out.length >= target) break;
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Reduce archived total toward `reduceToBytes` using oldest safe candidates only.
|
|
396
|
+
* Pending-restore destinations are skipped and do not count toward bytes freed.
|
|
397
|
+
*/
|
|
398
|
+
export function selectReduceToBytesSkippingPendingRestore(
|
|
399
|
+
candidates: ArchivedCandidate[],
|
|
400
|
+
reduceToBytes: number,
|
|
401
|
+
codexHome: string = resolveCodexHomeDir(),
|
|
402
|
+
): ArchivedCandidate[] {
|
|
403
|
+
if (!Number.isFinite(reduceToBytes) || reduceToBytes < 0) return [];
|
|
404
|
+
const total = candidates.reduce((sum, c) => sum + c.bytes, 0);
|
|
405
|
+
if (total <= reduceToBytes) return [];
|
|
406
|
+
const need = total - reduceToBytes;
|
|
407
|
+
const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
|
|
408
|
+
const out: ArchivedCandidate[] = [];
|
|
409
|
+
let freed = 0;
|
|
410
|
+
for (const c of candidates) {
|
|
411
|
+
if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue;
|
|
412
|
+
out.push(c);
|
|
413
|
+
freed += c.bytes;
|
|
414
|
+
if (freed >= need) break;
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Accepted destination paths from every valid in-progress restore marker under `.trash`. */
|
|
420
|
+
export function collectRestorePendingAcceptedDestRels(codexHome: string): Set<string> {
|
|
421
|
+
const out = new Set<string>();
|
|
422
|
+
const trashRoot = join(codexHome, TRASH_DIR);
|
|
423
|
+
if (!existsSync(trashRoot)) return out;
|
|
424
|
+
for (const name of readdirSync(trashRoot)) {
|
|
425
|
+
if (!TRASH_EPOCH_DIR.test(name)) continue;
|
|
426
|
+
const read = readRestorePending(join(trashRoot, name));
|
|
427
|
+
if (read.status !== "valid") continue;
|
|
428
|
+
for (const rel of read.state.acceptedDestRels) out.add(rel);
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
321
433
|
export function previewArchivedCleanup(
|
|
322
434
|
percent: number,
|
|
323
435
|
codexHome: string = resolveCodexHomeDir(),
|
|
324
436
|
): CleanupPreview {
|
|
325
437
|
const all = listArchivedCandidates(codexHome);
|
|
326
|
-
const
|
|
438
|
+
const safe = selectOldestPercentSkippingPendingRestore(all, percent, codexHome);
|
|
327
439
|
const pct = clampPercent(percent);
|
|
328
440
|
return {
|
|
329
441
|
codexHome,
|
|
330
442
|
percent: pct,
|
|
331
|
-
count:
|
|
332
|
-
bytes:
|
|
333
|
-
digest: computePreviewDigest(
|
|
334
|
-
candidates:
|
|
443
|
+
count: safe.length,
|
|
444
|
+
bytes: safe.reduce((sum, c) => sum + c.bytes, 0),
|
|
445
|
+
digest: computePreviewDigest(safe, pct),
|
|
446
|
+
candidates: safe,
|
|
335
447
|
};
|
|
336
448
|
}
|
|
337
449
|
|
|
450
|
+
/** Preview bound to an explicit candidate set (exact digest, percent left at 0). */
|
|
451
|
+
export function previewExactArchivedCleanup(
|
|
452
|
+
candidates: ArchivedCandidate[],
|
|
453
|
+
codexHome: string = resolveCodexHomeDir(),
|
|
454
|
+
): CleanupPreview {
|
|
455
|
+
const safe = filterCandidatesExcludingPendingRestore(candidates, codexHome);
|
|
456
|
+
return {
|
|
457
|
+
codexHome,
|
|
458
|
+
percent: 0,
|
|
459
|
+
count: safe.length,
|
|
460
|
+
bytes: safe.reduce((sum, c) => sum + c.bytes, 0),
|
|
461
|
+
digest: computeExactPreviewDigest(safe),
|
|
462
|
+
candidates: safe,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Resolve an exact candidate list from current archive state.
|
|
468
|
+
* Returns null when any requested path is missing or drifted (caller maps to stale_preview).
|
|
469
|
+
*/
|
|
470
|
+
export function resolveExactArchivedCandidates(
|
|
471
|
+
candidateRelPaths: string[],
|
|
472
|
+
codexHome: string = resolveCodexHomeDir(),
|
|
473
|
+
): ArchivedCandidate[] | null {
|
|
474
|
+
if (!Array.isArray(candidateRelPaths) || candidateRelPaths.length === 0) return [];
|
|
475
|
+
const all = listArchivedCandidates(codexHome);
|
|
476
|
+
const byRel = new Map(all.map(c => [c.relPath, c]));
|
|
477
|
+
const selected: ArchivedCandidate[] = [];
|
|
478
|
+
for (const rel of candidateRelPaths) {
|
|
479
|
+
const hit = byRel.get(rel);
|
|
480
|
+
if (!hit) return null;
|
|
481
|
+
selected.push(hit);
|
|
482
|
+
}
|
|
483
|
+
return selected;
|
|
484
|
+
}
|
|
485
|
+
|
|
338
486
|
function openDbWritable(dbPath: string, busyTimeoutMs = 100): Database {
|
|
339
487
|
const db = new Database(dbPath);
|
|
488
|
+
try {
|
|
489
|
+
// bun:sqlite exposes a binding-level timeout; set both so Windows lock waits
|
|
490
|
+
// honor the caller's budget (pragma alone has been flaky under CI contention).
|
|
491
|
+
(db as Database & { timeout?: number }).timeout = busyTimeoutMs;
|
|
492
|
+
} catch {
|
|
493
|
+
/* older bindings */
|
|
494
|
+
}
|
|
340
495
|
try {
|
|
341
496
|
db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
342
497
|
} catch {
|
|
@@ -552,6 +707,10 @@ type SqlRow = Record<string, string | number | bigint | null | Uint8Array>;
|
|
|
552
707
|
|
|
553
708
|
interface SatelliteBackup {
|
|
554
709
|
threadIds: string[];
|
|
710
|
+
/** Full `threads` row images (SELECT *) captured under the state write lock. */
|
|
711
|
+
threads?: SqlRow[];
|
|
712
|
+
dynamicTools?: SqlRow[];
|
|
713
|
+
spawnEdges?: SqlRow[];
|
|
555
714
|
logs?: { path: string; rows: SqlRow[] };
|
|
556
715
|
memories?: {
|
|
557
716
|
path: string;
|
|
@@ -569,6 +728,11 @@ interface SatelliteBackup {
|
|
|
569
728
|
};
|
|
570
729
|
}
|
|
571
730
|
|
|
731
|
+
type SatelliteBackupRead =
|
|
732
|
+
| { status: "missing" }
|
|
733
|
+
| { status: "ok"; backup: SatelliteBackup }
|
|
734
|
+
| { status: "invalid" };
|
|
735
|
+
|
|
572
736
|
interface ReconcileTestHooks {
|
|
573
737
|
failAfterLogsMutation?: boolean;
|
|
574
738
|
failAfterMemoriesMutation?: boolean;
|
|
@@ -576,11 +740,42 @@ interface ReconcileTestHooks {
|
|
|
576
740
|
failBeforeStateCommit?: boolean;
|
|
577
741
|
failSatelliteRestore?: boolean;
|
|
578
742
|
failSatelliteBackupWrite?: boolean;
|
|
743
|
+
/**
|
|
744
|
+
* Fail a satellite-backup.json *replacement* after the temp is durable but before
|
|
745
|
+
* rename — exercises crash-safety of the post-memories rewrite without truncating
|
|
746
|
+
* the last valid backup.
|
|
747
|
+
*/
|
|
748
|
+
failSatelliteBackupReplace?: boolean;
|
|
579
749
|
/** Runs after satellite deletes are committed, before state thread deletion. */
|
|
580
750
|
afterSatelliteMutations?: () => void;
|
|
581
751
|
}
|
|
582
752
|
|
|
583
753
|
const SATELLITE_BACKUP_FILE = "satellite-backup.json";
|
|
754
|
+
/** Marks an incomplete restore so retries can accept dest files and resume metadata. */
|
|
755
|
+
const RESTORE_PENDING_FILE = "restore-pending.json";
|
|
756
|
+
let _satelliteBackupSeq = 0;
|
|
757
|
+
|
|
758
|
+
type StagedFile = { from: string; to: string; relPath: string };
|
|
759
|
+
|
|
760
|
+
interface RestorePendingSections {
|
|
761
|
+
state: boolean;
|
|
762
|
+
logs: boolean;
|
|
763
|
+
memories: boolean;
|
|
764
|
+
goals: boolean;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
interface RestorePendingState {
|
|
768
|
+
version: 1;
|
|
769
|
+
filesRestored: true;
|
|
770
|
+
/**
|
|
771
|
+
* Planned CODEX_HOME-relative destinations for this restore attempt.
|
|
772
|
+
* Written before moves so a mid-loop failure can still accept placed dests
|
|
773
|
+
* on resume while finishing files that remain staged.
|
|
774
|
+
*/
|
|
775
|
+
acceptedDestRels: string[];
|
|
776
|
+
/** Sections that still need reconciliation on retry. */
|
|
777
|
+
pending: RestorePendingSections;
|
|
778
|
+
}
|
|
584
779
|
|
|
585
780
|
function quoteIdent(name: string): string {
|
|
586
781
|
return `"${name.replaceAll('"', '""')}"`;
|
|
@@ -590,17 +785,134 @@ function selectRows(db: Database, sql: string, params: Array<string | number>):
|
|
|
590
785
|
return db.query<SqlRow, Array<string | number>>(sql).all(...params) as SqlRow[];
|
|
591
786
|
}
|
|
592
787
|
|
|
593
|
-
function
|
|
788
|
+
function tableColumnNames(db: Database, table: string): Set<string> {
|
|
789
|
+
if (!tableExists(db, table)) return new Set();
|
|
790
|
+
const rows = db.query<{ name: string }, []>(
|
|
791
|
+
`PRAGMA table_info("${table.replaceAll('"', '""')}")`,
|
|
792
|
+
).all();
|
|
793
|
+
return new Set(rows.map(r => r.name));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Insert rows with ON CONFLICT DO NOTHING; returns only rows that were newly inserted. */
|
|
797
|
+
function insertRowsConflictIgnore(db: Database, table: string, rows: SqlRow[]): SqlRow[] {
|
|
798
|
+
const inserted: SqlRow[] = [];
|
|
799
|
+
if (rows.length === 0) return inserted;
|
|
800
|
+
const allowed = tableColumnNames(db, table);
|
|
594
801
|
for (const row of rows) {
|
|
595
|
-
const cols = Object.keys(row);
|
|
802
|
+
const cols = Object.keys(row).filter(c => allowed.has(c));
|
|
596
803
|
if (cols.length === 0) continue;
|
|
597
|
-
db.run(
|
|
804
|
+
const result = db.run(
|
|
598
805
|
`INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`,
|
|
599
806
|
cols.map(c => row[c] as string | number | bigint | null | Uint8Array),
|
|
600
807
|
);
|
|
808
|
+
if (result.changes > 0) inserted.push(row);
|
|
809
|
+
}
|
|
810
|
+
return inserted;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Snapshot state-DB dependents that cleanup deletes with the thread rows. */
|
|
814
|
+
function snapshotStateDependents(
|
|
815
|
+
db: Database,
|
|
816
|
+
threadIds: string[],
|
|
817
|
+
): Pick<SatelliteBackup, "threads" | "dynamicTools" | "spawnEdges"> {
|
|
818
|
+
const out: Pick<SatelliteBackup, "threads" | "dynamicTools" | "spawnEdges"> = {};
|
|
819
|
+
if (threadIds.length === 0 || !tableExists(db, "threads")) return out;
|
|
820
|
+
|
|
821
|
+
const threads: SqlRow[] = [];
|
|
822
|
+
for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
|
|
823
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
824
|
+
threads.push(...selectRows(db, `SELECT * FROM threads WHERE id IN (${placeholders})`, chunk));
|
|
825
|
+
}
|
|
826
|
+
out.threads = threads;
|
|
827
|
+
|
|
828
|
+
if (tableExists(db, "thread_dynamic_tools")) {
|
|
829
|
+
const dynamicTools: SqlRow[] = [];
|
|
830
|
+
for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
|
|
831
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
832
|
+
dynamicTools.push(...selectRows(
|
|
833
|
+
db,
|
|
834
|
+
`SELECT * FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`,
|
|
835
|
+
chunk,
|
|
836
|
+
));
|
|
837
|
+
}
|
|
838
|
+
out.dynamicTools = dynamicTools;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (tableExists(db, "thread_spawn_edges")) {
|
|
842
|
+
const spawnEdges: SqlRow[] = [];
|
|
843
|
+
for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK)) {
|
|
844
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
845
|
+
spawnEdges.push(...selectRows(
|
|
846
|
+
db,
|
|
847
|
+
`SELECT * FROM thread_spawn_edges
|
|
848
|
+
WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
|
|
849
|
+
[...chunk, ...chunk],
|
|
850
|
+
));
|
|
851
|
+
}
|
|
852
|
+
out.spawnEdges = spawnEdges;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return out;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Remap serialized absolute DB paths onto the newest DBs under the current Codex home. */
|
|
859
|
+
function remapSatelliteBackupPaths(
|
|
860
|
+
backup: SatelliteBackup,
|
|
861
|
+
paths: RuntimeDbPaths,
|
|
862
|
+
): { ok: true; backup: SatelliteBackup } | { ok: false } {
|
|
863
|
+
const next: SatelliteBackup = {
|
|
864
|
+
threadIds: backup.threadIds,
|
|
865
|
+
...(backup.threads ? { threads: backup.threads } : {}),
|
|
866
|
+
...(backup.dynamicTools ? { dynamicTools: backup.dynamicTools } : {}),
|
|
867
|
+
...(backup.spawnEdges ? { spawnEdges: backup.spawnEdges } : {}),
|
|
868
|
+
};
|
|
869
|
+
if (backup.logs) {
|
|
870
|
+
if (!paths.logs) return { ok: false };
|
|
871
|
+
next.logs = { ...backup.logs, path: paths.logs };
|
|
872
|
+
}
|
|
873
|
+
if (backup.memories) {
|
|
874
|
+
if (!paths.memories) return { ok: false };
|
|
875
|
+
next.memories = { ...backup.memories, path: paths.memories };
|
|
876
|
+
}
|
|
877
|
+
if (backup.goals) {
|
|
878
|
+
if (!paths.goals) return { ok: false };
|
|
879
|
+
next.goals = { ...backup.goals, path: paths.goals };
|
|
880
|
+
}
|
|
881
|
+
return { ok: true, backup: next };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Same-volume move that never replaces an existing destination.
|
|
886
|
+
*
|
|
887
|
+
* `existsSync` + `renameSync` is TOCTOU: a live file created between the check
|
|
888
|
+
* and rename can be overwritten (Windows rename replaces files). Hard-link then
|
|
889
|
+
* unlink fails with EEXIST if `to` appears, which is what trash → archived_sessions
|
|
890
|
+
* restore needs. Callers under the same `CODEX_HOME` volume should not hit EXDEV.
|
|
891
|
+
*/
|
|
892
|
+
function renameNoReplace(from: string, to: string): void {
|
|
893
|
+
try {
|
|
894
|
+
linkSync(from, to);
|
|
895
|
+
} catch (error) {
|
|
896
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
897
|
+
// Hard links unavailable (rare FS) — refuse rather than clobber via rename.
|
|
898
|
+
if (code === "EXDEV" || code === "EPERM" || code === "ENOTSUP" || code === "EINVAL") {
|
|
899
|
+
throw Object.assign(new Error("rename_no_replace_unsupported"), { code, cause: error });
|
|
900
|
+
}
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
unlinkSync(from);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
// Roll back the hard link so we do not leave the file at both paths.
|
|
907
|
+
try { unlinkSync(to); } catch { /* best-effort */ }
|
|
908
|
+
throw error;
|
|
601
909
|
}
|
|
602
910
|
}
|
|
603
911
|
|
|
912
|
+
function isExistError(error: unknown): boolean {
|
|
913
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
|
|
914
|
+
}
|
|
915
|
+
|
|
604
916
|
function updateRowFromSnapshot(
|
|
605
917
|
db: Database,
|
|
606
918
|
table: string,
|
|
@@ -667,8 +979,66 @@ function restoreConsolidateGlobalJob(
|
|
|
667
979
|
}
|
|
668
980
|
}
|
|
669
981
|
|
|
670
|
-
|
|
671
|
-
|
|
982
|
+
/**
|
|
983
|
+
* Best-effort directory fsync so a preceding rename is durable on crash.
|
|
984
|
+
* Unsupported on some Windows setups — never treat failure as fatal.
|
|
985
|
+
*/
|
|
986
|
+
function fsyncDirectoryBestEffort(dirPath: string): void {
|
|
987
|
+
let fd: number | undefined;
|
|
988
|
+
try {
|
|
989
|
+
fd = openSync(dirPath, "r");
|
|
990
|
+
fsyncSync(fd);
|
|
991
|
+
} catch {
|
|
992
|
+
/* best-effort */
|
|
993
|
+
} finally {
|
|
994
|
+
if (fd !== undefined) {
|
|
995
|
+
try { closeSync(fd); } catch { /* */ }
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Atomically replace satellite-backup.json: private temp in the stage, full write + fsync,
|
|
1002
|
+
* rename (with Windows sharing-violation retries), then best-effort directory fsync.
|
|
1003
|
+
* An interrupted update never truncates the last valid backup that was written before a
|
|
1004
|
+
* satellite DB commit.
|
|
1005
|
+
*/
|
|
1006
|
+
function writeSatelliteBackup(
|
|
1007
|
+
stageDir: string,
|
|
1008
|
+
backup: SatelliteBackup,
|
|
1009
|
+
options?: { failWrite?: boolean; failReplaceBeforeRename?: boolean },
|
|
1010
|
+
): void {
|
|
1011
|
+
if (options?.failWrite) throw new Error("test_fail_satellite_backup_write");
|
|
1012
|
+
const dest = join(stageDir, SATELLITE_BACKUP_FILE);
|
|
1013
|
+
const replacing = existsSync(dest);
|
|
1014
|
+
const tmp = join(stageDir, `${SATELLITE_BACKUP_FILE}.${process.pid}.${++_satelliteBackupSeq}.tmp`);
|
|
1015
|
+
const payload = Buffer.from(JSON.stringify(backup), "utf8");
|
|
1016
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
1017
|
+
try {
|
|
1018
|
+
let offset = 0;
|
|
1019
|
+
while (offset < payload.length) {
|
|
1020
|
+
offset += writeSync(fd, payload, offset, payload.length - offset, null);
|
|
1021
|
+
}
|
|
1022
|
+
fsyncSync(fd);
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
try { closeSync(fd); } catch { /* */ }
|
|
1025
|
+
try { unlinkSync(tmp); } catch { /* */ }
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
closeSync(fd);
|
|
1029
|
+
chmodPrivatePath(tmp, 0o600);
|
|
1030
|
+
if (options?.failReplaceBeforeRename && replacing) {
|
|
1031
|
+
try { unlinkSync(tmp); } catch { /* */ }
|
|
1032
|
+
throw new Error("test_fail_satellite_backup_replace");
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
renameAtomicFile(tmp, dest);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
try { unlinkSync(tmp); } catch { /* */ }
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
chmodPrivatePath(dest, 0o600);
|
|
1041
|
+
fsyncDirectoryBestEffort(stageDir);
|
|
672
1042
|
}
|
|
673
1043
|
|
|
674
1044
|
function clearSatelliteBackup(stageDir: string): void {
|
|
@@ -690,6 +1060,7 @@ interface SatelliteWriteLocks {
|
|
|
690
1060
|
function beginSatelliteWriteLocks(
|
|
691
1061
|
paths: RuntimeDbPaths,
|
|
692
1062
|
busyTimeoutMs: number,
|
|
1063
|
+
only?: Partial<Record<"logs" | "memories" | "goals", boolean>>,
|
|
693
1064
|
): SatelliteWriteLocks {
|
|
694
1065
|
const locks: SatelliteWriteLocks = {};
|
|
695
1066
|
const order: Array<{ key: "logs" | "memories" | "goals"; path: string | null }> = [
|
|
@@ -699,6 +1070,7 @@ function beginSatelliteWriteLocks(
|
|
|
699
1070
|
];
|
|
700
1071
|
try {
|
|
701
1072
|
for (const { key, path } of order) {
|
|
1073
|
+
if (only && !only[key]) continue;
|
|
702
1074
|
if (!path || !existsSync(path)) continue;
|
|
703
1075
|
const db = openDbWritable(path, busyTimeoutMs);
|
|
704
1076
|
try {
|
|
@@ -933,11 +1305,19 @@ function deleteAndCommitSatellites(
|
|
|
933
1305
|
if (locks.memories && backup.memories) {
|
|
934
1306
|
deleteMemoriesInTx(locks.memories.db, backup.memories);
|
|
935
1307
|
if (backup.memories.consolidateTouched) {
|
|
1308
|
+
// Capture under the write lock, but persist only after COMMIT+close.
|
|
1309
|
+
// Holding BEGIN IMMEDIATE across a durable backup rewrite lets Windows CI
|
|
1310
|
+
// disk/AV latency stall the lock long enough for concurrent reopen hooks
|
|
1311
|
+
// (and bun's default 5s test timeout) to hang — see PR #558 windows-latest.
|
|
936
1312
|
backup.memories.consolidatePostImage = readConsolidateGlobalJob(locks.memories.db);
|
|
937
|
-
writeSatelliteBackup(stageDir, backup);
|
|
938
1313
|
}
|
|
939
1314
|
commitSatelliteLock(locks.memories);
|
|
940
1315
|
locks.memories = undefined;
|
|
1316
|
+
if (backup.memories.consolidateTouched) {
|
|
1317
|
+
writeSatelliteBackup(stageDir, backup, {
|
|
1318
|
+
failReplaceBeforeRename: hooks?.failSatelliteBackupReplace,
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
941
1321
|
if (hooks?.failAfterMemoriesMutation) throw new Error("test_fail_after_memories");
|
|
942
1322
|
}
|
|
943
1323
|
if (locks.goals && backup.goals) {
|
|
@@ -1103,11 +1483,14 @@ function reconcileDeletedThreads(
|
|
|
1103
1483
|
satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs);
|
|
1104
1484
|
try {
|
|
1105
1485
|
backup = snapshotSatelliteBackupInLocks(satelliteLocks, threadIds);
|
|
1486
|
+
const stateDeps = snapshotStateDependents(stateDb, threadIds);
|
|
1487
|
+
backup.threads = stateDeps.threads;
|
|
1488
|
+
backup.dynamicTools = stateDeps.dynamicTools;
|
|
1489
|
+
backup.spawnEdges = stateDeps.spawnEdges;
|
|
1106
1490
|
try {
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
}
|
|
1110
|
-
writeSatelliteBackup(stageDir, backup);
|
|
1491
|
+
writeSatelliteBackup(stageDir, backup, {
|
|
1492
|
+
failWrite: hooks?.failSatelliteBackupWrite,
|
|
1493
|
+
});
|
|
1111
1494
|
} catch {
|
|
1112
1495
|
rollbackAllSatelliteLocks(satelliteLocks);
|
|
1113
1496
|
stateDb.exec("ROLLBACK");
|
|
@@ -1134,7 +1517,7 @@ function reconcileDeletedThreads(
|
|
|
1134
1517
|
deleteThreadsAndDependents(stateDb, threadIds);
|
|
1135
1518
|
if (hooks?.failBeforeStateCommit) throw new Error("test_fail_before_state_commit");
|
|
1136
1519
|
stateDb.exec("COMMIT");
|
|
1137
|
-
|
|
1520
|
+
// Keep satellite-backup.json for quarantine restore; permanent purge removes the stage.
|
|
1138
1521
|
return { ok: true, threads };
|
|
1139
1522
|
} catch (error) {
|
|
1140
1523
|
if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
|
|
@@ -1148,8 +1531,6 @@ function reconcileDeletedThreads(
|
|
|
1148
1531
|
}
|
|
1149
1532
|
}
|
|
1150
1533
|
|
|
1151
|
-
type StagedFile = { from: string; to: string; relPath: string };
|
|
1152
|
-
|
|
1153
1534
|
function absFromRel(codexHome: string, relPath: string): string {
|
|
1154
1535
|
if (relPath.includes("..") || isAbsolute(relPath) || /^[A-Za-z]:[\\/]/.test(relPath)) {
|
|
1155
1536
|
throw new Error("invalid_rel_path");
|
|
@@ -1271,6 +1652,11 @@ export interface ExecuteCleanupOptions {
|
|
|
1271
1652
|
mode: CleanupMode;
|
|
1272
1653
|
/** Required digest from preview; rejects when the candidate set drifted. */
|
|
1273
1654
|
digest: string;
|
|
1655
|
+
/**
|
|
1656
|
+
* Optional exact candidate set (logical relPaths). When set, selection bypasses
|
|
1657
|
+
* percent rounding and the digest must match `computeExactPreviewDigest`.
|
|
1658
|
+
*/
|
|
1659
|
+
candidateRelPaths?: string[];
|
|
1274
1660
|
codexHome?: string;
|
|
1275
1661
|
/** Test-only: shrink busy_timeout so lock tests fail fast. */
|
|
1276
1662
|
busyTimeoutMs?: number;
|
|
@@ -1287,6 +1673,7 @@ export interface ExecuteCleanupOptions {
|
|
|
1287
1673
|
failBeforeStateCommit?: boolean;
|
|
1288
1674
|
failSatelliteRestore?: boolean;
|
|
1289
1675
|
failSatelliteBackupWrite?: boolean;
|
|
1676
|
+
failSatelliteBackupReplace?: boolean;
|
|
1290
1677
|
afterSatelliteMutations?: () => void;
|
|
1291
1678
|
};
|
|
1292
1679
|
}
|
|
@@ -1316,6 +1703,7 @@ export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | u
|
|
|
1316
1703
|
if (typeof o.failBeforeStateCommit === "boolean") out.failBeforeStateCommit = o.failBeforeStateCommit;
|
|
1317
1704
|
if (typeof o.failSatelliteRestore === "boolean") out.failSatelliteRestore = o.failSatelliteRestore;
|
|
1318
1705
|
if (typeof o.failSatelliteBackupWrite === "boolean") out.failSatelliteBackupWrite = o.failSatelliteBackupWrite;
|
|
1706
|
+
if (typeof o.failSatelliteBackupReplace === "boolean") out.failSatelliteBackupReplace = o.failSatelliteBackupReplace;
|
|
1319
1707
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
1320
1708
|
}
|
|
1321
1709
|
|
|
@@ -1358,10 +1746,38 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
|
|
|
1358
1746
|
return fail(mode, percent, "invalid_digest");
|
|
1359
1747
|
}
|
|
1360
1748
|
|
|
1361
|
-
|
|
1749
|
+
let preview: CleanupPreview;
|
|
1750
|
+
let unfilteredSelected: ArchivedCandidate[];
|
|
1751
|
+
if (options.candidateRelPaths !== undefined) {
|
|
1752
|
+
const selected = resolveExactArchivedCandidates(options.candidateRelPaths, codexHome);
|
|
1753
|
+
if (selected === null) {
|
|
1754
|
+
return fail(mode, percent, "stale_preview");
|
|
1755
|
+
}
|
|
1756
|
+
unfilteredSelected = selected;
|
|
1757
|
+
preview = previewExactArchivedCleanup(selected, codexHome);
|
|
1758
|
+
} else {
|
|
1759
|
+
const all = listArchivedCandidates(codexHome);
|
|
1760
|
+
unfilteredSelected = selectOldestPercent(all, percent);
|
|
1761
|
+
preview = previewArchivedCleanup(percent, codexHome);
|
|
1762
|
+
}
|
|
1362
1763
|
if (preview.digest.toLowerCase() !== options.digest.toLowerCase()) {
|
|
1764
|
+
const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
|
|
1765
|
+
const blocked = unfilteredSelected.filter(c => candidateOverlapsPendingRestore(c, pendingDestRels));
|
|
1766
|
+
const unfilteredDigest = options.candidateRelPaths !== undefined
|
|
1767
|
+
? computeExactPreviewDigest(unfilteredSelected)
|
|
1768
|
+
: computePreviewDigest(unfilteredSelected, percent);
|
|
1769
|
+
if (
|
|
1770
|
+
unfilteredDigest.toLowerCase() === options.digest.toLowerCase()
|
|
1771
|
+
&& blocked.length > 0
|
|
1772
|
+
) {
|
|
1773
|
+
return fail(mode, percent, "restore_pending_overlap");
|
|
1774
|
+
}
|
|
1363
1775
|
return fail(mode, percent, "stale_preview");
|
|
1364
1776
|
}
|
|
1777
|
+
const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
|
|
1778
|
+
if (preview.candidates.some(c => candidateOverlapsPendingRestore(c, pendingDestRels))) {
|
|
1779
|
+
return fail(mode, percent, "restore_pending_overlap");
|
|
1780
|
+
}
|
|
1365
1781
|
|
|
1366
1782
|
if (preview.candidates.length === 0) {
|
|
1367
1783
|
return {
|
|
@@ -1505,9 +1921,12 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
|
|
|
1505
1921
|
digest: preview.digest,
|
|
1506
1922
|
purgeIncomplete: true,
|
|
1507
1923
|
purgedRelPaths: purge.purged.map(item => item.relPath),
|
|
1508
|
-
entries: manifestEntries
|
|
1509
|
-
|
|
1510
|
-
|
|
1924
|
+
entries: manifestEntries
|
|
1925
|
+
.map(entry => ({
|
|
1926
|
+
...entry,
|
|
1927
|
+
physicalRelPaths: entry.physicalRelPaths.filter(rel => survivingRelPaths.has(rel)),
|
|
1928
|
+
}))
|
|
1929
|
+
.filter(entry => entry.physicalRelPaths.length > 0),
|
|
1511
1930
|
}, null, 2),
|
|
1512
1931
|
);
|
|
1513
1932
|
} catch { /* best-effort: the pre-commit manifest is still on disk */ }
|
|
@@ -1536,3 +1955,1060 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
|
|
|
1536
1955
|
removedPaths,
|
|
1537
1956
|
};
|
|
1538
1957
|
}
|
|
1958
|
+
|
|
1959
|
+
// ---------------------------------------------------------------------------
|
|
1960
|
+
// Phase 2.1 — quarantine list + restore
|
|
1961
|
+
// ---------------------------------------------------------------------------
|
|
1962
|
+
|
|
1963
|
+
export type RestoreErrorCode =
|
|
1964
|
+
| "invalid_trash"
|
|
1965
|
+
| "missing_trash"
|
|
1966
|
+
| "codex_busy"
|
|
1967
|
+
| "storage_mutation_busy"
|
|
1968
|
+
| "fs_failed"
|
|
1969
|
+
| "db_reconcile_failed"
|
|
1970
|
+
| "dest_exists"
|
|
1971
|
+
| "restore_failed"
|
|
1972
|
+
| "restore_worker_timeout"
|
|
1973
|
+
| "restore_worker_aborted"
|
|
1974
|
+
| "restore_worker_failed";
|
|
1975
|
+
|
|
1976
|
+
export interface TrashEntrySummary {
|
|
1977
|
+
/** CODEX_HOME-relative path, e.g. `.trash/1700000000000`. */
|
|
1978
|
+
id: string;
|
|
1979
|
+
/** Epoch directory name (may include collision suffix, e.g. `1700-1`). */
|
|
1980
|
+
epoch: string;
|
|
1981
|
+
fileCount: number;
|
|
1982
|
+
bytes: number;
|
|
1983
|
+
quarantinedAt?: number;
|
|
1984
|
+
mode?: CleanupMode;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
export interface RestoreResult {
|
|
1988
|
+
ok: boolean;
|
|
1989
|
+
trashDir?: string;
|
|
1990
|
+
count: number;
|
|
1991
|
+
bytes: number;
|
|
1992
|
+
restoredPaths: string[];
|
|
1993
|
+
error?: RestoreErrorCode;
|
|
1994
|
+
/** Optional operator-facing detail when the error code alone is insufficient. */
|
|
1995
|
+
message?: string;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
interface TrashManifest {
|
|
1999
|
+
quarantinedAt?: number;
|
|
2000
|
+
mode?: CleanupMode;
|
|
2001
|
+
entries?: CleanupManifestEntry[];
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/** Epoch dir names: digits, optionally `-N` from createExclusiveStageDir collision. */
|
|
2005
|
+
const TRASH_EPOCH_DIR = /^(\d+)(-\d+)?$/;
|
|
2006
|
+
|
|
2007
|
+
/**
|
|
2008
|
+
* Parse a trash `manifest.json` atomically.
|
|
2009
|
+
*
|
|
2010
|
+
* Any missing `entries` array, or any malformed entry / `physicalRelPaths` value /
|
|
2011
|
+
* required field, rejects the **entire** manifest (returns null). Individual bad
|
|
2012
|
+
* entries are never filtered out so a partial parse cannot silently drop evidence.
|
|
2013
|
+
*/
|
|
2014
|
+
function parseTrashManifest(raw: string): TrashManifest | null {
|
|
2015
|
+
try {
|
|
2016
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
2017
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
2018
|
+
const o = parsed as Record<string, unknown>;
|
|
2019
|
+
if (!Array.isArray(o.entries)) return null;
|
|
2020
|
+
|
|
2021
|
+
const entries: CleanupManifestEntry[] = [];
|
|
2022
|
+
for (const e of o.entries) {
|
|
2023
|
+
if (!e || typeof e !== "object" || Array.isArray(e)) return null;
|
|
2024
|
+
const entry = e as Record<string, unknown>;
|
|
2025
|
+
if (typeof entry.relPath !== "string" || entry.relPath.length === 0) return null;
|
|
2026
|
+
if (typeof entry.bytes !== "number" || !Number.isFinite(entry.bytes)) return null;
|
|
2027
|
+
if (typeof entry.mtimeMs !== "number" || !Number.isFinite(entry.mtimeMs)) return null;
|
|
2028
|
+
if (!Array.isArray(entry.physicalRelPaths) || entry.physicalRelPaths.length === 0) return null;
|
|
2029
|
+
const physical: string[] = [];
|
|
2030
|
+
for (const p of entry.physicalRelPaths) {
|
|
2031
|
+
// Do not strip bad elements — one malformed path invalidates the whole manifest.
|
|
2032
|
+
if (typeof p !== "string" || p.length === 0) return null;
|
|
2033
|
+
physical.push(p);
|
|
2034
|
+
}
|
|
2035
|
+
if ("threadId" in entry && typeof entry.threadId !== "string") return null;
|
|
2036
|
+
if ("rolloutPath" in entry && typeof entry.rolloutPath !== "string") return null;
|
|
2037
|
+
if (
|
|
2038
|
+
"archived" in entry
|
|
2039
|
+
&& entry.archived !== null
|
|
2040
|
+
&& typeof entry.archived !== "number"
|
|
2041
|
+
) {
|
|
2042
|
+
return null;
|
|
2043
|
+
}
|
|
2044
|
+
entries.push({
|
|
2045
|
+
relPath: entry.relPath,
|
|
2046
|
+
bytes: entry.bytes,
|
|
2047
|
+
mtimeMs: entry.mtimeMs,
|
|
2048
|
+
physicalRelPaths: physical,
|
|
2049
|
+
...(typeof entry.threadId === "string" ? { threadId: entry.threadId } : {}),
|
|
2050
|
+
...(typeof entry.rolloutPath === "string" ? { rolloutPath: entry.rolloutPath } : {}),
|
|
2051
|
+
...(entry.archived === null || typeof entry.archived === "number"
|
|
2052
|
+
? { archived: entry.archived as number | null }
|
|
2053
|
+
: {}),
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
const out: TrashManifest = { entries };
|
|
2058
|
+
if (typeof o.quarantinedAt === "number" && Number.isFinite(o.quarantinedAt)) {
|
|
2059
|
+
out.quarantinedAt = o.quarantinedAt;
|
|
2060
|
+
}
|
|
2061
|
+
if (o.mode === "quarantine" || o.mode === "permanent") out.mode = o.mode;
|
|
2062
|
+
return out;
|
|
2063
|
+
} catch {
|
|
2064
|
+
return null;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Validate a trash entry id as a single `.trash/<epoch>` segment under CODEX_HOME.
|
|
2070
|
+
* Returns the absolute stage directory, or null when the id is unsafe / missing.
|
|
2071
|
+
*/
|
|
2072
|
+
export function resolveTrashStageDir(
|
|
2073
|
+
trashId: string,
|
|
2074
|
+
codexHome: string,
|
|
2075
|
+
): { ok: true; stageDir: string; id: string } | { ok: false; error: RestoreErrorCode } {
|
|
2076
|
+
const normalized = toForwardSlash(trashId.trim()).replace(/\/+$/, "");
|
|
2077
|
+
if (!normalized.startsWith(`${TRASH_DIR}/`)) return { ok: false, error: "invalid_trash" };
|
|
2078
|
+
const rest = normalized.slice(TRASH_DIR.length + 1);
|
|
2079
|
+
if (!rest || rest.includes("/") || rest.includes("\\") || rest.includes("..")) {
|
|
2080
|
+
return { ok: false, error: "invalid_trash" };
|
|
2081
|
+
}
|
|
2082
|
+
if (!TRASH_EPOCH_DIR.test(rest)) return { ok: false, error: "invalid_trash" };
|
|
2083
|
+
let stageDir: string;
|
|
2084
|
+
try {
|
|
2085
|
+
stageDir = absFromRel(codexHome, `${TRASH_DIR}/${rest}`);
|
|
2086
|
+
} catch {
|
|
2087
|
+
return { ok: false, error: "invalid_trash" };
|
|
2088
|
+
}
|
|
2089
|
+
if (!existsSync(stageDir)) return { ok: false, error: "missing_trash" };
|
|
2090
|
+
try {
|
|
2091
|
+
if (!statSync(stageDir).isDirectory()) return { ok: false, error: "invalid_trash" };
|
|
2092
|
+
} catch {
|
|
2093
|
+
return { ok: false, error: "missing_trash" };
|
|
2094
|
+
}
|
|
2095
|
+
return { ok: true, stageDir, id: `${TRASH_DIR}/${rest}` };
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function sumTrashEntryBytes(stageDir: string, manifest: TrashManifest | null): {
|
|
2099
|
+
fileCount: number;
|
|
2100
|
+
bytes: number;
|
|
2101
|
+
} {
|
|
2102
|
+
let fileCount = 0;
|
|
2103
|
+
let bytes = 0;
|
|
2104
|
+
let names: string[] = [];
|
|
2105
|
+
try {
|
|
2106
|
+
names = readdirSync(stageDir);
|
|
2107
|
+
} catch {
|
|
2108
|
+
return { fileCount: 0, bytes: 0 };
|
|
2109
|
+
}
|
|
2110
|
+
for (const name of names) {
|
|
2111
|
+
if (
|
|
2112
|
+
name === "manifest.json"
|
|
2113
|
+
|| name === SATELLITE_BACKUP_FILE
|
|
2114
|
+
|| name === RESTORE_PENDING_FILE
|
|
2115
|
+
) {
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
if (!isRolloutFileName(name)) continue;
|
|
2119
|
+
try {
|
|
2120
|
+
const st = statSync(join(stageDir, name));
|
|
2121
|
+
if (!st.isFile()) continue;
|
|
2122
|
+
fileCount += 1;
|
|
2123
|
+
bytes += st.size;
|
|
2124
|
+
} catch { /* */ }
|
|
2125
|
+
}
|
|
2126
|
+
// Prefer live FS counts; fall back to manifest totals when the stage is empty of rollouts.
|
|
2127
|
+
if (fileCount === 0 && manifest?.entries?.length) {
|
|
2128
|
+
fileCount = manifest.entries.reduce((n, e) => n + Math.max(1, e.physicalRelPaths.length), 0);
|
|
2129
|
+
bytes = manifest.entries.reduce((n, e) => n + (e.bytes || 0), 0);
|
|
2130
|
+
}
|
|
2131
|
+
return { fileCount, bytes };
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/** List quarantine entries under `CODEX_HOME/.trash/` (relative ids only). */
|
|
2135
|
+
export function listTrashEntries(
|
|
2136
|
+
codexHome: string = resolveCodexHomeDir(),
|
|
2137
|
+
): TrashEntrySummary[] {
|
|
2138
|
+
const trashRoot = join(codexHome, TRASH_DIR);
|
|
2139
|
+
let names: string[] = [];
|
|
2140
|
+
try {
|
|
2141
|
+
names = readdirSync(trashRoot);
|
|
2142
|
+
} catch {
|
|
2143
|
+
return [];
|
|
2144
|
+
}
|
|
2145
|
+
const out: TrashEntrySummary[] = [];
|
|
2146
|
+
for (const name of names) {
|
|
2147
|
+
if (!TRASH_EPOCH_DIR.test(name)) continue;
|
|
2148
|
+
const stageDir = join(trashRoot, name);
|
|
2149
|
+
try {
|
|
2150
|
+
if (!statSync(stageDir).isDirectory()) continue;
|
|
2151
|
+
} catch {
|
|
2152
|
+
continue;
|
|
2153
|
+
}
|
|
2154
|
+
let manifest: TrashManifest | null = null;
|
|
2155
|
+
try {
|
|
2156
|
+
manifest = parseTrashManifest(readFileSync(join(stageDir, "manifest.json"), "utf8"));
|
|
2157
|
+
} catch {
|
|
2158
|
+
manifest = null;
|
|
2159
|
+
}
|
|
2160
|
+
const { fileCount, bytes } = sumTrashEntryBytes(stageDir, manifest);
|
|
2161
|
+
// Skip empty collision placeholders left behind without a manifest or rollouts.
|
|
2162
|
+
if (fileCount === 0 && !manifest?.entries?.length) {
|
|
2163
|
+
try {
|
|
2164
|
+
if (!existsSync(join(stageDir, "manifest.json"))) continue;
|
|
2165
|
+
} catch {
|
|
2166
|
+
continue;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
out.push({
|
|
2170
|
+
id: `${TRASH_DIR}/${name}`,
|
|
2171
|
+
epoch: name,
|
|
2172
|
+
fileCount,
|
|
2173
|
+
bytes,
|
|
2174
|
+
...(manifest?.quarantinedAt !== undefined ? { quarantinedAt: manifest.quarantinedAt } : {}),
|
|
2175
|
+
...(manifest?.mode ? { mode: manifest.mode } : {}),
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
out.sort((a, b) => {
|
|
2179
|
+
const aq = a.quarantinedAt ?? (Number(a.epoch.split("-")[0]) || 0);
|
|
2180
|
+
const bq = b.quarantinedAt ?? (Number(b.epoch.split("-")[0]) || 0);
|
|
2181
|
+
return bq - aq || b.epoch.localeCompare(a.epoch);
|
|
2182
|
+
});
|
|
2183
|
+
return out;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function readSatelliteBackupFile(stageDir: string): SatelliteBackupRead {
|
|
2187
|
+
const path = join(stageDir, SATELLITE_BACKUP_FILE);
|
|
2188
|
+
if (!existsSync(path)) return { status: "missing" };
|
|
2189
|
+
try {
|
|
2190
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
2191
|
+
if (!raw || typeof raw !== "object") return { status: "invalid" };
|
|
2192
|
+
const o = raw as SatelliteBackup;
|
|
2193
|
+
if (!Array.isArray(o.threadIds)) return { status: "invalid" };
|
|
2194
|
+
return { status: "ok", backup: o };
|
|
2195
|
+
} catch {
|
|
2196
|
+
// File exists but is truncated / malformed — distinct from a missing backup.
|
|
2197
|
+
return { status: "invalid" };
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
function isSqlRowArray(value: unknown): value is SqlRow[] {
|
|
2202
|
+
return Array.isArray(value) && value.every(row => row && typeof row === "object" && !Array.isArray(row));
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
/** True when a snapshotted thread row covers every NOT NULL column on the live schema. */
|
|
2206
|
+
function threadSnapshotCoversRequiredColumns(row: SqlRow, requiredCols: string[]): boolean {
|
|
2207
|
+
for (const col of requiredCols) {
|
|
2208
|
+
if (!(col in row) || row[col] === undefined) return false;
|
|
2209
|
+
}
|
|
2210
|
+
return true;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function requiredThreadColumnNames(db: Database): string[] {
|
|
2214
|
+
if (!tableExists(db, "threads")) return [];
|
|
2215
|
+
const rows = db.query<{ name: string; notnull: number }, []>(
|
|
2216
|
+
`PRAGMA table_info("threads")`,
|
|
2217
|
+
).all();
|
|
2218
|
+
return rows.filter(r => r.notnull === 1).map(r => r.name);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* Build a production-shaped thread row for schemas that predate full satellite snapshots.
|
|
2223
|
+
* Prefer `readThreadFieldsFromRollout` (canonical history/session_meta path); fall back to
|
|
2224
|
+
* the sparse manifest fields only when the live schema does not require model/source/message.
|
|
2225
|
+
*/
|
|
2226
|
+
function reconstructThreadRowFromRollout(
|
|
2227
|
+
entry: CleanupManifestEntry,
|
|
2228
|
+
rolloutAbsPath: string,
|
|
2229
|
+
allowedCols: Set<string>,
|
|
2230
|
+
requiredCols: string[],
|
|
2231
|
+
): SqlRow | null {
|
|
2232
|
+
if (typeof entry.threadId !== "string" || typeof entry.rolloutPath !== "string") return null;
|
|
2233
|
+
|
|
2234
|
+
const fields = readThreadFieldsFromRollout(rolloutAbsPath);
|
|
2235
|
+
const row: SqlRow = {
|
|
2236
|
+
id: entry.threadId,
|
|
2237
|
+
rollout_path: entry.rolloutPath,
|
|
2238
|
+
};
|
|
2239
|
+
|
|
2240
|
+
if (fields) {
|
|
2241
|
+
// Prefer manifest thread id (binding) but keep rollout-derived listing fields.
|
|
2242
|
+
if (allowedCols.has("model_provider")) row.model_provider = fields.modelProvider;
|
|
2243
|
+
if (allowedCols.has("source")) row.source = fields.source;
|
|
2244
|
+
if (allowedCols.has("first_user_message")) row.first_user_message = fields.firstUserMessage;
|
|
2245
|
+
if (allowedCols.has("has_user_event")) row.has_user_event = fields.hasUserEvent;
|
|
2246
|
+
if (allowedCols.has("cwd") && fields.cwd !== undefined) row.cwd = fields.cwd;
|
|
2247
|
+
if (allowedCols.has("history_mode") && fields.historyMode !== undefined) {
|
|
2248
|
+
row.history_mode = fields.historyMode;
|
|
2249
|
+
}
|
|
2250
|
+
if (allowedCols.has("cli_version") && fields.cliVersion !== undefined) {
|
|
2251
|
+
row.cli_version = fields.cliVersion;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
if (allowedCols.has("archived")) {
|
|
2256
|
+
row.archived = entry.archived ?? 1;
|
|
2257
|
+
}
|
|
2258
|
+
if (allowedCols.has("archived_at")) {
|
|
2259
|
+
row.archived_at = null;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// Fill remaining NOT NULL columns with safe empties when the rollout lacked them
|
|
2263
|
+
// (e.g. fixture rollouts without a user turn still need first_user_message = '').
|
|
2264
|
+
for (const col of requiredCols) {
|
|
2265
|
+
if (row[col] !== undefined) continue;
|
|
2266
|
+
if (col === "id" || col === "rollout_path") continue;
|
|
2267
|
+
if (col === "model_provider") row[col] = "openai";
|
|
2268
|
+
else if (col === "source") row[col] = "cli";
|
|
2269
|
+
else if (col === "first_user_message") row[col] = "";
|
|
2270
|
+
else if (col === "has_user_event") row[col] = 0;
|
|
2271
|
+
else if (col === "archived") row[col] = entry.archived ?? 1;
|
|
2272
|
+
else return null; // unknown required column we cannot invent
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// If the schema requires listing fields, refuse when the rollout was unreadable.
|
|
2276
|
+
const needsSessionMeta = requiredCols.some(
|
|
2277
|
+
c => c === "model_provider" || c === "source" || c === "first_user_message",
|
|
2278
|
+
);
|
|
2279
|
+
if (needsSessionMeta && !fields) return null;
|
|
2280
|
+
|
|
2281
|
+
return row;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
type RestorePendingRead =
|
|
2285
|
+
| { status: "missing" }
|
|
2286
|
+
| { status: "valid"; state: RestorePendingState }
|
|
2287
|
+
| { status: "invalid" };
|
|
2288
|
+
|
|
2289
|
+
let _restorePendingSeq = 0;
|
|
2290
|
+
|
|
2291
|
+
function parseRestorePendingState(raw: unknown): RestorePendingState | null {
|
|
2292
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
2293
|
+
const o = raw as Record<string, unknown>;
|
|
2294
|
+
if (o.version !== 1 || o.filesRestored !== true) return null;
|
|
2295
|
+
if (!Array.isArray(o.acceptedDestRels)) return null;
|
|
2296
|
+
const acceptedDestRels = o.acceptedDestRels.filter((r): r is string => typeof r === "string");
|
|
2297
|
+
if (acceptedDestRels.length !== o.acceptedDestRels.length) return null;
|
|
2298
|
+
const pendingRaw = o.pending;
|
|
2299
|
+
if (!pendingRaw || typeof pendingRaw !== "object" || Array.isArray(pendingRaw)) return null;
|
|
2300
|
+
const p = pendingRaw as Record<string, unknown>;
|
|
2301
|
+
if (
|
|
2302
|
+
typeof p.state !== "boolean"
|
|
2303
|
+
|| typeof p.logs !== "boolean"
|
|
2304
|
+
|| typeof p.memories !== "boolean"
|
|
2305
|
+
|| typeof p.goals !== "boolean"
|
|
2306
|
+
) {
|
|
2307
|
+
return null;
|
|
2308
|
+
}
|
|
2309
|
+
return {
|
|
2310
|
+
version: 1,
|
|
2311
|
+
filesRestored: true,
|
|
2312
|
+
acceptedDestRels,
|
|
2313
|
+
pending: {
|
|
2314
|
+
state: p.state,
|
|
2315
|
+
logs: p.logs,
|
|
2316
|
+
memories: p.memories,
|
|
2317
|
+
goals: p.goals,
|
|
2318
|
+
},
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/**
|
|
2323
|
+
* Distinguish a missing marker from a present-but-malformed one. An invalid marker
|
|
2324
|
+
* must never be treated as a fresh restore (that would ignore already-moved files).
|
|
2325
|
+
*/
|
|
2326
|
+
function readRestorePending(stageDir: string): RestorePendingRead {
|
|
2327
|
+
const path = join(stageDir, RESTORE_PENDING_FILE);
|
|
2328
|
+
if (!existsSync(path)) return { status: "missing" };
|
|
2329
|
+
try {
|
|
2330
|
+
const state = parseRestorePendingState(JSON.parse(readFileSync(path, "utf8")) as unknown);
|
|
2331
|
+
if (!state) return { status: "invalid" };
|
|
2332
|
+
return { status: "valid", state };
|
|
2333
|
+
} catch {
|
|
2334
|
+
return { status: "invalid" };
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* Atomically replace restore-pending.json: private temp in the stage, fsync, then rename.
|
|
2340
|
+
* An interrupted update leaves the previous valid marker intact.
|
|
2341
|
+
*/
|
|
2342
|
+
function writeRestorePending(
|
|
2343
|
+
stageDir: string,
|
|
2344
|
+
state: RestorePendingState,
|
|
2345
|
+
options?: { failBeforeRename?: boolean; failWrite?: boolean },
|
|
2346
|
+
): void {
|
|
2347
|
+
if (options?.failWrite) throw new Error("test_fail_pending_write");
|
|
2348
|
+
const dest = join(stageDir, RESTORE_PENDING_FILE);
|
|
2349
|
+
const tmp = join(stageDir, `${RESTORE_PENDING_FILE}.${process.pid}.${++_restorePendingSeq}.tmp`);
|
|
2350
|
+
const payload = JSON.stringify(state);
|
|
2351
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
2352
|
+
try {
|
|
2353
|
+
writeSync(fd, payload, null, "utf8");
|
|
2354
|
+
fsyncSync(fd);
|
|
2355
|
+
} catch (error) {
|
|
2356
|
+
try { closeSync(fd); } catch { /* */ }
|
|
2357
|
+
try { unlinkSync(tmp); } catch { /* */ }
|
|
2358
|
+
throw error;
|
|
2359
|
+
}
|
|
2360
|
+
closeSync(fd);
|
|
2361
|
+
chmodPrivatePath(tmp, 0o600);
|
|
2362
|
+
if (options?.failBeforeRename) {
|
|
2363
|
+
try { unlinkSync(tmp); } catch { /* */ }
|
|
2364
|
+
throw new Error("test_fail_pending_rename");
|
|
2365
|
+
}
|
|
2366
|
+
try {
|
|
2367
|
+
renameSync(tmp, dest);
|
|
2368
|
+
} catch (error) {
|
|
2369
|
+
try { unlinkSync(tmp); } catch { /* */ }
|
|
2370
|
+
throw error;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function restoreThreadsFromManifest(
|
|
2375
|
+
stateDbPath: string | null,
|
|
2376
|
+
entries: CleanupManifestEntry[],
|
|
2377
|
+
backup: SatelliteBackup | null,
|
|
2378
|
+
busyTimeoutMs: number,
|
|
2379
|
+
codexHome: string,
|
|
2380
|
+
): { ok: true } | ReconcileErr {
|
|
2381
|
+
const manifestThreadIds = entries
|
|
2382
|
+
.map(e => e.threadId)
|
|
2383
|
+
.filter((id): id is string => typeof id === "string");
|
|
2384
|
+
const backupThreadIds = backup?.threadIds ?? [];
|
|
2385
|
+
const needsThreads = manifestThreadIds.length > 0
|
|
2386
|
+
|| backupThreadIds.length > 0
|
|
2387
|
+
|| Boolean(backup?.threads?.length);
|
|
2388
|
+
|
|
2389
|
+
if (needsThreads && (!stateDbPath || !existsSync(stateDbPath))) {
|
|
2390
|
+
return { ok: false, error: "db_reconcile_failed" };
|
|
2391
|
+
}
|
|
2392
|
+
if (!stateDbPath || !existsSync(stateDbPath)) {
|
|
2393
|
+
return { ok: true };
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
const result = withWritableDb(stateDbPath, busyTimeoutMs, db => {
|
|
2397
|
+
if (!tableExists(db, "threads")) throw new Error("missing_threads_table");
|
|
2398
|
+
|
|
2399
|
+
const requiredCols = requiredThreadColumnNames(db);
|
|
2400
|
+
const allowedCols = tableColumnNames(db, "threads");
|
|
2401
|
+
const snapshotThreads = backup?.threads && isSqlRowArray(backup.threads)
|
|
2402
|
+
? backup.threads
|
|
2403
|
+
: [];
|
|
2404
|
+
const completeSnapshots = snapshotThreads.filter(row =>
|
|
2405
|
+
threadSnapshotCoversRequiredColumns(row, requiredCols),
|
|
2406
|
+
);
|
|
2407
|
+
const coveredIds = new Set(
|
|
2408
|
+
completeSnapshots
|
|
2409
|
+
.map(r => r.id)
|
|
2410
|
+
.filter((id): id is string => typeof id === "string"),
|
|
2411
|
+
);
|
|
2412
|
+
|
|
2413
|
+
// Legacy Phase-2 quarantine (no / incomplete satellite thread snapshots): reconstruct
|
|
2414
|
+
// every required column from the restored rollout via the history-provider session path.
|
|
2415
|
+
const toReconstruct = entries.filter(
|
|
2416
|
+
e => typeof e.threadId === "string"
|
|
2417
|
+
&& typeof e.rolloutPath === "string"
|
|
2418
|
+
&& !coveredIds.has(e.threadId!),
|
|
2419
|
+
);
|
|
2420
|
+
const reconstructed: SqlRow[] = [];
|
|
2421
|
+
for (const entry of toReconstruct) {
|
|
2422
|
+
let abs: string | undefined;
|
|
2423
|
+
try {
|
|
2424
|
+
abs = absFromRel(codexHome, entry.rolloutPath!);
|
|
2425
|
+
} catch {
|
|
2426
|
+
abs = undefined;
|
|
2427
|
+
}
|
|
2428
|
+
// Legacy compressed-only quarantine: manifest rolloutPath is often the logical
|
|
2429
|
+
// `.jsonl` name while the only restored physical file is `.jsonl.zst`.
|
|
2430
|
+
if (!abs || !existsSync(abs)) {
|
|
2431
|
+
for (const rel of entry.physicalRelPaths) {
|
|
2432
|
+
try {
|
|
2433
|
+
const candidate = absFromRel(codexHome, rel);
|
|
2434
|
+
if (existsSync(candidate)) {
|
|
2435
|
+
abs = candidate;
|
|
2436
|
+
break;
|
|
2437
|
+
}
|
|
2438
|
+
} catch {
|
|
2439
|
+
/* try next physical path */
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
if (!abs) throw new Error("missing_rollout_for_thread");
|
|
2444
|
+
// Prefer a plain .jsonl sibling when present; otherwise readThreadFieldsFromRollout
|
|
2445
|
+
// decompresses a lone .jsonl.zst in memory (bounded) for legacy quarantine restores.
|
|
2446
|
+
if (abs.endsWith(ZST_SUFFIX)) {
|
|
2447
|
+
const plain = abs.slice(0, -".zst".length);
|
|
2448
|
+
if (existsSync(plain)) abs = plain;
|
|
2449
|
+
}
|
|
2450
|
+
const row = reconstructThreadRowFromRollout(entry, abs, allowedCols, requiredCols);
|
|
2451
|
+
if (!row) throw new Error("thread_reconstruct_failed");
|
|
2452
|
+
reconstructed.push(row);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
if (completeSnapshots.length > 0) {
|
|
2456
|
+
insertRowsConflictIgnore(db, "threads", completeSnapshots);
|
|
2457
|
+
}
|
|
2458
|
+
if (reconstructed.length > 0) {
|
|
2459
|
+
insertRowsConflictIgnore(db, "threads", reconstructed);
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
if (backup?.dynamicTools && isSqlRowArray(backup.dynamicTools) && tableExists(db, "thread_dynamic_tools")) {
|
|
2463
|
+
insertRowsConflictIgnore(db, "thread_dynamic_tools", backup.dynamicTools);
|
|
2464
|
+
}
|
|
2465
|
+
if (backup?.spawnEdges && isSqlRowArray(backup.spawnEdges) && tableExists(db, "thread_spawn_edges")) {
|
|
2466
|
+
insertRowsConflictIgnore(db, "thread_spawn_edges", backup.spawnEdges);
|
|
2467
|
+
}
|
|
2468
|
+
});
|
|
2469
|
+
if (!result.ok) return result;
|
|
2470
|
+
return { ok: true };
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
function isSafeArchivedPhysicalRel(rel: string): boolean {
|
|
2474
|
+
const normalized = toForwardSlash(rel);
|
|
2475
|
+
if (!normalized.startsWith(`${ARCHIVED_SESSIONS_DIR}/`)) return false;
|
|
2476
|
+
if (normalized.includes("..")) return false;
|
|
2477
|
+
const rest = normalized.slice(ARCHIVED_SESSIONS_DIR.length + 1);
|
|
2478
|
+
if (!rest || rest.includes("/")) return false;
|
|
2479
|
+
return isRolloutFileName(rest);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/** Test-only failure injection for restore atomicity regressions. */
|
|
2483
|
+
export interface RestoreTestHooks {
|
|
2484
|
+
/** After state threads/dependents commit, before satellite commits. */
|
|
2485
|
+
failAfterStateCommit?: boolean;
|
|
2486
|
+
/** After the first satellite DB commit (logs → memories → goals). */
|
|
2487
|
+
failAfterFirstSatelliteCommit?: boolean;
|
|
2488
|
+
/** When the leftover staged-rollout completeness gate runs. */
|
|
2489
|
+
failAtLeftoverStageGate?: boolean;
|
|
2490
|
+
/** Fail the initial restore-pending.json write (before any file moves). */
|
|
2491
|
+
failInitialPendingWrite?: boolean;
|
|
2492
|
+
/** Fail a later pending update after the temp is written but before rename. */
|
|
2493
|
+
failPendingWriteBeforeRename?: boolean;
|
|
2494
|
+
/** Crash immediately after file moves (marker already durable). */
|
|
2495
|
+
failAfterFileMoves?: boolean;
|
|
2496
|
+
/**
|
|
2497
|
+
* After this many successful rollout moves in the current attempt, throw.
|
|
2498
|
+
* Exercises mid-loop failure with some dests placed and others still staged.
|
|
2499
|
+
*/
|
|
2500
|
+
failAfterMoveCount?: number;
|
|
2501
|
+
/** Fail renaming the completed stage to a non-listable tombstone dir. */
|
|
2502
|
+
failStageTombstoneRename?: boolean;
|
|
2503
|
+
/** After tombstone rename, skip best-effort tombstone delete (orphan is OK). */
|
|
2504
|
+
failTombstoneDelete?: boolean;
|
|
2505
|
+
/**
|
|
2506
|
+
* Test-only: spin-wait this many ms after rollout file moves, before DB
|
|
2507
|
+
* reconcile, so cleanup can race an in-flight restore.
|
|
2508
|
+
*/
|
|
2509
|
+
holdAfterFileMovesMs?: number;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
/**
|
|
2513
|
+
* Resume must not clear owed satellite work when the matching backup section is
|
|
2514
|
+
* absent — fail closed per section instead.
|
|
2515
|
+
*/
|
|
2516
|
+
function failClosedSatelliteResume(
|
|
2517
|
+
priorPending: RestorePendingState,
|
|
2518
|
+
satelliteBackup: SatelliteBackup | null,
|
|
2519
|
+
): RestoreErrorCode | null {
|
|
2520
|
+
const owed = priorPending.pending;
|
|
2521
|
+
if (!owed.logs && !owed.memories && !owed.goals) return null;
|
|
2522
|
+
if (!satelliteBackup) return "db_reconcile_failed";
|
|
2523
|
+
if (owed.logs && !satelliteBackup.logs) return "db_reconcile_failed";
|
|
2524
|
+
if (owed.memories && !satelliteBackup.memories) return "db_reconcile_failed";
|
|
2525
|
+
if (owed.goals && !satelliteBackup.goals) return "db_reconcile_failed";
|
|
2526
|
+
return null;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* Successful restore finalization: rename the stage to a tombstone name that
|
|
2531
|
+
* `listTrashEntries` ignores, then delete the tombstone best-effort. A failed
|
|
2532
|
+
* rename leaves the original stage (and all evidence) intact for retry.
|
|
2533
|
+
*/
|
|
2534
|
+
function finalizeRestoredStage(
|
|
2535
|
+
stageDir: string,
|
|
2536
|
+
codexHome: string,
|
|
2537
|
+
hooks?: Pick<RestoreTestHooks, "failStageTombstoneRename" | "failTombstoneDelete">,
|
|
2538
|
+
): boolean {
|
|
2539
|
+
const trashRoot = join(codexHome, TRASH_DIR);
|
|
2540
|
+
const epoch = basename(stageDir);
|
|
2541
|
+
const tombstoneName = `.tombstone-${epoch}-${randomUUID()}`;
|
|
2542
|
+
const tombstonePath = join(trashRoot, tombstoneName);
|
|
2543
|
+
try {
|
|
2544
|
+
if (hooks?.failStageTombstoneRename) throw new Error("test_fail_stage_tombstone_rename");
|
|
2545
|
+
renameSync(stageDir, tombstonePath);
|
|
2546
|
+
} catch {
|
|
2547
|
+
return false;
|
|
2548
|
+
}
|
|
2549
|
+
if (!hooks?.failTombstoneDelete) {
|
|
2550
|
+
try { rmSync(tombstonePath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
2551
|
+
}
|
|
2552
|
+
return true;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
/**
|
|
2556
|
+
* Restore one quarantine entry: move JSONL back, re-insert threads (+ satellites
|
|
2557
|
+
* when satellite-backup.json is present), then remove the trash directory.
|
|
2558
|
+
*
|
|
2559
|
+
* Late failures after files have moved never compensate metadata or restage.
|
|
2560
|
+
* Instead they persist `restore-pending.json` (accepted dest paths + which
|
|
2561
|
+
* state/logs/memories/goals sections still need work) atomically *before* any
|
|
2562
|
+
* rollout move, then update it after each section so a retry can accept existing
|
|
2563
|
+
* destinations and resume only missing metadata.
|
|
2564
|
+
*/
|
|
2565
|
+
export function restoreTrashEntry(
|
|
2566
|
+
trashId: string,
|
|
2567
|
+
options?: {
|
|
2568
|
+
codexHome?: string;
|
|
2569
|
+
busyTimeoutMs?: number;
|
|
2570
|
+
_test?: RestoreTestHooks;
|
|
2571
|
+
},
|
|
2572
|
+
): RestoreResult {
|
|
2573
|
+
const codexHome = options?.codexHome ?? resolveCodexHomeDir();
|
|
2574
|
+
const busyTimeoutMs = options?.busyTimeoutMs ?? 100;
|
|
2575
|
+
const hooks = options?._test;
|
|
2576
|
+
|
|
2577
|
+
const resolved = resolveTrashStageDir(trashId, codexHome);
|
|
2578
|
+
if (!resolved.ok) {
|
|
2579
|
+
return { ok: false, count: 0, bytes: 0, restoredPaths: [], error: resolved.error };
|
|
2580
|
+
}
|
|
2581
|
+
const { stageDir, id } = resolved;
|
|
2582
|
+
|
|
2583
|
+
let manifestRaw: string;
|
|
2584
|
+
try {
|
|
2585
|
+
manifestRaw = readFileSync(join(stageDir, "manifest.json"), "utf8");
|
|
2586
|
+
} catch {
|
|
2587
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
|
|
2588
|
+
}
|
|
2589
|
+
const manifest = parseTrashManifest(manifestRaw);
|
|
2590
|
+
if (!manifest?.entries?.length) {
|
|
2591
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
const pendingRead = readRestorePending(stageDir);
|
|
2595
|
+
if (pendingRead.status === "invalid") {
|
|
2596
|
+
// Malformed marker means an incomplete restore may already have moved files;
|
|
2597
|
+
// never treat it as a fresh restore.
|
|
2598
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
|
|
2599
|
+
}
|
|
2600
|
+
const priorPending = pendingRead.status === "valid" ? pendingRead.state : null;
|
|
2601
|
+
const acceptedDest = new Set(priorPending?.acceptedDestRels ?? []);
|
|
2602
|
+
|
|
2603
|
+
// Partial permanent purges may leave only a subset of physical files on disk —
|
|
2604
|
+
// trim to survivors rather than failing the whole entry for a purged twin.
|
|
2605
|
+
// Resume also treats already-restored accepted destinations as survivors.
|
|
2606
|
+
const entries: CleanupManifestEntry[] = [];
|
|
2607
|
+
for (const entry of manifest.entries) {
|
|
2608
|
+
if (!entry.physicalRelPaths.every(isSafeArchivedPhysicalRel)) {
|
|
2609
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
|
|
2610
|
+
}
|
|
2611
|
+
const surviving = entry.physicalRelPaths.filter(rel => {
|
|
2612
|
+
if (existsSync(join(stageDir, basename(rel)))) return true;
|
|
2613
|
+
if (!acceptedDest.has(rel)) return false;
|
|
2614
|
+
try {
|
|
2615
|
+
return existsSync(absFromRel(codexHome, rel));
|
|
2616
|
+
} catch {
|
|
2617
|
+
return false;
|
|
2618
|
+
}
|
|
2619
|
+
});
|
|
2620
|
+
if (surviving.length === 0) {
|
|
2621
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
|
|
2622
|
+
}
|
|
2623
|
+
entries.push({ ...entry, physicalRelPaths: surviving });
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
const paths = discoverRuntimeDbPaths(codexHome);
|
|
2627
|
+
const backupRead = readSatelliteBackupFile(stageDir);
|
|
2628
|
+
if (backupRead.status === "invalid") {
|
|
2629
|
+
return {
|
|
2630
|
+
ok: false,
|
|
2631
|
+
trashDir: id,
|
|
2632
|
+
count: 0,
|
|
2633
|
+
bytes: 0,
|
|
2634
|
+
restoredPaths: [],
|
|
2635
|
+
error: "db_reconcile_failed",
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
let satelliteBackup: SatelliteBackup | null = null;
|
|
2640
|
+
if (backupRead.status === "ok") {
|
|
2641
|
+
const remapped = remapSatelliteBackupPaths(backupRead.backup, paths);
|
|
2642
|
+
if (!remapped.ok) {
|
|
2643
|
+
return {
|
|
2644
|
+
ok: false,
|
|
2645
|
+
trashDir: id,
|
|
2646
|
+
count: 0,
|
|
2647
|
+
bytes: 0,
|
|
2648
|
+
restoredPaths: [],
|
|
2649
|
+
error: "db_reconcile_failed",
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
satelliteBackup = remapped.backup;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
if (priorPending) {
|
|
2656
|
+
const resumeErr = failClosedSatelliteResume(priorPending, satelliteBackup);
|
|
2657
|
+
if (resumeErr) {
|
|
2658
|
+
return {
|
|
2659
|
+
ok: false,
|
|
2660
|
+
trashDir: id,
|
|
2661
|
+
count: 0,
|
|
2662
|
+
bytes: 0,
|
|
2663
|
+
restoredPaths: [],
|
|
2664
|
+
error: resumeErr,
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
const pendingSections: RestorePendingSections = {
|
|
2670
|
+
state: priorPending ? priorPending.pending.state : true,
|
|
2671
|
+
logs: priorPending ? priorPending.pending.logs : Boolean(satelliteBackup?.logs),
|
|
2672
|
+
memories: priorPending ? priorPending.pending.memories : Boolean(satelliteBackup?.memories),
|
|
2673
|
+
goals: priorPending ? priorPending.pending.goals : Boolean(satelliteBackup?.goals),
|
|
2674
|
+
};
|
|
2675
|
+
|
|
2676
|
+
const needAnySatellite = pendingSections.logs || pendingSections.memories || pendingSections.goals;
|
|
2677
|
+
if (pendingSections.state) {
|
|
2678
|
+
const needsThreads = entries.some(e => typeof e.threadId === "string")
|
|
2679
|
+
|| Boolean(satelliteBackup?.threadIds?.length)
|
|
2680
|
+
|| Boolean(satelliteBackup?.threads?.length);
|
|
2681
|
+
if (needsThreads && (!paths.state || !existsSync(paths.state))) {
|
|
2682
|
+
return {
|
|
2683
|
+
ok: false,
|
|
2684
|
+
trashDir: id,
|
|
2685
|
+
count: 0,
|
|
2686
|
+
bytes: 0,
|
|
2687
|
+
restoredPaths: [],
|
|
2688
|
+
error: "db_reconcile_failed",
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
const probe = probeStateDbWritable(codexHome, busyTimeoutMs);
|
|
2692
|
+
if (!probe.ok) {
|
|
2693
|
+
return {
|
|
2694
|
+
ok: false,
|
|
2695
|
+
trashDir: id,
|
|
2696
|
+
count: 0,
|
|
2697
|
+
bytes: 0,
|
|
2698
|
+
restoredPaths: [],
|
|
2699
|
+
error: probe.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// Acquire only the satellite locks still needed so a busy DB for an already-
|
|
2705
|
+
// finished section cannot block resume. Locks happen before moves on a fresh
|
|
2706
|
+
// attempt so failure stays retryable (nothing has left the stage yet).
|
|
2707
|
+
let satelliteLocks: SatelliteWriteLocks | undefined;
|
|
2708
|
+
if (needAnySatellite) {
|
|
2709
|
+
try {
|
|
2710
|
+
satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs, {
|
|
2711
|
+
logs: pendingSections.logs,
|
|
2712
|
+
memories: pendingSections.memories,
|
|
2713
|
+
goals: pendingSections.goals,
|
|
2714
|
+
});
|
|
2715
|
+
} catch (error) {
|
|
2716
|
+
return {
|
|
2717
|
+
ok: false,
|
|
2718
|
+
trashDir: id,
|
|
2719
|
+
count: 0,
|
|
2720
|
+
bytes: 0,
|
|
2721
|
+
restoredPaths: [],
|
|
2722
|
+
error: mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
const failBeforeMoves = (error: RestoreErrorCode): RestoreResult => {
|
|
2728
|
+
if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
|
|
2729
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error };
|
|
2730
|
+
};
|
|
2731
|
+
|
|
2732
|
+
// Plan renames: staged basename → original archived_sessions path.
|
|
2733
|
+
// Resume accepts destinations already restored by this incomplete attempt.
|
|
2734
|
+
const alreadyMoved: StagedFile[] = [];
|
|
2735
|
+
const toMove: StagedFile[] = [];
|
|
2736
|
+
for (const entry of entries) {
|
|
2737
|
+
for (const rel of entry.physicalRelPaths) {
|
|
2738
|
+
const base = basename(rel);
|
|
2739
|
+
const from = join(stageDir, base);
|
|
2740
|
+
let to: string;
|
|
2741
|
+
try {
|
|
2742
|
+
to = absFromRel(codexHome, rel);
|
|
2743
|
+
} catch {
|
|
2744
|
+
return failBeforeMoves("invalid_trash");
|
|
2745
|
+
}
|
|
2746
|
+
const fromExists = existsSync(from);
|
|
2747
|
+
const toExists = existsSync(to);
|
|
2748
|
+
if (toExists && acceptedDest.has(rel) && !fromExists) {
|
|
2749
|
+
alreadyMoved.push({ from, to, relPath: rel });
|
|
2750
|
+
continue;
|
|
2751
|
+
}
|
|
2752
|
+
if (toExists) {
|
|
2753
|
+
return failBeforeMoves("dest_exists");
|
|
2754
|
+
}
|
|
2755
|
+
if (!fromExists) {
|
|
2756
|
+
return failBeforeMoves("fs_failed");
|
|
2757
|
+
}
|
|
2758
|
+
toMove.push({ from, to, relPath: rel });
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
const planned = [...alreadyMoved, ...toMove];
|
|
2763
|
+
const restoredPaths = [...new Set(entries.map(e => e.relPath))];
|
|
2764
|
+
const bytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0);
|
|
2765
|
+
const partialCounts = { count: restoredPaths.length, bytes, restoredPaths };
|
|
2766
|
+
|
|
2767
|
+
let pendingWriteCount = 0;
|
|
2768
|
+
const persistPending = (): void => {
|
|
2769
|
+
pendingWriteCount += 1;
|
|
2770
|
+
const isInitial = pendingWriteCount === 1;
|
|
2771
|
+
writeRestorePending(
|
|
2772
|
+
stageDir,
|
|
2773
|
+
{
|
|
2774
|
+
version: 1,
|
|
2775
|
+
filesRestored: true,
|
|
2776
|
+
acceptedDestRels: planned.map(m => m.relPath),
|
|
2777
|
+
pending: { ...pendingSections },
|
|
2778
|
+
},
|
|
2779
|
+
{
|
|
2780
|
+
failWrite: Boolean(isInitial && hooks?.failInitialPendingWrite),
|
|
2781
|
+
failBeforeRename: Boolean(!isInitial && hooks?.failPendingWriteBeforeRename),
|
|
2782
|
+
},
|
|
2783
|
+
);
|
|
2784
|
+
};
|
|
2785
|
+
|
|
2786
|
+
// Durable resume marker before any rollout leaves the stage. Crash after a
|
|
2787
|
+
// later move can still accept destinations from this marker.
|
|
2788
|
+
try {
|
|
2789
|
+
persistPending();
|
|
2790
|
+
} catch {
|
|
2791
|
+
return failBeforeMoves("fs_failed");
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
const newlyMoved: StagedFile[] = [];
|
|
2795
|
+
try {
|
|
2796
|
+
mkdirSync(join(codexHome, ARCHIVED_SESSIONS_DIR), { recursive: true });
|
|
2797
|
+
for (const item of toMove) {
|
|
2798
|
+
// Atomic no-replace (.trash ↔ archived_sessions). Mid-loop failure keeps
|
|
2799
|
+
// already-placed dests and the durable planned acceptedDestRels marker.
|
|
2800
|
+
renameNoReplace(item.from, item.to);
|
|
2801
|
+
newlyMoved.push(item);
|
|
2802
|
+
if (
|
|
2803
|
+
hooks?.failAfterMoveCount !== undefined
|
|
2804
|
+
&& newlyMoved.length >= hooks.failAfterMoveCount
|
|
2805
|
+
) {
|
|
2806
|
+
throw new Error("test_fail_after_move_count");
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
} catch (error) {
|
|
2810
|
+
// Marker was written before any move. Never reverse successful renames or
|
|
2811
|
+
// drop/narrow acceptedDestRels — resume must accept placed dests and finish
|
|
2812
|
+
// the remaining staged files.
|
|
2813
|
+
if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
|
|
2814
|
+
const placed = [...alreadyMoved, ...newlyMoved];
|
|
2815
|
+
const placedPhysical = new Set(placed.map(m => m.relPath));
|
|
2816
|
+
const partialEntries = entries.filter(e =>
|
|
2817
|
+
e.physicalRelPaths.every(rel => placedPhysical.has(rel)),
|
|
2818
|
+
);
|
|
2819
|
+
const midMoveRestored = [...new Set(partialEntries.map(e => e.relPath))];
|
|
2820
|
+
return {
|
|
2821
|
+
ok: false,
|
|
2822
|
+
trashDir: id,
|
|
2823
|
+
count: midMoveRestored.length,
|
|
2824
|
+
bytes: partialEntries.reduce((sum, e) => sum + (e.bytes || 0), 0),
|
|
2825
|
+
restoredPaths: midMoveRestored,
|
|
2826
|
+
error: isExistError(error) ? "dest_exists" : "fs_failed",
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
const moved = [...alreadyMoved, ...newlyMoved];
|
|
2831
|
+
|
|
2832
|
+
/**
|
|
2833
|
+
* Never compensate DBs or restage files after moves. Keep restored files,
|
|
2834
|
+
* persist which sections remain, and return accurate partial counts.
|
|
2835
|
+
*/
|
|
2836
|
+
const abortAfterMoves = (error: RestoreErrorCode): RestoreResult => {
|
|
2837
|
+
if (satelliteLocks) {
|
|
2838
|
+
rollbackAllSatelliteLocks(satelliteLocks);
|
|
2839
|
+
satelliteLocks = undefined;
|
|
2840
|
+
}
|
|
2841
|
+
try {
|
|
2842
|
+
persistPending();
|
|
2843
|
+
} catch {
|
|
2844
|
+
/* best-effort — files already restored; prior atomic marker remains */
|
|
2845
|
+
}
|
|
2846
|
+
return { ok: false, trashDir: id, ...partialCounts, error };
|
|
2847
|
+
};
|
|
2848
|
+
|
|
2849
|
+
if (hooks?.holdAfterFileMovesMs !== undefined) {
|
|
2850
|
+
const holdMs = Math.max(0, Math.floor(hooks.holdAfterFileMovesMs));
|
|
2851
|
+
if (holdMs > 0) {
|
|
2852
|
+
const deadline = Date.now() + holdMs;
|
|
2853
|
+
while (Date.now() < deadline) { /* test-only spin wait */ }
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
if (hooks?.failAfterFileMoves) {
|
|
2858
|
+
return abortAfterMoves("fs_failed");
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
if (pendingSections.state) {
|
|
2862
|
+
const threadsRestored = restoreThreadsFromManifest(
|
|
2863
|
+
paths.state,
|
|
2864
|
+
entries,
|
|
2865
|
+
satelliteBackup,
|
|
2866
|
+
busyTimeoutMs,
|
|
2867
|
+
codexHome,
|
|
2868
|
+
);
|
|
2869
|
+
if (!threadsRestored.ok) {
|
|
2870
|
+
return abortAfterMoves(
|
|
2871
|
+
threadsRestored.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
|
|
2872
|
+
);
|
|
2873
|
+
}
|
|
2874
|
+
pendingSections.state = false;
|
|
2875
|
+
try {
|
|
2876
|
+
persistPending();
|
|
2877
|
+
} catch {
|
|
2878
|
+
return abortAfterMoves("fs_failed");
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
if (hooks?.failAfterStateCommit) {
|
|
2883
|
+
return abortAfterMoves("db_reconcile_failed");
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
if (satelliteLocks && satelliteBackup) {
|
|
2887
|
+
const locks = satelliteLocks;
|
|
2888
|
+
try {
|
|
2889
|
+
// Commit one satellite DB at a time; uncommitted txs roll back via
|
|
2890
|
+
// rollbackAllSatelliteLocks. Completed sections are cleared in pending.
|
|
2891
|
+
if (pendingSections.logs && satelliteBackup.logs) {
|
|
2892
|
+
if (!locks.logs) throw new Error("missing_logs_lock");
|
|
2893
|
+
if (!tableExists(locks.logs.db, "logs")) throw new Error("missing_logs_table");
|
|
2894
|
+
insertRowsConflictIgnore(locks.logs.db, "logs", satelliteBackup.logs.rows);
|
|
2895
|
+
commitSatelliteLock(locks.logs);
|
|
2896
|
+
locks.logs = undefined;
|
|
2897
|
+
pendingSections.logs = false;
|
|
2898
|
+
persistPending();
|
|
2899
|
+
if (hooks?.failAfterFirstSatelliteCommit) {
|
|
2900
|
+
throw new Error("test_fail_after_first_satellite");
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (pendingSections.memories && satelliteBackup.memories) {
|
|
2904
|
+
if (!locks.memories) throw new Error("missing_memories_lock");
|
|
2905
|
+
const mem = satelliteBackup.memories;
|
|
2906
|
+
if (!tableExists(locks.memories.db, "stage1_outputs")) {
|
|
2907
|
+
throw new Error("missing_stage1_outputs_table");
|
|
2908
|
+
}
|
|
2909
|
+
insertRowsConflictIgnore(locks.memories.db, "stage1_outputs", mem.stage1);
|
|
2910
|
+
if (tableExists(locks.memories.db, "jobs")) {
|
|
2911
|
+
insertRowsConflictIgnore(locks.memories.db, "jobs", mem.stage1Jobs);
|
|
2912
|
+
if (mem.consolidateTouched) {
|
|
2913
|
+
restoreConsolidateGlobalJob(
|
|
2914
|
+
locks.memories.db,
|
|
2915
|
+
mem.consolidateJob,
|
|
2916
|
+
mem.consolidatePostImage,
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
commitSatelliteLock(locks.memories);
|
|
2921
|
+
locks.memories = undefined;
|
|
2922
|
+
pendingSections.memories = false;
|
|
2923
|
+
persistPending();
|
|
2924
|
+
if (hooks?.failAfterFirstSatelliteCommit && !satelliteBackup.logs) {
|
|
2925
|
+
throw new Error("test_fail_after_first_satellite");
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
if (pendingSections.goals && satelliteBackup.goals) {
|
|
2929
|
+
if (!locks.goals) throw new Error("missing_goals_lock");
|
|
2930
|
+
const g = satelliteBackup.goals;
|
|
2931
|
+
if (!tableExists(locks.goals.db, "thread_goals")) {
|
|
2932
|
+
throw new Error("missing_thread_goals_table");
|
|
2933
|
+
}
|
|
2934
|
+
insertRowsConflictIgnore(locks.goals.db, "thread_goals", g.goals);
|
|
2935
|
+
if (tableExists(locks.goals.db, "thread_goal_continuation_deferrals")) {
|
|
2936
|
+
insertRowsConflictIgnore(
|
|
2937
|
+
locks.goals.db,
|
|
2938
|
+
"thread_goal_continuation_deferrals",
|
|
2939
|
+
g.deferrals,
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
commitSatelliteLock(locks.goals);
|
|
2943
|
+
locks.goals = undefined;
|
|
2944
|
+
pendingSections.goals = false;
|
|
2945
|
+
persistPending();
|
|
2946
|
+
if (
|
|
2947
|
+
hooks?.failAfterFirstSatelliteCommit
|
|
2948
|
+
&& !satelliteBackup.logs
|
|
2949
|
+
&& !satelliteBackup.memories
|
|
2950
|
+
) {
|
|
2951
|
+
throw new Error("test_fail_after_first_satellite");
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
// Close any locks acquired for DBs that had no pending work / backup rows.
|
|
2955
|
+
rollbackAllSatelliteLocks(locks);
|
|
2956
|
+
satelliteLocks = undefined;
|
|
2957
|
+
} catch (error) {
|
|
2958
|
+
return abortAfterMoves(
|
|
2959
|
+
mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
if (
|
|
2965
|
+
pendingSections.state
|
|
2966
|
+
|| pendingSections.logs
|
|
2967
|
+
|| pendingSections.memories
|
|
2968
|
+
|| pendingSections.goals
|
|
2969
|
+
) {
|
|
2970
|
+
return abortAfterMoves("db_reconcile_failed");
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
// Completeness gate: every planned file must sit at its restored path, and the stage
|
|
2974
|
+
// must hold no leftover rollout files, before we destroy the quarantine evidence.
|
|
2975
|
+
for (const item of moved) {
|
|
2976
|
+
if (!existsSync(item.to) || existsSync(item.from)) {
|
|
2977
|
+
return abortAfterMoves("fs_failed");
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
try {
|
|
2981
|
+
if (hooks?.failAtLeftoverStageGate) {
|
|
2982
|
+
return abortAfterMoves("fs_failed");
|
|
2983
|
+
}
|
|
2984
|
+
for (const name of readdirSync(stageDir)) {
|
|
2985
|
+
if (
|
|
2986
|
+
name === "manifest.json"
|
|
2987
|
+
|| name === SATELLITE_BACKUP_FILE
|
|
2988
|
+
|| name === RESTORE_PENDING_FILE
|
|
2989
|
+
) {
|
|
2990
|
+
continue;
|
|
2991
|
+
}
|
|
2992
|
+
if (!isRolloutFileName(name)) continue;
|
|
2993
|
+
return abortAfterMoves("fs_failed");
|
|
2994
|
+
}
|
|
2995
|
+
} catch {
|
|
2996
|
+
return abortAfterMoves("fs_failed");
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
if (!finalizeRestoredStage(stageDir, codexHome, hooks)) {
|
|
3000
|
+
return {
|
|
3001
|
+
ok: false,
|
|
3002
|
+
trashDir: id,
|
|
3003
|
+
...partialCounts,
|
|
3004
|
+
error: "fs_failed",
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
removeEmptyTrashRoot(codexHome);
|
|
3008
|
+
|
|
3009
|
+
return {
|
|
3010
|
+
ok: true,
|
|
3011
|
+
trashDir: id,
|
|
3012
|
+
...partialCounts,
|
|
3013
|
+
};
|
|
3014
|
+
}
|