@bitkyc08/opencodex 2.52.0 → 2.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-BlO4Yl6q.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
import { resolveCodexHomeDir } from "../../codex/home";
|
|
15
|
+
import { readThreadFieldsFromRollout } from "../../codex/history-provider";
|
|
16
|
+
import { discoverRuntimeDbPaths, insertRowsConflictIgnore, isSqlRowArray, mapDbError, probeStateDbWritable, tableColumnNames, tableExists, withWritableDb } from "./db";
|
|
17
|
+
import type { ReconcileErr, SqlRow } from "./db";
|
|
18
|
+
import { ARCHIVED_SESSIONS_DIR, TRASH_DIR, TRASH_EPOCH_DIR, ZST_SUFFIX, isExistError, isRolloutFileName, isSafeArchivedPhysicalRel, renameNoReplace, toForwardSlash } from "./paths";
|
|
19
|
+
import { RESTORE_PENDING_FILE, readRestorePending, writeRestorePending } from "./pending";
|
|
20
|
+
import type { RestorePendingSections, RestorePendingState } from "./pending";
|
|
21
|
+
import { SATELLITE_BACKUP_FILE, beginSatelliteWriteLocks, commitSatelliteLock, readSatelliteBackupFile, remapSatelliteBackupPaths, restoreConsolidateGlobalJob, rollbackAllSatelliteLocks } from "./satellite";
|
|
22
|
+
import type { SatelliteBackup, SatelliteWriteLocks } from "./satellite";
|
|
23
|
+
import { absFromRel, removeEmptyTrashRoot } from "./staging";
|
|
24
|
+
import type { StagedFile } from "./staging";
|
|
25
|
+
import type { CleanupManifestEntry, CleanupMode, RestoreErrorCode, RestoreResult, TrashEntrySummary } from "./types";
|
|
26
|
+
|
|
27
|
+
interface TrashManifest {
|
|
28
|
+
quarantinedAt?: number;
|
|
29
|
+
mode?: CleanupMode;
|
|
30
|
+
entries?: CleanupManifestEntry[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a trash `manifest.json` atomically.
|
|
35
|
+
*
|
|
36
|
+
* Any missing `entries` array, or any malformed entry / `physicalRelPaths` value /
|
|
37
|
+
* required field, rejects the **entire** manifest (returns null). Individual bad
|
|
38
|
+
* entries are never filtered out so a partial parse cannot silently drop evidence.
|
|
39
|
+
*/
|
|
40
|
+
function parseTrashManifest(raw: string): TrashManifest | null {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
43
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
44
|
+
const o = parsed as Record<string, unknown>;
|
|
45
|
+
if (!Array.isArray(o.entries)) return null;
|
|
46
|
+
|
|
47
|
+
const entries: CleanupManifestEntry[] = [];
|
|
48
|
+
for (const e of o.entries) {
|
|
49
|
+
if (!e || typeof e !== "object" || Array.isArray(e)) return null;
|
|
50
|
+
const entry = e as Record<string, unknown>;
|
|
51
|
+
if (typeof entry.relPath !== "string" || entry.relPath.length === 0) return null;
|
|
52
|
+
if (typeof entry.bytes !== "number" || !Number.isFinite(entry.bytes)) return null;
|
|
53
|
+
if (typeof entry.mtimeMs !== "number" || !Number.isFinite(entry.mtimeMs)) return null;
|
|
54
|
+
if (!Array.isArray(entry.physicalRelPaths) || entry.physicalRelPaths.length === 0) return null;
|
|
55
|
+
const physical: string[] = [];
|
|
56
|
+
for (const p of entry.physicalRelPaths) {
|
|
57
|
+
// Do not strip bad elements — one malformed path invalidates the whole manifest.
|
|
58
|
+
if (typeof p !== "string" || p.length === 0) return null;
|
|
59
|
+
physical.push(p);
|
|
60
|
+
}
|
|
61
|
+
if ("threadId" in entry && typeof entry.threadId !== "string") return null;
|
|
62
|
+
if ("rolloutPath" in entry && typeof entry.rolloutPath !== "string") return null;
|
|
63
|
+
if (
|
|
64
|
+
"archived" in entry
|
|
65
|
+
&& entry.archived !== null
|
|
66
|
+
&& typeof entry.archived !== "number"
|
|
67
|
+
) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
entries.push({
|
|
71
|
+
relPath: entry.relPath,
|
|
72
|
+
bytes: entry.bytes,
|
|
73
|
+
mtimeMs: entry.mtimeMs,
|
|
74
|
+
physicalRelPaths: physical,
|
|
75
|
+
...(typeof entry.threadId === "string" ? { threadId: entry.threadId } : {}),
|
|
76
|
+
...(typeof entry.rolloutPath === "string" ? { rolloutPath: entry.rolloutPath } : {}),
|
|
77
|
+
...(entry.archived === null || typeof entry.archived === "number"
|
|
78
|
+
? { archived: entry.archived as number | null }
|
|
79
|
+
: {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const out: TrashManifest = { entries };
|
|
84
|
+
if (typeof o.quarantinedAt === "number" && Number.isFinite(o.quarantinedAt)) {
|
|
85
|
+
out.quarantinedAt = o.quarantinedAt;
|
|
86
|
+
}
|
|
87
|
+
if (o.mode === "quarantine" || o.mode === "permanent") out.mode = o.mode;
|
|
88
|
+
return out;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate a trash entry id as a single `.trash/<epoch>` segment under CODEX_HOME.
|
|
96
|
+
* Returns the absolute stage directory, or null when the id is unsafe / missing.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveTrashStageDir(
|
|
99
|
+
trashId: string,
|
|
100
|
+
codexHome: string,
|
|
101
|
+
): { ok: true; stageDir: string; id: string } | { ok: false; error: RestoreErrorCode } {
|
|
102
|
+
const normalized = toForwardSlash(trashId.trim()).replace(/\/+$/, "");
|
|
103
|
+
if (!normalized.startsWith(`${TRASH_DIR}/`)) return { ok: false, error: "invalid_trash" };
|
|
104
|
+
const rest = normalized.slice(TRASH_DIR.length + 1);
|
|
105
|
+
if (!rest || rest.includes("/") || rest.includes("\\") || rest.includes("..")) {
|
|
106
|
+
return { ok: false, error: "invalid_trash" };
|
|
107
|
+
}
|
|
108
|
+
if (!TRASH_EPOCH_DIR.test(rest)) return { ok: false, error: "invalid_trash" };
|
|
109
|
+
let stageDir: string;
|
|
110
|
+
try {
|
|
111
|
+
stageDir = absFromRel(codexHome, `${TRASH_DIR}/${rest}`);
|
|
112
|
+
} catch {
|
|
113
|
+
return { ok: false, error: "invalid_trash" };
|
|
114
|
+
}
|
|
115
|
+
if (!existsSync(stageDir)) return { ok: false, error: "missing_trash" };
|
|
116
|
+
try {
|
|
117
|
+
if (!statSync(stageDir).isDirectory()) return { ok: false, error: "invalid_trash" };
|
|
118
|
+
} catch {
|
|
119
|
+
return { ok: false, error: "missing_trash" };
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, stageDir, id: `${TRASH_DIR}/${rest}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sumTrashEntryBytes(stageDir: string, manifest: TrashManifest | null): {
|
|
125
|
+
fileCount: number;
|
|
126
|
+
bytes: number;
|
|
127
|
+
} {
|
|
128
|
+
let fileCount = 0;
|
|
129
|
+
let bytes = 0;
|
|
130
|
+
let names: string[] = [];
|
|
131
|
+
try {
|
|
132
|
+
names = readdirSync(stageDir);
|
|
133
|
+
} catch {
|
|
134
|
+
return { fileCount: 0, bytes: 0 };
|
|
135
|
+
}
|
|
136
|
+
for (const name of names) {
|
|
137
|
+
if (
|
|
138
|
+
name === "manifest.json"
|
|
139
|
+
|| name === SATELLITE_BACKUP_FILE
|
|
140
|
+
|| name === RESTORE_PENDING_FILE
|
|
141
|
+
) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!isRolloutFileName(name)) continue;
|
|
145
|
+
try {
|
|
146
|
+
const st = statSync(join(stageDir, name));
|
|
147
|
+
if (!st.isFile()) continue;
|
|
148
|
+
fileCount += 1;
|
|
149
|
+
bytes += st.size;
|
|
150
|
+
} catch { /* */ }
|
|
151
|
+
}
|
|
152
|
+
// Prefer live FS counts; fall back to manifest totals when the stage is empty of rollouts.
|
|
153
|
+
if (fileCount === 0 && manifest?.entries?.length) {
|
|
154
|
+
fileCount = manifest.entries.reduce((n, e) => n + Math.max(1, e.physicalRelPaths.length), 0);
|
|
155
|
+
bytes = manifest.entries.reduce((n, e) => n + (e.bytes || 0), 0);
|
|
156
|
+
}
|
|
157
|
+
return { fileCount, bytes };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** List quarantine entries under `CODEX_HOME/.trash/` (relative ids only). */
|
|
161
|
+
export function listTrashEntries(
|
|
162
|
+
codexHome: string = resolveCodexHomeDir(),
|
|
163
|
+
): TrashEntrySummary[] {
|
|
164
|
+
const trashRoot = join(codexHome, TRASH_DIR);
|
|
165
|
+
let names: string[] = [];
|
|
166
|
+
try {
|
|
167
|
+
names = readdirSync(trashRoot);
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
const out: TrashEntrySummary[] = [];
|
|
172
|
+
for (const name of names) {
|
|
173
|
+
if (!TRASH_EPOCH_DIR.test(name)) continue;
|
|
174
|
+
const stageDir = join(trashRoot, name);
|
|
175
|
+
try {
|
|
176
|
+
if (!statSync(stageDir).isDirectory()) continue;
|
|
177
|
+
} catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
let manifest: TrashManifest | null = null;
|
|
181
|
+
try {
|
|
182
|
+
manifest = parseTrashManifest(readFileSync(join(stageDir, "manifest.json"), "utf8"));
|
|
183
|
+
} catch {
|
|
184
|
+
manifest = null;
|
|
185
|
+
}
|
|
186
|
+
const { fileCount, bytes } = sumTrashEntryBytes(stageDir, manifest);
|
|
187
|
+
// Skip empty collision placeholders left behind without a manifest or rollouts.
|
|
188
|
+
if (fileCount === 0 && !manifest?.entries?.length) {
|
|
189
|
+
try {
|
|
190
|
+
if (!existsSync(join(stageDir, "manifest.json"))) continue;
|
|
191
|
+
} catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
out.push({
|
|
196
|
+
id: `${TRASH_DIR}/${name}`,
|
|
197
|
+
epoch: name,
|
|
198
|
+
fileCount,
|
|
199
|
+
bytes,
|
|
200
|
+
...(manifest?.quarantinedAt !== undefined ? { quarantinedAt: manifest.quarantinedAt } : {}),
|
|
201
|
+
...(manifest?.mode ? { mode: manifest.mode } : {}),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
out.sort((a, b) => {
|
|
205
|
+
const aq = a.quarantinedAt ?? (Number(a.epoch.split("-")[0]) || 0);
|
|
206
|
+
const bq = b.quarantinedAt ?? (Number(b.epoch.split("-")[0]) || 0);
|
|
207
|
+
return bq - aq || b.epoch.localeCompare(a.epoch);
|
|
208
|
+
});
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** True when a snapshotted thread row covers every NOT NULL column on the live schema. */
|
|
213
|
+
function threadSnapshotCoversRequiredColumns(row: SqlRow, requiredCols: string[]): boolean {
|
|
214
|
+
for (const col of requiredCols) {
|
|
215
|
+
if (!(col in row) || row[col] === undefined) return false;
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function requiredThreadColumnNames(db: Database): string[] {
|
|
221
|
+
if (!tableExists(db, "threads")) return [];
|
|
222
|
+
const rows = db.query<{ name: string; notnull: number }, []>(
|
|
223
|
+
`PRAGMA table_info("threads")`,
|
|
224
|
+
).all();
|
|
225
|
+
return rows.filter(r => r.notnull === 1).map(r => r.name);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build a production-shaped thread row for schemas that predate full satellite snapshots.
|
|
230
|
+
* Prefer `readThreadFieldsFromRollout` (canonical history/session_meta path); fall back to
|
|
231
|
+
* the sparse manifest fields only when the live schema does not require model/source/message.
|
|
232
|
+
*/
|
|
233
|
+
function reconstructThreadRowFromRollout(
|
|
234
|
+
entry: CleanupManifestEntry,
|
|
235
|
+
rolloutAbsPath: string,
|
|
236
|
+
allowedCols: Set<string>,
|
|
237
|
+
requiredCols: string[],
|
|
238
|
+
): SqlRow | null {
|
|
239
|
+
if (typeof entry.threadId !== "string" || typeof entry.rolloutPath !== "string") return null;
|
|
240
|
+
|
|
241
|
+
const fields = readThreadFieldsFromRollout(rolloutAbsPath);
|
|
242
|
+
const row: SqlRow = {
|
|
243
|
+
id: entry.threadId,
|
|
244
|
+
rollout_path: entry.rolloutPath,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
if (fields) {
|
|
248
|
+
// Prefer manifest thread id (binding) but keep rollout-derived listing fields.
|
|
249
|
+
if (allowedCols.has("model_provider")) row.model_provider = fields.modelProvider;
|
|
250
|
+
if (allowedCols.has("source")) row.source = fields.source;
|
|
251
|
+
if (allowedCols.has("first_user_message")) row.first_user_message = fields.firstUserMessage;
|
|
252
|
+
if (allowedCols.has("has_user_event")) row.has_user_event = fields.hasUserEvent;
|
|
253
|
+
if (allowedCols.has("cwd") && fields.cwd !== undefined) row.cwd = fields.cwd;
|
|
254
|
+
if (allowedCols.has("history_mode") && fields.historyMode !== undefined) {
|
|
255
|
+
row.history_mode = fields.historyMode;
|
|
256
|
+
}
|
|
257
|
+
if (allowedCols.has("cli_version") && fields.cliVersion !== undefined) {
|
|
258
|
+
row.cli_version = fields.cliVersion;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (allowedCols.has("archived")) {
|
|
263
|
+
row.archived = entry.archived ?? 1;
|
|
264
|
+
}
|
|
265
|
+
if (allowedCols.has("archived_at")) {
|
|
266
|
+
row.archived_at = null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fill remaining NOT NULL columns with safe empties when the rollout lacked them
|
|
270
|
+
// (e.g. fixture rollouts without a user turn still need first_user_message = '').
|
|
271
|
+
for (const col of requiredCols) {
|
|
272
|
+
if (row[col] !== undefined) continue;
|
|
273
|
+
if (col === "id" || col === "rollout_path") continue;
|
|
274
|
+
if (col === "model_provider") row[col] = "openai";
|
|
275
|
+
else if (col === "source") row[col] = "cli";
|
|
276
|
+
else if (col === "first_user_message") row[col] = "";
|
|
277
|
+
else if (col === "has_user_event") row[col] = 0;
|
|
278
|
+
else if (col === "archived") row[col] = entry.archived ?? 1;
|
|
279
|
+
else return null; // unknown required column we cannot invent
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// If the schema requires listing fields, refuse when the rollout was unreadable.
|
|
283
|
+
const needsSessionMeta = requiredCols.some(
|
|
284
|
+
c => c === "model_provider" || c === "source" || c === "first_user_message",
|
|
285
|
+
);
|
|
286
|
+
if (needsSessionMeta && !fields) return null;
|
|
287
|
+
|
|
288
|
+
return row;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function restoreThreadsFromManifest(
|
|
292
|
+
stateDbPath: string | null,
|
|
293
|
+
entries: CleanupManifestEntry[],
|
|
294
|
+
backup: SatelliteBackup | null,
|
|
295
|
+
busyTimeoutMs: number,
|
|
296
|
+
codexHome: string,
|
|
297
|
+
): { ok: true } | ReconcileErr {
|
|
298
|
+
const manifestThreadIds = entries
|
|
299
|
+
.map(e => e.threadId)
|
|
300
|
+
.filter((id): id is string => typeof id === "string");
|
|
301
|
+
const backupThreadIds = backup?.threadIds ?? [];
|
|
302
|
+
const needsThreads = manifestThreadIds.length > 0
|
|
303
|
+
|| backupThreadIds.length > 0
|
|
304
|
+
|| Boolean(backup?.threads?.length);
|
|
305
|
+
|
|
306
|
+
if (needsThreads && (!stateDbPath || !existsSync(stateDbPath))) {
|
|
307
|
+
return { ok: false, error: "db_reconcile_failed" };
|
|
308
|
+
}
|
|
309
|
+
if (!stateDbPath || !existsSync(stateDbPath)) {
|
|
310
|
+
return { ok: true };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const result = withWritableDb(stateDbPath, busyTimeoutMs, db => {
|
|
314
|
+
if (!tableExists(db, "threads")) throw new Error("missing_threads_table");
|
|
315
|
+
|
|
316
|
+
const requiredCols = requiredThreadColumnNames(db);
|
|
317
|
+
const allowedCols = tableColumnNames(db, "threads");
|
|
318
|
+
const snapshotThreads = backup?.threads && isSqlRowArray(backup.threads)
|
|
319
|
+
? backup.threads
|
|
320
|
+
: [];
|
|
321
|
+
const completeSnapshots = snapshotThreads.filter(row =>
|
|
322
|
+
threadSnapshotCoversRequiredColumns(row, requiredCols),
|
|
323
|
+
);
|
|
324
|
+
const coveredIds = new Set(
|
|
325
|
+
completeSnapshots
|
|
326
|
+
.map(r => r.id)
|
|
327
|
+
.filter((id): id is string => typeof id === "string"),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Legacy Phase-2 quarantine (no / incomplete satellite thread snapshots): reconstruct
|
|
331
|
+
// every required column from the restored rollout via the history-provider session path.
|
|
332
|
+
const toReconstruct = entries.filter(
|
|
333
|
+
e => typeof e.threadId === "string"
|
|
334
|
+
&& typeof e.rolloutPath === "string"
|
|
335
|
+
&& !coveredIds.has(e.threadId!),
|
|
336
|
+
);
|
|
337
|
+
const reconstructed: SqlRow[] = [];
|
|
338
|
+
for (const entry of toReconstruct) {
|
|
339
|
+
let abs: string | undefined;
|
|
340
|
+
try {
|
|
341
|
+
abs = absFromRel(codexHome, entry.rolloutPath!);
|
|
342
|
+
} catch {
|
|
343
|
+
abs = undefined;
|
|
344
|
+
}
|
|
345
|
+
// Legacy compressed-only quarantine: manifest rolloutPath is often the logical
|
|
346
|
+
// `.jsonl` name while the only restored physical file is `.jsonl.zst`.
|
|
347
|
+
if (!abs || !existsSync(abs)) {
|
|
348
|
+
for (const rel of entry.physicalRelPaths) {
|
|
349
|
+
try {
|
|
350
|
+
const candidate = absFromRel(codexHome, rel);
|
|
351
|
+
if (existsSync(candidate)) {
|
|
352
|
+
abs = candidate;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
/* try next physical path */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (!abs) throw new Error("missing_rollout_for_thread");
|
|
361
|
+
// Prefer a plain .jsonl sibling when present; otherwise readThreadFieldsFromRollout
|
|
362
|
+
// decompresses a lone .jsonl.zst in memory (bounded) for legacy quarantine restores.
|
|
363
|
+
if (abs.endsWith(ZST_SUFFIX)) {
|
|
364
|
+
const plain = abs.slice(0, -".zst".length);
|
|
365
|
+
if (existsSync(plain)) abs = plain;
|
|
366
|
+
}
|
|
367
|
+
const row = reconstructThreadRowFromRollout(entry, abs, allowedCols, requiredCols);
|
|
368
|
+
if (!row) throw new Error("thread_reconstruct_failed");
|
|
369
|
+
reconstructed.push(row);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (completeSnapshots.length > 0) {
|
|
373
|
+
insertRowsConflictIgnore(db, "threads", completeSnapshots);
|
|
374
|
+
}
|
|
375
|
+
if (reconstructed.length > 0) {
|
|
376
|
+
insertRowsConflictIgnore(db, "threads", reconstructed);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (backup?.dynamicTools && isSqlRowArray(backup.dynamicTools) && tableExists(db, "thread_dynamic_tools")) {
|
|
380
|
+
insertRowsConflictIgnore(db, "thread_dynamic_tools", backup.dynamicTools);
|
|
381
|
+
}
|
|
382
|
+
if (backup?.spawnEdges && isSqlRowArray(backup.spawnEdges) && tableExists(db, "thread_spawn_edges")) {
|
|
383
|
+
insertRowsConflictIgnore(db, "thread_spawn_edges", backup.spawnEdges);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
if (!result.ok) return result;
|
|
387
|
+
return { ok: true };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Test-only failure injection for restore atomicity regressions. */
|
|
391
|
+
export interface RestoreTestHooks {
|
|
392
|
+
/** After state threads/dependents commit, before satellite commits. */
|
|
393
|
+
failAfterStateCommit?: boolean;
|
|
394
|
+
/** After the first satellite DB commit (logs → memories → goals). */
|
|
395
|
+
failAfterFirstSatelliteCommit?: boolean;
|
|
396
|
+
/** When the leftover staged-rollout completeness gate runs. */
|
|
397
|
+
failAtLeftoverStageGate?: boolean;
|
|
398
|
+
/** Fail the initial restore-pending.json write (before any file moves). */
|
|
399
|
+
failInitialPendingWrite?: boolean;
|
|
400
|
+
/** Fail a later pending update after the temp is written but before rename. */
|
|
401
|
+
failPendingWriteBeforeRename?: boolean;
|
|
402
|
+
/** Crash immediately after file moves (marker already durable). */
|
|
403
|
+
failAfterFileMoves?: boolean;
|
|
404
|
+
/**
|
|
405
|
+
* After this many successful rollout moves in the current attempt, throw.
|
|
406
|
+
* Exercises mid-loop failure with some dests placed and others still staged.
|
|
407
|
+
*/
|
|
408
|
+
failAfterMoveCount?: number;
|
|
409
|
+
/** Fail renaming the completed stage to a non-listable tombstone dir. */
|
|
410
|
+
failStageTombstoneRename?: boolean;
|
|
411
|
+
/** After tombstone rename, skip best-effort tombstone delete (orphan is OK). */
|
|
412
|
+
failTombstoneDelete?: boolean;
|
|
413
|
+
/**
|
|
414
|
+
* Test-only: spin-wait this many ms after rollout file moves, before DB
|
|
415
|
+
* reconcile, so cleanup can race an in-flight restore.
|
|
416
|
+
*/
|
|
417
|
+
holdAfterFileMovesMs?: number;
|
|
418
|
+
/**
|
|
419
|
+
* Test-only: publish a ready file after rollout moves, then wait until the
|
|
420
|
+
* release file exists. This makes cross-thread race tests phase-driven.
|
|
421
|
+
*/
|
|
422
|
+
pauseAfterFileMoves?: { readyPath: string; releasePath: string };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Resume must not clear owed satellite work when the matching backup section is
|
|
427
|
+
* absent — fail closed per section instead.
|
|
428
|
+
*/
|
|
429
|
+
function failClosedSatelliteResume(
|
|
430
|
+
priorPending: RestorePendingState,
|
|
431
|
+
satelliteBackup: SatelliteBackup | null,
|
|
432
|
+
): RestoreErrorCode | null {
|
|
433
|
+
const owed = priorPending.pending;
|
|
434
|
+
if (!owed.logs && !owed.memories && !owed.goals) return null;
|
|
435
|
+
if (!satelliteBackup) return "db_reconcile_failed";
|
|
436
|
+
if (owed.logs && !satelliteBackup.logs) return "db_reconcile_failed";
|
|
437
|
+
if (owed.memories && !satelliteBackup.memories) return "db_reconcile_failed";
|
|
438
|
+
if (owed.goals && !satelliteBackup.goals) return "db_reconcile_failed";
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Successful restore finalization: rename the stage to a tombstone name that
|
|
444
|
+
* `listTrashEntries` ignores, then delete the tombstone best-effort. A failed
|
|
445
|
+
* rename leaves the original stage (and all evidence) intact for retry.
|
|
446
|
+
*/
|
|
447
|
+
function finalizeRestoredStage(
|
|
448
|
+
stageDir: string,
|
|
449
|
+
codexHome: string,
|
|
450
|
+
hooks?: Pick<RestoreTestHooks, "failStageTombstoneRename" | "failTombstoneDelete">,
|
|
451
|
+
): boolean {
|
|
452
|
+
const trashRoot = join(codexHome, TRASH_DIR);
|
|
453
|
+
const epoch = basename(stageDir);
|
|
454
|
+
const tombstoneName = `.tombstone-${epoch}-${randomUUID()}`;
|
|
455
|
+
const tombstonePath = join(trashRoot, tombstoneName);
|
|
456
|
+
try {
|
|
457
|
+
if (hooks?.failStageTombstoneRename) throw new Error("test_fail_stage_tombstone_rename");
|
|
458
|
+
renameSync(stageDir, tombstonePath);
|
|
459
|
+
} catch {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
if (!hooks?.failTombstoneDelete) {
|
|
463
|
+
try { rmSync(tombstonePath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
464
|
+
}
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Restore one quarantine entry: move JSONL back, re-insert threads (+ satellites
|
|
470
|
+
* when satellite-backup.json is present), then remove the trash directory.
|
|
471
|
+
*
|
|
472
|
+
* Late failures after files have moved never compensate metadata or restage.
|
|
473
|
+
* Instead they persist `restore-pending.json` (accepted dest paths + which
|
|
474
|
+
* state/logs/memories/goals sections still need work) atomically *before* any
|
|
475
|
+
* rollout move, then update it after each section so a retry can accept existing
|
|
476
|
+
* destinations and resume only missing metadata.
|
|
477
|
+
*/
|
|
478
|
+
export function restoreTrashEntry(
|
|
479
|
+
trashId: string,
|
|
480
|
+
options?: {
|
|
481
|
+
codexHome?: string;
|
|
482
|
+
busyTimeoutMs?: number;
|
|
483
|
+
_test?: RestoreTestHooks;
|
|
484
|
+
},
|
|
485
|
+
): RestoreResult {
|
|
486
|
+
const codexHome = options?.codexHome ?? resolveCodexHomeDir();
|
|
487
|
+
const busyTimeoutMs = options?.busyTimeoutMs ?? 100;
|
|
488
|
+
const hooks = options?._test;
|
|
489
|
+
|
|
490
|
+
const resolved = resolveTrashStageDir(trashId, codexHome);
|
|
491
|
+
if (!resolved.ok) {
|
|
492
|
+
return { ok: false, count: 0, bytes: 0, restoredPaths: [], error: resolved.error };
|
|
493
|
+
}
|
|
494
|
+
const { stageDir, id } = resolved;
|
|
495
|
+
|
|
496
|
+
let manifestRaw: string;
|
|
497
|
+
try {
|
|
498
|
+
manifestRaw = readFileSync(join(stageDir, "manifest.json"), "utf8");
|
|
499
|
+
} catch {
|
|
500
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
|
|
501
|
+
}
|
|
502
|
+
const manifest = parseTrashManifest(manifestRaw);
|
|
503
|
+
if (!manifest?.entries?.length) {
|
|
504
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const pendingRead = readRestorePending(stageDir);
|
|
508
|
+
if (pendingRead.status === "invalid") {
|
|
509
|
+
// Malformed marker means an incomplete restore may already have moved files;
|
|
510
|
+
// never treat it as a fresh restore.
|
|
511
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
|
|
512
|
+
}
|
|
513
|
+
const priorPending = pendingRead.status === "valid" ? pendingRead.state : null;
|
|
514
|
+
const acceptedDest = new Set(priorPending?.acceptedDestRels ?? []);
|
|
515
|
+
|
|
516
|
+
// Partial permanent purges may leave only a subset of physical files on disk —
|
|
517
|
+
// trim to survivors rather than failing the whole entry for a purged twin.
|
|
518
|
+
// Resume also treats already-restored accepted destinations as survivors.
|
|
519
|
+
const entries: CleanupManifestEntry[] = [];
|
|
520
|
+
for (const entry of manifest.entries) {
|
|
521
|
+
if (!entry.physicalRelPaths.every(isSafeArchivedPhysicalRel)) {
|
|
522
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
|
|
523
|
+
}
|
|
524
|
+
const surviving = entry.physicalRelPaths.filter(rel => {
|
|
525
|
+
if (existsSync(join(stageDir, basename(rel)))) return true;
|
|
526
|
+
if (!acceptedDest.has(rel)) return false;
|
|
527
|
+
try {
|
|
528
|
+
return existsSync(absFromRel(codexHome, rel));
|
|
529
|
+
} catch {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
if (surviving.length === 0) {
|
|
534
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
|
|
535
|
+
}
|
|
536
|
+
entries.push({ ...entry, physicalRelPaths: surviving });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const paths = discoverRuntimeDbPaths(codexHome);
|
|
540
|
+
const backupRead = readSatelliteBackupFile(stageDir);
|
|
541
|
+
if (backupRead.status === "invalid") {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
trashDir: id,
|
|
545
|
+
count: 0,
|
|
546
|
+
bytes: 0,
|
|
547
|
+
restoredPaths: [],
|
|
548
|
+
error: "db_reconcile_failed",
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let satelliteBackup: SatelliteBackup | null = null;
|
|
553
|
+
if (backupRead.status === "ok") {
|
|
554
|
+
const remapped = remapSatelliteBackupPaths(backupRead.backup, paths);
|
|
555
|
+
if (!remapped.ok) {
|
|
556
|
+
return {
|
|
557
|
+
ok: false,
|
|
558
|
+
trashDir: id,
|
|
559
|
+
count: 0,
|
|
560
|
+
bytes: 0,
|
|
561
|
+
restoredPaths: [],
|
|
562
|
+
error: "db_reconcile_failed",
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
satelliteBackup = remapped.backup;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (priorPending) {
|
|
569
|
+
const resumeErr = failClosedSatelliteResume(priorPending, satelliteBackup);
|
|
570
|
+
if (resumeErr) {
|
|
571
|
+
return {
|
|
572
|
+
ok: false,
|
|
573
|
+
trashDir: id,
|
|
574
|
+
count: 0,
|
|
575
|
+
bytes: 0,
|
|
576
|
+
restoredPaths: [],
|
|
577
|
+
error: resumeErr,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const pendingSections: RestorePendingSections = {
|
|
583
|
+
state: priorPending ? priorPending.pending.state : true,
|
|
584
|
+
logs: priorPending ? priorPending.pending.logs : Boolean(satelliteBackup?.logs),
|
|
585
|
+
memories: priorPending ? priorPending.pending.memories : Boolean(satelliteBackup?.memories),
|
|
586
|
+
goals: priorPending ? priorPending.pending.goals : Boolean(satelliteBackup?.goals),
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const needAnySatellite = pendingSections.logs || pendingSections.memories || pendingSections.goals;
|
|
590
|
+
if (pendingSections.state) {
|
|
591
|
+
const needsThreads = entries.some(e => typeof e.threadId === "string")
|
|
592
|
+
|| Boolean(satelliteBackup?.threadIds?.length)
|
|
593
|
+
|| Boolean(satelliteBackup?.threads?.length);
|
|
594
|
+
if (needsThreads && (!paths.state || !existsSync(paths.state))) {
|
|
595
|
+
return {
|
|
596
|
+
ok: false,
|
|
597
|
+
trashDir: id,
|
|
598
|
+
count: 0,
|
|
599
|
+
bytes: 0,
|
|
600
|
+
restoredPaths: [],
|
|
601
|
+
error: "db_reconcile_failed",
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const probe = probeStateDbWritable(codexHome, busyTimeoutMs);
|
|
605
|
+
if (!probe.ok) {
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
trashDir: id,
|
|
609
|
+
count: 0,
|
|
610
|
+
bytes: 0,
|
|
611
|
+
restoredPaths: [],
|
|
612
|
+
error: probe.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Acquire only the satellite locks still needed so a busy DB for an already-
|
|
618
|
+
// finished section cannot block resume. Locks happen before moves on a fresh
|
|
619
|
+
// attempt so failure stays retryable (nothing has left the stage yet).
|
|
620
|
+
let satelliteLocks: SatelliteWriteLocks | undefined;
|
|
621
|
+
if (needAnySatellite) {
|
|
622
|
+
try {
|
|
623
|
+
satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs, {
|
|
624
|
+
logs: pendingSections.logs,
|
|
625
|
+
memories: pendingSections.memories,
|
|
626
|
+
goals: pendingSections.goals,
|
|
627
|
+
});
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return {
|
|
630
|
+
ok: false,
|
|
631
|
+
trashDir: id,
|
|
632
|
+
count: 0,
|
|
633
|
+
bytes: 0,
|
|
634
|
+
restoredPaths: [],
|
|
635
|
+
error: mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const failBeforeMoves = (error: RestoreErrorCode): RestoreResult => {
|
|
641
|
+
if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
|
|
642
|
+
return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error };
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
// Plan renames: staged basename → original archived_sessions path.
|
|
646
|
+
// Resume accepts destinations already restored by this incomplete attempt.
|
|
647
|
+
const alreadyMoved: StagedFile[] = [];
|
|
648
|
+
const toMove: StagedFile[] = [];
|
|
649
|
+
for (const entry of entries) {
|
|
650
|
+
for (const rel of entry.physicalRelPaths) {
|
|
651
|
+
const base = basename(rel);
|
|
652
|
+
const from = join(stageDir, base);
|
|
653
|
+
let to: string;
|
|
654
|
+
try {
|
|
655
|
+
to = absFromRel(codexHome, rel);
|
|
656
|
+
} catch {
|
|
657
|
+
return failBeforeMoves("invalid_trash");
|
|
658
|
+
}
|
|
659
|
+
const fromExists = existsSync(from);
|
|
660
|
+
const toExists = existsSync(to);
|
|
661
|
+
if (toExists && acceptedDest.has(rel) && !fromExists) {
|
|
662
|
+
alreadyMoved.push({ from, to, relPath: rel });
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (toExists) {
|
|
666
|
+
return failBeforeMoves("dest_exists");
|
|
667
|
+
}
|
|
668
|
+
if (!fromExists) {
|
|
669
|
+
return failBeforeMoves("fs_failed");
|
|
670
|
+
}
|
|
671
|
+
toMove.push({ from, to, relPath: rel });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const planned = [...alreadyMoved, ...toMove];
|
|
676
|
+
const restoredPaths = [...new Set(entries.map(e => e.relPath))];
|
|
677
|
+
const bytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0);
|
|
678
|
+
const partialCounts = { count: restoredPaths.length, bytes, restoredPaths };
|
|
679
|
+
|
|
680
|
+
let pendingWriteCount = 0;
|
|
681
|
+
const persistPending = (): void => {
|
|
682
|
+
pendingWriteCount += 1;
|
|
683
|
+
const isInitial = pendingWriteCount === 1;
|
|
684
|
+
writeRestorePending(
|
|
685
|
+
stageDir,
|
|
686
|
+
{
|
|
687
|
+
version: 1,
|
|
688
|
+
filesRestored: true,
|
|
689
|
+
acceptedDestRels: planned.map(m => m.relPath),
|
|
690
|
+
pending: { ...pendingSections },
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
failWrite: Boolean(isInitial && hooks?.failInitialPendingWrite),
|
|
694
|
+
failBeforeRename: Boolean(!isInitial && hooks?.failPendingWriteBeforeRename),
|
|
695
|
+
},
|
|
696
|
+
);
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
// Durable resume marker before any rollout leaves the stage. Crash after a
|
|
700
|
+
// later move can still accept destinations from this marker.
|
|
701
|
+
try {
|
|
702
|
+
persistPending();
|
|
703
|
+
} catch {
|
|
704
|
+
return failBeforeMoves("fs_failed");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const newlyMoved: StagedFile[] = [];
|
|
708
|
+
try {
|
|
709
|
+
mkdirSync(join(codexHome, ARCHIVED_SESSIONS_DIR), { recursive: true });
|
|
710
|
+
for (const item of toMove) {
|
|
711
|
+
// Atomic no-replace (.trash ↔ archived_sessions). Mid-loop failure keeps
|
|
712
|
+
// already-placed dests and the durable planned acceptedDestRels marker.
|
|
713
|
+
renameNoReplace(item.from, item.to);
|
|
714
|
+
newlyMoved.push(item);
|
|
715
|
+
if (
|
|
716
|
+
hooks?.failAfterMoveCount !== undefined
|
|
717
|
+
&& newlyMoved.length >= hooks.failAfterMoveCount
|
|
718
|
+
) {
|
|
719
|
+
throw new Error("test_fail_after_move_count");
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
} catch (error) {
|
|
723
|
+
// Marker was written before any move. Never reverse successful renames or
|
|
724
|
+
// drop/narrow acceptedDestRels — resume must accept placed dests and finish
|
|
725
|
+
// the remaining staged files.
|
|
726
|
+
if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
|
|
727
|
+
const placed = [...alreadyMoved, ...newlyMoved];
|
|
728
|
+
const placedPhysical = new Set(placed.map(m => m.relPath));
|
|
729
|
+
const partialEntries = entries.filter(e =>
|
|
730
|
+
e.physicalRelPaths.every(rel => placedPhysical.has(rel)),
|
|
731
|
+
);
|
|
732
|
+
const midMoveRestored = [...new Set(partialEntries.map(e => e.relPath))];
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
trashDir: id,
|
|
736
|
+
count: midMoveRestored.length,
|
|
737
|
+
bytes: partialEntries.reduce((sum, e) => sum + (e.bytes || 0), 0),
|
|
738
|
+
restoredPaths: midMoveRestored,
|
|
739
|
+
error: isExistError(error) ? "dest_exists" : "fs_failed",
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const moved = [...alreadyMoved, ...newlyMoved];
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Never compensate DBs or restage files after moves. Keep restored files,
|
|
747
|
+
* persist which sections remain, and return accurate partial counts.
|
|
748
|
+
*/
|
|
749
|
+
const abortAfterMoves = (error: RestoreErrorCode): RestoreResult => {
|
|
750
|
+
if (satelliteLocks) {
|
|
751
|
+
rollbackAllSatelliteLocks(satelliteLocks);
|
|
752
|
+
satelliteLocks = undefined;
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
persistPending();
|
|
756
|
+
} catch {
|
|
757
|
+
/* best-effort — files already restored; prior atomic marker remains */
|
|
758
|
+
}
|
|
759
|
+
return { ok: false, trashDir: id, ...partialCounts, error };
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
if (hooks?.pauseAfterFileMoves) {
|
|
763
|
+
writeFileSync(hooks.pauseAfterFileMoves.readyPath, "ready\n");
|
|
764
|
+
while (!existsSync(hooks.pauseAfterFileMoves.releasePath)) Bun.sleepSync(10);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (hooks?.holdAfterFileMovesMs !== undefined) {
|
|
768
|
+
const holdMs = Math.max(0, Math.floor(hooks.holdAfterFileMovesMs));
|
|
769
|
+
if (holdMs > 0) {
|
|
770
|
+
const deadline = Date.now() + holdMs;
|
|
771
|
+
while (Date.now() < deadline) { /* test-only spin wait */ }
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (hooks?.failAfterFileMoves) {
|
|
776
|
+
return abortAfterMoves("fs_failed");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (pendingSections.state) {
|
|
780
|
+
const threadsRestored = restoreThreadsFromManifest(
|
|
781
|
+
paths.state,
|
|
782
|
+
entries,
|
|
783
|
+
satelliteBackup,
|
|
784
|
+
busyTimeoutMs,
|
|
785
|
+
codexHome,
|
|
786
|
+
);
|
|
787
|
+
if (!threadsRestored.ok) {
|
|
788
|
+
return abortAfterMoves(
|
|
789
|
+
threadsRestored.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
pendingSections.state = false;
|
|
793
|
+
try {
|
|
794
|
+
persistPending();
|
|
795
|
+
} catch {
|
|
796
|
+
return abortAfterMoves("fs_failed");
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (hooks?.failAfterStateCommit) {
|
|
801
|
+
return abortAfterMoves("db_reconcile_failed");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (satelliteLocks && satelliteBackup) {
|
|
805
|
+
const locks = satelliteLocks;
|
|
806
|
+
try {
|
|
807
|
+
// Commit one satellite DB at a time; uncommitted txs roll back via
|
|
808
|
+
// rollbackAllSatelliteLocks. Completed sections are cleared in pending.
|
|
809
|
+
if (pendingSections.logs && satelliteBackup.logs) {
|
|
810
|
+
if (!locks.logs) throw new Error("missing_logs_lock");
|
|
811
|
+
if (!tableExists(locks.logs.db, "logs")) throw new Error("missing_logs_table");
|
|
812
|
+
insertRowsConflictIgnore(locks.logs.db, "logs", satelliteBackup.logs.rows);
|
|
813
|
+
commitSatelliteLock(locks.logs);
|
|
814
|
+
locks.logs = undefined;
|
|
815
|
+
pendingSections.logs = false;
|
|
816
|
+
persistPending();
|
|
817
|
+
if (hooks?.failAfterFirstSatelliteCommit) {
|
|
818
|
+
throw new Error("test_fail_after_first_satellite");
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (pendingSections.memories && satelliteBackup.memories) {
|
|
822
|
+
if (!locks.memories) throw new Error("missing_memories_lock");
|
|
823
|
+
const mem = satelliteBackup.memories;
|
|
824
|
+
if (!tableExists(locks.memories.db, "stage1_outputs")) {
|
|
825
|
+
throw new Error("missing_stage1_outputs_table");
|
|
826
|
+
}
|
|
827
|
+
insertRowsConflictIgnore(locks.memories.db, "stage1_outputs", mem.stage1);
|
|
828
|
+
if (tableExists(locks.memories.db, "jobs")) {
|
|
829
|
+
insertRowsConflictIgnore(locks.memories.db, "jobs", mem.stage1Jobs);
|
|
830
|
+
if (mem.consolidateTouched) {
|
|
831
|
+
restoreConsolidateGlobalJob(
|
|
832
|
+
locks.memories.db,
|
|
833
|
+
mem.consolidateJob,
|
|
834
|
+
mem.consolidatePostImage,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
commitSatelliteLock(locks.memories);
|
|
839
|
+
locks.memories = undefined;
|
|
840
|
+
pendingSections.memories = false;
|
|
841
|
+
persistPending();
|
|
842
|
+
if (hooks?.failAfterFirstSatelliteCommit && !satelliteBackup.logs) {
|
|
843
|
+
throw new Error("test_fail_after_first_satellite");
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (pendingSections.goals && satelliteBackup.goals) {
|
|
847
|
+
if (!locks.goals) throw new Error("missing_goals_lock");
|
|
848
|
+
const g = satelliteBackup.goals;
|
|
849
|
+
if (!tableExists(locks.goals.db, "thread_goals")) {
|
|
850
|
+
throw new Error("missing_thread_goals_table");
|
|
851
|
+
}
|
|
852
|
+
insertRowsConflictIgnore(locks.goals.db, "thread_goals", g.goals);
|
|
853
|
+
if (tableExists(locks.goals.db, "thread_goal_continuation_deferrals")) {
|
|
854
|
+
insertRowsConflictIgnore(
|
|
855
|
+
locks.goals.db,
|
|
856
|
+
"thread_goal_continuation_deferrals",
|
|
857
|
+
g.deferrals,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
commitSatelliteLock(locks.goals);
|
|
861
|
+
locks.goals = undefined;
|
|
862
|
+
pendingSections.goals = false;
|
|
863
|
+
persistPending();
|
|
864
|
+
if (
|
|
865
|
+
hooks?.failAfterFirstSatelliteCommit
|
|
866
|
+
&& !satelliteBackup.logs
|
|
867
|
+
&& !satelliteBackup.memories
|
|
868
|
+
) {
|
|
869
|
+
throw new Error("test_fail_after_first_satellite");
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
// Close any locks acquired for DBs that had no pending work / backup rows.
|
|
873
|
+
rollbackAllSatelliteLocks(locks);
|
|
874
|
+
satelliteLocks = undefined;
|
|
875
|
+
} catch (error) {
|
|
876
|
+
return abortAfterMoves(
|
|
877
|
+
mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (
|
|
883
|
+
pendingSections.state
|
|
884
|
+
|| pendingSections.logs
|
|
885
|
+
|| pendingSections.memories
|
|
886
|
+
|| pendingSections.goals
|
|
887
|
+
) {
|
|
888
|
+
return abortAfterMoves("db_reconcile_failed");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Completeness gate: every planned file must sit at its restored path, and the stage
|
|
892
|
+
// must hold no leftover rollout files, before we destroy the quarantine evidence.
|
|
893
|
+
for (const item of moved) {
|
|
894
|
+
if (!existsSync(item.to) || existsSync(item.from)) {
|
|
895
|
+
return abortAfterMoves("fs_failed");
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
if (hooks?.failAtLeftoverStageGate) {
|
|
900
|
+
return abortAfterMoves("fs_failed");
|
|
901
|
+
}
|
|
902
|
+
for (const name of readdirSync(stageDir)) {
|
|
903
|
+
if (
|
|
904
|
+
name === "manifest.json"
|
|
905
|
+
|| name === SATELLITE_BACKUP_FILE
|
|
906
|
+
|| name === RESTORE_PENDING_FILE
|
|
907
|
+
) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (!isRolloutFileName(name)) continue;
|
|
911
|
+
return abortAfterMoves("fs_failed");
|
|
912
|
+
}
|
|
913
|
+
} catch {
|
|
914
|
+
return abortAfterMoves("fs_failed");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (!finalizeRestoredStage(stageDir, codexHome, hooks)) {
|
|
918
|
+
return {
|
|
919
|
+
ok: false,
|
|
920
|
+
trashDir: id,
|
|
921
|
+
...partialCounts,
|
|
922
|
+
error: "fs_failed",
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
removeEmptyTrashRoot(codexHome);
|
|
926
|
+
|
|
927
|
+
return {
|
|
928
|
+
ok: true,
|
|
929
|
+
trashDir: id,
|
|
930
|
+
...partialCounts,
|
|
931
|
+
};
|
|
932
|
+
}
|