@bitkyc08/opencodex 2.7.41 → 2.7.42
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 +4 -0
- package/gui/dist/assets/index-Bl_VBGoI.js +65 -0
- package/gui/dist/assets/index-DfVGuN88.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/base.ts +6 -0
- package/src/adapters/kiro-constants.ts +6 -2
- package/src/adapters/kiro-retry.ts +175 -10
- package/src/adapters/kiro.ts +172 -85
- package/src/adapters/mimo-free.ts +1 -0
- package/src/adapters/openai-chat.ts +30 -4
- package/src/adapters/openai-responses.ts +90 -12
- package/src/bridge.ts +91 -43
- package/src/claude/desktop-3p-paths.ts +84 -0
- package/src/claude/desktop-3p.ts +29 -2
- package/src/cli/access.ts +108 -0
- package/src/cli/account-auth.ts +223 -0
- package/src/cli/account.ts +9 -1
- package/src/cli/agent.ts +184 -0
- package/src/cli/combo.ts +119 -0
- package/src/cli/config-command.ts +145 -0
- package/src/cli/debug.ts +20 -8
- package/src/cli/doctor.ts +45 -8
- package/src/cli/help.ts +65 -13
- package/src/cli/index.ts +108 -7
- package/src/cli/integrations.ts +142 -0
- package/src/cli/models-runtime.ts +212 -0
- package/src/cli/models.ts +9 -10
- package/src/cli/observe.ts +117 -0
- package/src/cli/provider-runtime.ts +152 -0
- package/src/cli/provider.ts +23 -1
- package/src/cli/runtime-api.ts +325 -0
- package/src/cli/star-prompt.ts +3 -3
- package/src/cli/status.ts +17 -0
- package/src/cli/system-command.ts +112 -0
- package/src/codex/auth-api.ts +3 -2
- package/src/codex/catalog/aggregation.ts +113 -18
- package/src/codex/catalog/provider-fetch.ts +24 -13
- package/src/codex/catalog/sync.ts +20 -8
- package/src/codex/catalog.ts +2 -1
- package/src/codex/refresh.ts +10 -3
- package/src/codex/routing.ts +21 -32
- package/src/codex/sync.ts +17 -0
- package/src/config.ts +48 -0
- package/src/generated/jawcode-model-metadata.ts +2 -1
- package/src/grok/inject.ts +184 -4
- package/src/grok/status.ts +33 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/windows-elevation.ts +627 -0
- package/src/providers/openai-sidecar.ts +46 -2
- package/src/providers/registry.ts +52 -0
- package/src/server/auth-cors.ts +6 -0
- package/src/server/chat-completions.ts +6 -1
- package/src/server/claude-messages.ts +20 -1
- package/src/server/images.ts +14 -7
- package/src/server/management/agent-settings-routes.ts +10 -4
- package/src/server/management/combo-routes.ts +0 -1
- package/src/server/management/config-routes.ts +0 -1
- package/src/server/management/logs-usage-routes.ts +94 -0
- package/src/server/management/model-routes.ts +0 -1
- package/src/server/management/oauth-account-routes.ts +0 -1
- package/src/server/management/provider-routes.ts +0 -1
- package/src/server/management/shared.ts +0 -1
- package/src/server/management/system-routes.ts +27 -15
- package/src/server/management-api.ts +0 -1
- package/src/server/memory-watchdog.ts +54 -10
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +122 -2
- package/src/server/responses/core.ts +76 -13
- package/src/server/responses/passthrough-error.ts +38 -13
- package/src/server/startup-action-control.ts +266 -15
- package/src/service.ts +512 -3
- package/src/storage/cleanup.ts +1538 -0
- package/src/storage/scanner.ts +4 -1
- package/src/types.ts +16 -0
- package/src/update/job.ts +229 -25
- package/src/usage/log.ts +39 -0
- package/src/web-search/loop.ts +8 -1
- package/gui/dist/assets/index-B2J4t3te.css +0 -1
- package/gui/dist/assets/index-BmvM6wRb.js +0 -65
|
@@ -0,0 +1,1538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 archived-session cleanup (issue #42 Option A).
|
|
3
|
+
*
|
|
4
|
+
* Preview + execute for files under `archived_sessions/` only. Active `sessions/`
|
|
5
|
+
* are never touched. Default mode quarantines into `CODEX_HOME/.trash/<epoch>/`;
|
|
6
|
+
* permanent delete is opt-in.
|
|
7
|
+
*
|
|
8
|
+
* Execution is bound to a preview digest. All candidates are staged first; any FS
|
|
9
|
+
* Freezes the thread-ID set under the state write lock, persists a complete
|
|
10
|
+
* satellite-backup.json before any satellite delete commit, then mutates
|
|
11
|
+
* `logs_*` → `memories_*` → `goals_*` → `state_*`. Later failures restore
|
|
12
|
+
* satellite rows before staged files. Success never carries soft `dbWarning` /
|
|
13
|
+
* `failedPaths`.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
statSync,
|
|
23
|
+
unlinkSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
chmodSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
28
|
+
import { Database } from "bun:sqlite";
|
|
29
|
+
import { resolveCodexHomeDir } from "../codex/home";
|
|
30
|
+
|
|
31
|
+
export const ARCHIVED_SESSIONS_DIR = "archived_sessions";
|
|
32
|
+
export const TRASH_DIR = ".trash";
|
|
33
|
+
|
|
34
|
+
export type CleanupMode = "quarantine" | "permanent";
|
|
35
|
+
|
|
36
|
+
/** Mapped failure codes only — never embed absolute host paths. */
|
|
37
|
+
export type CleanupErrorCode =
|
|
38
|
+
| "invalid_mode"
|
|
39
|
+
| "invalid_digest"
|
|
40
|
+
| "stale_preview"
|
|
41
|
+
| "codex_busy"
|
|
42
|
+
| "fs_failed"
|
|
43
|
+
| "db_reconcile_failed"
|
|
44
|
+
| "referenced_history"
|
|
45
|
+
| "cleanup_failed";
|
|
46
|
+
|
|
47
|
+
export interface ArchivedCandidate {
|
|
48
|
+
/** Path relative to CODEX_HOME, forward-slash separated (logical `.jsonl` path). */
|
|
49
|
+
relPath: string;
|
|
50
|
+
absPath: string;
|
|
51
|
+
bytes: number;
|
|
52
|
+
mtimeMs: number;
|
|
53
|
+
/** All physical files for this logical rollout (`.jsonl` and/or `.jsonl.zst`). */
|
|
54
|
+
physicalRelPaths: string[];
|
|
55
|
+
/** Per-physical-file metadata bound into the preview digest. */
|
|
56
|
+
physicalFiles: Array<{ relPath: string; bytes: number; mtimeMs: number }>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CleanupPreview {
|
|
60
|
+
codexHome: string;
|
|
61
|
+
percent: number;
|
|
62
|
+
count: number;
|
|
63
|
+
bytes: number;
|
|
64
|
+
/** HMAC-free content digest binding execute to this exact candidate set. */
|
|
65
|
+
digest: string;
|
|
66
|
+
candidates: ArchivedCandidate[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CleanupManifestEntry {
|
|
70
|
+
relPath: string;
|
|
71
|
+
bytes: number;
|
|
72
|
+
mtimeMs: number;
|
|
73
|
+
physicalRelPaths: string[];
|
|
74
|
+
threadId?: string;
|
|
75
|
+
rolloutPath?: string;
|
|
76
|
+
archived?: number | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CleanupResult {
|
|
80
|
+
ok: boolean;
|
|
81
|
+
mode: CleanupMode;
|
|
82
|
+
percent: number;
|
|
83
|
+
count: number;
|
|
84
|
+
bytes: number;
|
|
85
|
+
trashDir?: string;
|
|
86
|
+
error?: CleanupErrorCode;
|
|
87
|
+
removedPaths: string[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const STATE_DB_FILE = /^state_(\d+)\.sqlite$/;
|
|
91
|
+
const LOGS_DB_FILE = /^logs_(\d+)\.sqlite$/;
|
|
92
|
+
const GOALS_DB_FILE = /^goals_(\d+)\.sqlite$/;
|
|
93
|
+
const MEMORIES_DB_FILE = /^memories_(\d+)\.sqlite$/;
|
|
94
|
+
const JSONL_SUFFIX = ".jsonl";
|
|
95
|
+
const ZST_SUFFIX = ".jsonl.zst";
|
|
96
|
+
const JOB_KIND_MEMORY_STAGE1 = "memory_stage1";
|
|
97
|
+
const JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL = "memory_consolidate_global";
|
|
98
|
+
const MEMORY_CONSOLIDATION_JOB_KEY = "global";
|
|
99
|
+
const DEFAULT_RETRY_REMAINING = 3;
|
|
100
|
+
/** Chunk size for `IN (...)` binds; spawn-edge checks bind each id twice. */
|
|
101
|
+
const SQLITE_ID_CHUNK = 200;
|
|
102
|
+
|
|
103
|
+
function chmodPrivatePath(path: string, mode: number): void {
|
|
104
|
+
try { chmodSync(path, mode); } catch { /* best-effort (e.g. Windows ACLs) */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function writePrivateFile(path: string, content: string): void {
|
|
108
|
+
writeFileSync(path, content, "utf8");
|
|
109
|
+
chmodPrivatePath(path, 0o600);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function chunkIds(ids: string[], chunkSize: number): string[][] {
|
|
113
|
+
const chunks: string[][] = [];
|
|
114
|
+
for (let i = 0; i < ids.length; i += chunkSize) chunks.push(ids.slice(i, i + chunkSize));
|
|
115
|
+
return chunks;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Create `.trash/<epoch>` exclusively; suffix on collision. */
|
|
119
|
+
function createExclusiveStageDir(codexHome: string, epoch: number): string {
|
|
120
|
+
const trashRoot = join(codexHome, TRASH_DIR);
|
|
121
|
+
mkdirSync(trashRoot, { recursive: true });
|
|
122
|
+
chmodPrivatePath(trashRoot, 0o700);
|
|
123
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
124
|
+
const name = attempt === 0 ? String(epoch) : `${epoch}-${attempt}`;
|
|
125
|
+
const stageDir = join(trashRoot, name);
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(stageDir);
|
|
128
|
+
chmodPrivatePath(stageDir, 0o700);
|
|
129
|
+
return stageDir;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
throw new Error("stage_dir_collision");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isSafeArchiveFileName(name: string): boolean {
|
|
139
|
+
if (name.includes("/") || name.includes("\\") || name.includes("..")) return false;
|
|
140
|
+
return isRolloutFileName(name);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function clampPercent(percent: unknown): number {
|
|
144
|
+
if (typeof percent !== "number" || !Number.isFinite(percent)) return 0;
|
|
145
|
+
return Math.max(0, Math.min(100, Math.floor(percent)));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toForwardSlash(p: string): string {
|
|
149
|
+
return p.split(sep).join("/");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Strip trailing `.zst` so plain + compressed share one logical rollout id. */
|
|
153
|
+
export function logicalRolloutRelPath(relPath: string): string {
|
|
154
|
+
const normalized = toForwardSlash(relPath);
|
|
155
|
+
return normalized.endsWith(ZST_SUFFIX)
|
|
156
|
+
? normalized.slice(0, -".zst".length)
|
|
157
|
+
: normalized;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isRolloutFileName(name: string): boolean {
|
|
161
|
+
return name.endsWith(ZST_SUFFIX) || name.endsWith(JSONL_SUFFIX);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Newest `prefix_N.sqlite` under CODEX_HOME, or null when absent. */
|
|
165
|
+
function newestVersionedDb(codexHome: string, pattern: RegExp): string | null {
|
|
166
|
+
let best: string | null = null;
|
|
167
|
+
let bestVersion = -1;
|
|
168
|
+
let names: string[] = [];
|
|
169
|
+
try {
|
|
170
|
+
names = readdirSync(codexHome);
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
for (const name of names) {
|
|
175
|
+
const match = name.match(pattern);
|
|
176
|
+
if (!match) continue;
|
|
177
|
+
const version = Number(match[1]);
|
|
178
|
+
if (version > bestVersion) {
|
|
179
|
+
bestVersion = version;
|
|
180
|
+
best = name;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return best ? join(codexHome, best) : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function newestStateDb(codexHome: string): string | null {
|
|
187
|
+
return newestVersionedDb(codexHome, STATE_DB_FILE);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface RuntimeDbPaths {
|
|
191
|
+
state: string | null;
|
|
192
|
+
logs: string | null;
|
|
193
|
+
goals: string | null;
|
|
194
|
+
memories: string | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function discoverRuntimeDbPaths(codexHome: string): RuntimeDbPaths {
|
|
198
|
+
return {
|
|
199
|
+
state: newestVersionedDb(codexHome, STATE_DB_FILE),
|
|
200
|
+
logs: newestVersionedDb(codexHome, LOGS_DB_FILE),
|
|
201
|
+
goals: newestVersionedDb(codexHome, GOALS_DB_FILE),
|
|
202
|
+
memories: newestVersionedDb(codexHome, MEMORIES_DB_FILE),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Normalize a DB `rollout_path` to a CODEX_HOME-relative forward-slash path, then
|
|
208
|
+
* to the logical `.jsonl` form. Returns null when the path is not under
|
|
209
|
+
* `archived_sessions/` (rejects active `sessions/` and foreign paths).
|
|
210
|
+
*/
|
|
211
|
+
export function normalizeArchivedRolloutPath(rolloutPath: string, codexHome: string): string | null {
|
|
212
|
+
const raw = toForwardSlash(rolloutPath.trim());
|
|
213
|
+
if (!raw) return null;
|
|
214
|
+
let relativePath = raw;
|
|
215
|
+
try {
|
|
216
|
+
// Prefer Node's absolute-path detection. Do not treat a colon anywhere in the
|
|
217
|
+
// filename (Codex ISO timestamps) as an absolute Windows path.
|
|
218
|
+
const looksAbsolute = isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw);
|
|
219
|
+
const abs = looksAbsolute ? resolve(raw) : resolve(codexHome, raw);
|
|
220
|
+
const homeAbs = resolve(codexHome);
|
|
221
|
+
const rel = toForwardSlash(relative(homeAbs, abs));
|
|
222
|
+
if (rel.startsWith("..") || rel === "") return null;
|
|
223
|
+
relativePath = rel;
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const logical = logicalRolloutRelPath(relativePath);
|
|
228
|
+
if (!logical.startsWith(`${ARCHIVED_SESSIONS_DIR}/`)) return null;
|
|
229
|
+
if (!logical.endsWith(JSONL_SUFFIX)) return null;
|
|
230
|
+
// Reject path tricks: only a single file under archived_sessions/
|
|
231
|
+
const rest = logical.slice(ARCHIVED_SESSIONS_DIR.length + 1);
|
|
232
|
+
if (!rest || rest.includes("/") || rest.includes("..")) return null;
|
|
233
|
+
return logical;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Content digest of the exact previewed candidate set (paths + size + mtime). */
|
|
237
|
+
export function computePreviewDigest(candidates: ArchivedCandidate[], percent: number): string {
|
|
238
|
+
const lines = candidates
|
|
239
|
+
.map(c => {
|
|
240
|
+
const physical = [...c.physicalFiles]
|
|
241
|
+
.sort((a, b) => a.relPath.localeCompare(b.relPath))
|
|
242
|
+
.map(f => `${f.relPath}|${f.bytes}|${Math.trunc(f.mtimeMs)}`)
|
|
243
|
+
.join(",");
|
|
244
|
+
return `${c.relPath}|${c.bytes}|${Math.trunc(c.mtimeMs)}|${physical}`;
|
|
245
|
+
})
|
|
246
|
+
.sort();
|
|
247
|
+
return createHash("sha256")
|
|
248
|
+
.update(`${clampPercent(percent)}\n${lines.join("\n")}`)
|
|
249
|
+
.digest("hex");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** List archived rollout groups oldest-first. Never walks `sessions/`. */
|
|
253
|
+
export function listArchivedCandidates(codexHome: string): ArchivedCandidate[] {
|
|
254
|
+
const dir = join(codexHome, ARCHIVED_SESSIONS_DIR);
|
|
255
|
+
let names: string[] = [];
|
|
256
|
+
try {
|
|
257
|
+
names = readdirSync(dir);
|
|
258
|
+
} catch {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type Acc = {
|
|
263
|
+
logicalRel: string;
|
|
264
|
+
files: Array<{ name: string; absPath: string; relPath: string; bytes: number; mtimeMs: number }>;
|
|
265
|
+
};
|
|
266
|
+
const groups = new Map<string, Acc>();
|
|
267
|
+
|
|
268
|
+
for (const name of names) {
|
|
269
|
+
if (!isSafeArchiveFileName(name)) continue;
|
|
270
|
+
const absPath = join(dir, name);
|
|
271
|
+
try {
|
|
272
|
+
const st = statSync(absPath);
|
|
273
|
+
if (!st.isFile()) continue;
|
|
274
|
+
const relPath = `${ARCHIVED_SESSIONS_DIR}/${name}`;
|
|
275
|
+
const logicalRel = logicalRolloutRelPath(relPath);
|
|
276
|
+
let acc = groups.get(logicalRel);
|
|
277
|
+
if (!acc) {
|
|
278
|
+
acc = { logicalRel, files: [] };
|
|
279
|
+
groups.set(logicalRel, acc);
|
|
280
|
+
}
|
|
281
|
+
acc.files.push({
|
|
282
|
+
name,
|
|
283
|
+
absPath,
|
|
284
|
+
relPath,
|
|
285
|
+
bytes: st.size,
|
|
286
|
+
mtimeMs: st.mtimeMs,
|
|
287
|
+
});
|
|
288
|
+
} catch {
|
|
289
|
+
/* vanished mid-scan */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const out: ArchivedCandidate[] = [];
|
|
294
|
+
for (const acc of groups.values()) {
|
|
295
|
+
// Prefer the plain `.jsonl` path as the public/logical identity when both exist.
|
|
296
|
+
acc.files.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
297
|
+
const primary =
|
|
298
|
+
acc.files.find(f => f.relPath === acc.logicalRel) ??
|
|
299
|
+
acc.files[0]!;
|
|
300
|
+
out.push({
|
|
301
|
+
relPath: acc.logicalRel,
|
|
302
|
+
absPath: primary.absPath,
|
|
303
|
+
bytes: acc.files.reduce((sum, f) => sum + f.bytes, 0),
|
|
304
|
+
mtimeMs: Math.min(...acc.files.map(f => f.mtimeMs)),
|
|
305
|
+
physicalRelPaths: acc.files.map(f => f.relPath),
|
|
306
|
+
physicalFiles: acc.files.map(f => ({ relPath: f.relPath, bytes: f.bytes, mtimeMs: f.mtimeMs })),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
out.sort((a, b) => a.mtimeMs - b.mtimeMs || a.relPath.localeCompare(b.relPath));
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function selectOldestPercent(candidates: ArchivedCandidate[], percent: number): ArchivedCandidate[] {
|
|
314
|
+
const pct = clampPercent(percent);
|
|
315
|
+
if (pct <= 0 || candidates.length === 0) return [];
|
|
316
|
+
if (pct >= 100) return [...candidates];
|
|
317
|
+
const n = Math.max(1, Math.floor((candidates.length * pct) / 100));
|
|
318
|
+
return candidates.slice(0, n);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function previewArchivedCleanup(
|
|
322
|
+
percent: number,
|
|
323
|
+
codexHome: string = resolveCodexHomeDir(),
|
|
324
|
+
): CleanupPreview {
|
|
325
|
+
const all = listArchivedCandidates(codexHome);
|
|
326
|
+
const selected = selectOldestPercent(all, percent);
|
|
327
|
+
const pct = clampPercent(percent);
|
|
328
|
+
return {
|
|
329
|
+
codexHome,
|
|
330
|
+
percent: pct,
|
|
331
|
+
count: selected.length,
|
|
332
|
+
bytes: selected.reduce((sum, c) => sum + c.bytes, 0),
|
|
333
|
+
digest: computePreviewDigest(selected, pct),
|
|
334
|
+
candidates: selected,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function openDbWritable(dbPath: string, busyTimeoutMs = 100): Database {
|
|
339
|
+
const db = new Database(dbPath);
|
|
340
|
+
try {
|
|
341
|
+
db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
342
|
+
} catch {
|
|
343
|
+
/* older sqlite */
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
347
|
+
} catch {
|
|
348
|
+
/* ignore */
|
|
349
|
+
}
|
|
350
|
+
return db;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function isBusyError(error: unknown): boolean {
|
|
354
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
355
|
+
const code = (error as { code?: string })?.code ?? "";
|
|
356
|
+
return (
|
|
357
|
+
code === "SQLITE_BUSY" ||
|
|
358
|
+
code === "SQLITE_LOCKED" ||
|
|
359
|
+
/SQLITE_BUSY|SQLITE_LOCKED|database is locked|database table is locked/i.test(msg)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function mapDbError(error: unknown): CleanupErrorCode {
|
|
364
|
+
if (isBusyError(error)) return "codex_busy";
|
|
365
|
+
return "db_reconcile_failed";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Probe a single DB with BEGIN IMMEDIATE; missing path is a no-op success. */
|
|
369
|
+
function probeDbWritable(
|
|
370
|
+
path: string | null,
|
|
371
|
+
busyTimeoutMs: number,
|
|
372
|
+
): { ok: true } | { ok: false; error: CleanupErrorCode } {
|
|
373
|
+
if (!path || !existsSync(path)) return { ok: true };
|
|
374
|
+
let db: Database | undefined;
|
|
375
|
+
try {
|
|
376
|
+
db = openDbWritable(path, busyTimeoutMs);
|
|
377
|
+
db.exec("BEGIN IMMEDIATE");
|
|
378
|
+
db.exec("ROLLBACK");
|
|
379
|
+
return { ok: true };
|
|
380
|
+
} catch (error) {
|
|
381
|
+
if (isBusyError(error)) return { ok: false, error: "codex_busy" };
|
|
382
|
+
return { ok: false, error: "db_reconcile_failed" };
|
|
383
|
+
} finally {
|
|
384
|
+
try { db?.close(); } catch { /* */ }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* True when every present Codex runtime DB can be written (BEGIN IMMEDIATE).
|
|
390
|
+
* Busy / corrupt stores abort cleanup before any filesystem mutation.
|
|
391
|
+
*/
|
|
392
|
+
export function probeStateDbWritable(
|
|
393
|
+
codexHome: string,
|
|
394
|
+
busyTimeoutMs = 100,
|
|
395
|
+
): { ok: true; path: string } | { ok: false; error: CleanupErrorCode } {
|
|
396
|
+
const paths = discoverRuntimeDbPaths(codexHome);
|
|
397
|
+
for (const path of [paths.state, paths.logs, paths.goals, paths.memories]) {
|
|
398
|
+
const probed = probeDbWritable(path, busyTimeoutMs);
|
|
399
|
+
if (!probed.ok) return probed;
|
|
400
|
+
}
|
|
401
|
+
return { ok: true, path: paths.state ?? "" };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
interface ThreadSnapshot {
|
|
405
|
+
id: string;
|
|
406
|
+
rollout_path: string;
|
|
407
|
+
archived: number | null;
|
|
408
|
+
history_mode?: string | null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Load archived threads matching the candidate set.
|
|
413
|
+
* Optional columns are detected via PRAGMA; missing `threads` / query failures throw
|
|
414
|
+
* so callers map to `db_reconcile_failed` / `codex_busy` instead of treating them as empty.
|
|
415
|
+
*/
|
|
416
|
+
function loadMatchingThreads(db: Database, candidates: ArchivedCandidate[], codexHome: string): ThreadSnapshot[] {
|
|
417
|
+
if (!tableExists(db, "threads")) {
|
|
418
|
+
throw new Error("missing_threads_table");
|
|
419
|
+
}
|
|
420
|
+
const logicalSet = new Set(candidates.map(c => c.relPath));
|
|
421
|
+
const hasArchived = columnExists(db, "threads", "archived");
|
|
422
|
+
const hasHistoryMode = columnExists(db, "threads", "history_mode");
|
|
423
|
+
const selectCols = ["id", "rollout_path"];
|
|
424
|
+
if (hasArchived) selectCols.push("archived");
|
|
425
|
+
if (hasHistoryMode) selectCols.push("history_mode");
|
|
426
|
+
const rows = db.query<
|
|
427
|
+
{ id: string; rollout_path: string; archived?: number | null; history_mode?: string | null },
|
|
428
|
+
[]
|
|
429
|
+
>(`SELECT ${selectCols.join(", ")} FROM threads`).all();
|
|
430
|
+
|
|
431
|
+
return rows
|
|
432
|
+
.filter(row => {
|
|
433
|
+
// When the archived column is present, only archived=1 rows may be deleted.
|
|
434
|
+
if (hasArchived && Number(row.archived ?? 0) !== 1) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
const normalized = normalizeArchivedRolloutPath(row.rollout_path, codexHome);
|
|
438
|
+
return normalized !== null && logicalSet.has(normalized);
|
|
439
|
+
})
|
|
440
|
+
.map(row => ({
|
|
441
|
+
id: row.id,
|
|
442
|
+
rollout_path: row.rollout_path,
|
|
443
|
+
archived: hasArchived ? (row.archived ?? null) : null,
|
|
444
|
+
history_mode: hasHistoryMode ? (row.history_mode ?? null) : null,
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* True when any matched thread is still linked to a thread outside the delete set
|
|
450
|
+
* (spawn edges) or uses paginated history that other live threads may depend on via fork.
|
|
451
|
+
* Throws real DB errors (busy/corruption) so callers can refuse cleanup.
|
|
452
|
+
*/
|
|
453
|
+
function findReferencedHistory(
|
|
454
|
+
db: Database,
|
|
455
|
+
threads: ThreadSnapshot[],
|
|
456
|
+
): boolean {
|
|
457
|
+
if (threads.length === 0) return false;
|
|
458
|
+
const ids = threads.map(t => t.id);
|
|
459
|
+
const idSet = new Set(ids);
|
|
460
|
+
|
|
461
|
+
// Paginated history keeps durable projections tied to the rollout — refuse cleanup.
|
|
462
|
+
if (threads.some(t => (t.history_mode ?? "").toLowerCase() === "paginated")) {
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Spawn edges that cross the delete boundary keep history reachable.
|
|
467
|
+
if (tableExists(db, "thread_spawn_edges")) {
|
|
468
|
+
for (const chunk of chunkIds(ids, SQLITE_ID_CHUNK)) {
|
|
469
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
470
|
+
const edges = db.query<{ parent_thread_id: string; child_thread_id: string }, string[]>(
|
|
471
|
+
`SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges
|
|
472
|
+
WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
|
|
473
|
+
).all(...chunk, ...chunk);
|
|
474
|
+
for (const edge of edges) {
|
|
475
|
+
if (!idSet.has(edge.parent_thread_id) || !idSet.has(edge.child_thread_id)) {
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Other threads that list one of ours as forked_from / parent (when columns exist).
|
|
483
|
+
for (const column of ["forked_from_id", "parent_thread_id", "source_thread_id"] as const) {
|
|
484
|
+
if (!columnExists(db, "threads", column)) continue;
|
|
485
|
+
for (const chunk of chunkIds(ids, SQLITE_ID_CHUNK * 2)) {
|
|
486
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
487
|
+
const rows = db.query<{ id: string }, string[]>(
|
|
488
|
+
`SELECT id FROM threads WHERE ${column} IN (${placeholders})`,
|
|
489
|
+
).all(...chunk);
|
|
490
|
+
if (rows.some(r => !idSet.has(r.id))) return true;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function tableExists(db: Database, name: string): boolean {
|
|
498
|
+
const row = db.query<{ name: string }, [string]>(
|
|
499
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
|
|
500
|
+
).get(name);
|
|
501
|
+
return Boolean(row);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function columnExists(db: Database, table: string, column: string): boolean {
|
|
505
|
+
if (!tableExists(db, table)) return false;
|
|
506
|
+
// `table` is only ever a hardcoded identifier already verified via sqlite_master.
|
|
507
|
+
const rows = db.query<{ name: string }, []>(
|
|
508
|
+
`PRAGMA table_info("${table.replaceAll('"', '""')}")`,
|
|
509
|
+
).all();
|
|
510
|
+
return rows.some(r => r.name === column);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function deleteThreadsAndDependents(db: Database, threadIds: string[]): void {
|
|
514
|
+
if (threadIds.length === 0) return;
|
|
515
|
+
|
|
516
|
+
// Upstream deletes dynamic tools before spawn edges before threads.
|
|
517
|
+
if (tableExists(db, "thread_dynamic_tools")) {
|
|
518
|
+
for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
|
|
519
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
520
|
+
db.run(`DELETE FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`, chunk);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (tableExists(db, "thread_spawn_edges")) {
|
|
525
|
+
for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK)) {
|
|
526
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
527
|
+
db.run(
|
|
528
|
+
`DELETE FROM thread_spawn_edges WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
|
|
529
|
+
[...chunk, ...chunk],
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
|
|
535
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
536
|
+
db.run(`DELETE FROM threads WHERE id IN (${placeholders})`, chunk);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
interface ReconcileOk {
|
|
541
|
+
ok: true;
|
|
542
|
+
threads: ThreadSnapshot[];
|
|
543
|
+
}
|
|
544
|
+
interface ReconcileErr {
|
|
545
|
+
ok: false;
|
|
546
|
+
error: CleanupErrorCode;
|
|
547
|
+
/** True when satellite rows were mutated and could not all be restored. */
|
|
548
|
+
satelliteRestoreFailed?: boolean;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
type SqlRow = Record<string, string | number | bigint | null | Uint8Array>;
|
|
552
|
+
|
|
553
|
+
interface SatelliteBackup {
|
|
554
|
+
threadIds: string[];
|
|
555
|
+
logs?: { path: string; rows: SqlRow[] };
|
|
556
|
+
memories?: {
|
|
557
|
+
path: string;
|
|
558
|
+
stage1: SqlRow[];
|
|
559
|
+
stage1Jobs: SqlRow[];
|
|
560
|
+
consolidateJob: SqlRow | null;
|
|
561
|
+
consolidateTouched: boolean;
|
|
562
|
+
/** Row image after deleteMemoriesInTx; set before memories commit (in-memory only). */
|
|
563
|
+
consolidatePostImage?: SqlRow | null;
|
|
564
|
+
};
|
|
565
|
+
goals?: {
|
|
566
|
+
path: string;
|
|
567
|
+
goals: SqlRow[];
|
|
568
|
+
deferrals: SqlRow[];
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
interface ReconcileTestHooks {
|
|
573
|
+
failAfterLogsMutation?: boolean;
|
|
574
|
+
failAfterMemoriesMutation?: boolean;
|
|
575
|
+
failAfterGoalsMutation?: boolean;
|
|
576
|
+
failBeforeStateCommit?: boolean;
|
|
577
|
+
failSatelliteRestore?: boolean;
|
|
578
|
+
failSatelliteBackupWrite?: boolean;
|
|
579
|
+
/** Runs after satellite deletes are committed, before state thread deletion. */
|
|
580
|
+
afterSatelliteMutations?: () => void;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const SATELLITE_BACKUP_FILE = "satellite-backup.json";
|
|
584
|
+
|
|
585
|
+
function quoteIdent(name: string): string {
|
|
586
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function selectRows(db: Database, sql: string, params: Array<string | number>): SqlRow[] {
|
|
590
|
+
return db.query<SqlRow, Array<string | number>>(sql).all(...params) as SqlRow[];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function insertRowsConflictIgnore(db: Database, table: string, rows: SqlRow[]): void {
|
|
594
|
+
for (const row of rows) {
|
|
595
|
+
const cols = Object.keys(row);
|
|
596
|
+
if (cols.length === 0) continue;
|
|
597
|
+
db.run(
|
|
598
|
+
`INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`,
|
|
599
|
+
cols.map(c => row[c] as string | number | bigint | null | Uint8Array),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function updateRowFromSnapshot(
|
|
605
|
+
db: Database,
|
|
606
|
+
table: string,
|
|
607
|
+
row: SqlRow,
|
|
608
|
+
pkCols: string[],
|
|
609
|
+
): void {
|
|
610
|
+
const cols = Object.keys(row).filter(c => !pkCols.includes(c));
|
|
611
|
+
if (cols.length === 0) return;
|
|
612
|
+
const sets = cols.map(c => `${quoteIdent(c)} = ?`).join(", ");
|
|
613
|
+
const where = pkCols.map(c => `${quoteIdent(c)} = ?`).join(" AND ");
|
|
614
|
+
db.run(
|
|
615
|
+
`UPDATE ${quoteIdent(table)} SET ${sets} WHERE ${where}`,
|
|
616
|
+
[
|
|
617
|
+
...cols.map(c => row[c] as string | number | bigint | null | Uint8Array),
|
|
618
|
+
...pkCols.map(c => row[c] as string | number | bigint | null | Uint8Array),
|
|
619
|
+
],
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function normalizeSqlValue(
|
|
624
|
+
v: string | number | bigint | null | Uint8Array | undefined,
|
|
625
|
+
): string {
|
|
626
|
+
if (v === null || v === undefined) return "";
|
|
627
|
+
if (typeof v === "bigint") return v.toString();
|
|
628
|
+
if (v instanceof Uint8Array) return Buffer.from(v).toString("base64");
|
|
629
|
+
return String(v);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function sqlRowEqual(a: SqlRow, b: SqlRow): boolean {
|
|
633
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
634
|
+
for (const key of keys) {
|
|
635
|
+
if (normalizeSqlValue(a[key]) !== normalizeSqlValue(b[key])) return false;
|
|
636
|
+
}
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function readConsolidateGlobalJob(db: Database): SqlRow | null {
|
|
641
|
+
if (!tableExists(db, "jobs")) return null;
|
|
642
|
+
return db.query<SqlRow, [string, string]>(
|
|
643
|
+
"SELECT * FROM jobs WHERE kind = ? AND job_key = ?",
|
|
644
|
+
).get(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL, MEMORY_CONSOLIDATION_JOB_KEY) as SqlRow | null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Revert delete-time enqueue only when the row still matches cleanup's post-delete image. */
|
|
648
|
+
function restoreConsolidateGlobalJob(
|
|
649
|
+
db: Database,
|
|
650
|
+
snapshot: SqlRow | null,
|
|
651
|
+
postImage: SqlRow | null | undefined,
|
|
652
|
+
): void {
|
|
653
|
+
if (!postImage) return;
|
|
654
|
+
const current = readConsolidateGlobalJob(db);
|
|
655
|
+
if (!current) {
|
|
656
|
+
if (snapshot) insertRowsConflictIgnore(db, "jobs", [snapshot]);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (!sqlRowEqual(current, postImage)) return;
|
|
660
|
+
if (snapshot) {
|
|
661
|
+
updateRowFromSnapshot(db, "jobs", snapshot, ["kind", "job_key"]);
|
|
662
|
+
} else {
|
|
663
|
+
db.run(
|
|
664
|
+
"DELETE FROM jobs WHERE kind = ? AND job_key = ?",
|
|
665
|
+
[JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL, MEMORY_CONSOLIDATION_JOB_KEY],
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function writeSatelliteBackup(stageDir: string, backup: SatelliteBackup): void {
|
|
671
|
+
writePrivateFile(join(stageDir, SATELLITE_BACKUP_FILE), JSON.stringify(backup));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function clearSatelliteBackup(stageDir: string): void {
|
|
675
|
+
try { unlinkSync(join(stageDir, SATELLITE_BACKUP_FILE)); } catch { /* */ }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
interface SatelliteWriteLock {
|
|
679
|
+
path: string;
|
|
680
|
+
db: Database;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
interface SatelliteWriteLocks {
|
|
684
|
+
logs?: SatelliteWriteLock;
|
|
685
|
+
memories?: SatelliteWriteLock;
|
|
686
|
+
goals?: SatelliteWriteLock;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Deterministic order: logs → memories → goals. Each present DB gets BEGIN IMMEDIATE. */
|
|
690
|
+
function beginSatelliteWriteLocks(
|
|
691
|
+
paths: RuntimeDbPaths,
|
|
692
|
+
busyTimeoutMs: number,
|
|
693
|
+
): SatelliteWriteLocks {
|
|
694
|
+
const locks: SatelliteWriteLocks = {};
|
|
695
|
+
const order: Array<{ key: "logs" | "memories" | "goals"; path: string | null }> = [
|
|
696
|
+
{ key: "logs", path: paths.logs },
|
|
697
|
+
{ key: "memories", path: paths.memories },
|
|
698
|
+
{ key: "goals", path: paths.goals },
|
|
699
|
+
];
|
|
700
|
+
try {
|
|
701
|
+
for (const { key, path } of order) {
|
|
702
|
+
if (!path || !existsSync(path)) continue;
|
|
703
|
+
const db = openDbWritable(path, busyTimeoutMs);
|
|
704
|
+
try {
|
|
705
|
+
db.exec("BEGIN IMMEDIATE");
|
|
706
|
+
locks[key] = { path, db };
|
|
707
|
+
} catch (error) {
|
|
708
|
+
try { db.close(); } catch { /* */ }
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return locks;
|
|
713
|
+
} catch (error) {
|
|
714
|
+
rollbackAllSatelliteLocks(locks);
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function rollbackSatelliteLock(lock: SatelliteWriteLock | undefined): void {
|
|
720
|
+
if (!lock) return;
|
|
721
|
+
try { lock.db.exec("ROLLBACK"); } catch { /* */ }
|
|
722
|
+
try { lock.db.close(); } catch { /* */ }
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function rollbackAllSatelliteLocks(locks: SatelliteWriteLocks): void {
|
|
726
|
+
rollbackSatelliteLock(locks.logs);
|
|
727
|
+
rollbackSatelliteLock(locks.memories);
|
|
728
|
+
rollbackSatelliteLock(locks.goals);
|
|
729
|
+
locks.logs = undefined;
|
|
730
|
+
locks.memories = undefined;
|
|
731
|
+
locks.goals = undefined;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function commitSatelliteLock(lock: SatelliteWriteLock | undefined): void {
|
|
735
|
+
if (!lock) return;
|
|
736
|
+
lock.db.exec("COMMIT");
|
|
737
|
+
lock.db.close();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function snapshotLogsInTx(
|
|
741
|
+
db: Database,
|
|
742
|
+
path: string,
|
|
743
|
+
threadIds: string[],
|
|
744
|
+
): SatelliteBackup["logs"] {
|
|
745
|
+
if (threadIds.length === 0) return undefined;
|
|
746
|
+
if (!tableExists(db, "logs")) throw new Error("missing_logs_table");
|
|
747
|
+
const rows: SqlRow[] = [];
|
|
748
|
+
for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
|
|
749
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
750
|
+
rows.push(...selectRows(db, `SELECT * FROM logs WHERE thread_id IN (${placeholders})`, chunk));
|
|
751
|
+
}
|
|
752
|
+
return { path, rows };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function snapshotMemoriesInTx(
|
|
756
|
+
db: Database,
|
|
757
|
+
path: string,
|
|
758
|
+
threadIds: string[],
|
|
759
|
+
): SatelliteBackup["memories"] {
|
|
760
|
+
if (threadIds.length === 0) return undefined;
|
|
761
|
+
if (!tableExists(db, "stage1_outputs")) throw new Error("missing_stage1_outputs_table");
|
|
762
|
+
const stage1: SqlRow[] = [];
|
|
763
|
+
let stage1Jobs: SqlRow[] = [];
|
|
764
|
+
for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
|
|
765
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
766
|
+
stage1.push(...selectRows(
|
|
767
|
+
db,
|
|
768
|
+
`SELECT * FROM stage1_outputs WHERE thread_id IN (${placeholders})`,
|
|
769
|
+
chunk,
|
|
770
|
+
));
|
|
771
|
+
if (tableExists(db, "jobs")) {
|
|
772
|
+
stage1Jobs.push(...selectRows(
|
|
773
|
+
db,
|
|
774
|
+
`SELECT * FROM jobs WHERE kind = ? AND job_key IN (${placeholders})`,
|
|
775
|
+
[JOB_KIND_MEMORY_STAGE1, ...chunk],
|
|
776
|
+
));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
let consolidateJob: SqlRow | null = null;
|
|
780
|
+
let selectedForPhase2 = 0;
|
|
781
|
+
if (columnExists(db, "stage1_outputs", "selected_for_phase2")) {
|
|
782
|
+
selectedForPhase2 = stage1.filter(r => Number(r.selected_for_phase2 ?? 0) !== 0).length;
|
|
783
|
+
}
|
|
784
|
+
if (tableExists(db, "jobs")) {
|
|
785
|
+
consolidateJob = readConsolidateGlobalJob(db);
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
path,
|
|
789
|
+
stage1,
|
|
790
|
+
stage1Jobs,
|
|
791
|
+
consolidateJob,
|
|
792
|
+
consolidateTouched: selectedForPhase2 > 0,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function snapshotGoalsInTx(
|
|
797
|
+
db: Database,
|
|
798
|
+
path: string,
|
|
799
|
+
threadIds: string[],
|
|
800
|
+
): SatelliteBackup["goals"] {
|
|
801
|
+
if (threadIds.length === 0) return undefined;
|
|
802
|
+
if (!tableExists(db, "thread_goals")) throw new Error("missing_thread_goals_table");
|
|
803
|
+
const goals: SqlRow[] = [];
|
|
804
|
+
let deferrals: SqlRow[] = [];
|
|
805
|
+
for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
|
|
806
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
807
|
+
goals.push(...selectRows(
|
|
808
|
+
db,
|
|
809
|
+
`SELECT * FROM thread_goals WHERE thread_id IN (${placeholders})`,
|
|
810
|
+
chunk,
|
|
811
|
+
));
|
|
812
|
+
if (tableExists(db, "thread_goal_continuation_deferrals")) {
|
|
813
|
+
deferrals.push(...selectRows(
|
|
814
|
+
db,
|
|
815
|
+
`SELECT * FROM thread_goal_continuation_deferrals WHERE thread_id IN (${placeholders})`,
|
|
816
|
+
chunk,
|
|
817
|
+
));
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return { path, goals, deferrals };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Snapshot every present satellite under its write lock (rows stable until commit). */
|
|
824
|
+
function snapshotSatelliteBackupInLocks(
|
|
825
|
+
locks: SatelliteWriteLocks,
|
|
826
|
+
threadIds: string[],
|
|
827
|
+
): SatelliteBackup {
|
|
828
|
+
const backup: SatelliteBackup = { threadIds };
|
|
829
|
+
if (locks.logs) {
|
|
830
|
+
backup.logs = snapshotLogsInTx(locks.logs.db, locks.logs.path, threadIds);
|
|
831
|
+
}
|
|
832
|
+
if (locks.memories) {
|
|
833
|
+
backup.memories = snapshotMemoriesInTx(locks.memories.db, locks.memories.path, threadIds);
|
|
834
|
+
}
|
|
835
|
+
if (locks.goals) {
|
|
836
|
+
backup.goals = snapshotGoalsInTx(locks.goals.db, locks.goals.path, threadIds);
|
|
837
|
+
}
|
|
838
|
+
return backup;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function deleteLogsInTx(db: Database, rows: SqlRow[]): void {
|
|
842
|
+
if (rows.length === 0) return;
|
|
843
|
+
if (!tableExists(db, "logs")) throw new Error("missing_logs_table");
|
|
844
|
+
const ids = rows.map(r => r.id).filter(id => id !== null && id !== undefined);
|
|
845
|
+
if (ids.length === 0) return;
|
|
846
|
+
for (const chunk of chunkIds(ids as string[], SQLITE_ID_CHUNK * 2)) {
|
|
847
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
848
|
+
db.run(`DELETE FROM logs WHERE id IN (${placeholders})`, chunk as Array<string | number>);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function deleteMemoriesInTx(
|
|
853
|
+
db: Database,
|
|
854
|
+
section: NonNullable<SatelliteBackup["memories"]>,
|
|
855
|
+
): void {
|
|
856
|
+
if (!tableExists(db, "stage1_outputs")) throw new Error("missing_stage1_outputs_table");
|
|
857
|
+
const stage1Ids = section.stage1.map(r => String(r.thread_id));
|
|
858
|
+
for (const chunk of chunkIds(stage1Ids, SQLITE_ID_CHUNK * 2)) {
|
|
859
|
+
if (chunk.length === 0) continue;
|
|
860
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
861
|
+
db.run(`DELETE FROM stage1_outputs WHERE thread_id IN (${placeholders})`, chunk);
|
|
862
|
+
}
|
|
863
|
+
if (tableExists(db, "jobs")) {
|
|
864
|
+
const jobKeys = section.stage1Jobs.map(r => String(r.job_key));
|
|
865
|
+
for (const chunk of chunkIds(jobKeys, SQLITE_ID_CHUNK * 2)) {
|
|
866
|
+
if (chunk.length === 0) continue;
|
|
867
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
868
|
+
db.run(
|
|
869
|
+
`DELETE FROM jobs WHERE kind = ? AND job_key IN (${placeholders})`,
|
|
870
|
+
[JOB_KIND_MEMORY_STAGE1, ...chunk],
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
if (section.consolidateTouched) {
|
|
874
|
+
const now = Math.floor(Date.now() / 1000);
|
|
875
|
+
db.run(
|
|
876
|
+
`INSERT INTO jobs (
|
|
877
|
+
kind, job_key, status, worker_id, ownership_token, started_at, finished_at,
|
|
878
|
+
lease_until, retry_at, retry_remaining, last_error, input_watermark, last_success_watermark
|
|
879
|
+
) VALUES (?, ?, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, ?, NULL, ?, 0)
|
|
880
|
+
ON CONFLICT(kind, job_key) DO UPDATE SET
|
|
881
|
+
status = CASE WHEN jobs.status = 'running' THEN 'running' ELSE 'pending' END,
|
|
882
|
+
retry_at = CASE WHEN jobs.status = 'running' THEN jobs.retry_at ELSE NULL END,
|
|
883
|
+
retry_remaining = max(jobs.retry_remaining, excluded.retry_remaining),
|
|
884
|
+
input_watermark = CASE
|
|
885
|
+
WHEN excluded.input_watermark > COALESCE(jobs.input_watermark, 0)
|
|
886
|
+
THEN excluded.input_watermark
|
|
887
|
+
ELSE COALESCE(jobs.input_watermark, 0) + 1
|
|
888
|
+
END`,
|
|
889
|
+
[JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL, MEMORY_CONSOLIDATION_JOB_KEY, DEFAULT_RETRY_REMAINING, now],
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function deleteGoalsInTx(
|
|
896
|
+
db: Database,
|
|
897
|
+
section: NonNullable<SatelliteBackup["goals"]>,
|
|
898
|
+
): void {
|
|
899
|
+
if (!tableExists(db, "thread_goals")) throw new Error("missing_thread_goals_table");
|
|
900
|
+
const deferralIds = section.deferrals.map(r => String(r.thread_id));
|
|
901
|
+
if (tableExists(db, "thread_goal_continuation_deferrals")) {
|
|
902
|
+
for (const chunk of chunkIds(deferralIds, SQLITE_ID_CHUNK * 2)) {
|
|
903
|
+
if (chunk.length === 0) continue;
|
|
904
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
905
|
+
db.run(
|
|
906
|
+
`DELETE FROM thread_goal_continuation_deferrals WHERE thread_id IN (${placeholders})`,
|
|
907
|
+
chunk,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const goalIds = section.goals.map(r => String(r.thread_id));
|
|
912
|
+
for (const chunk of chunkIds(goalIds, SQLITE_ID_CHUNK * 2)) {
|
|
913
|
+
if (chunk.length === 0) continue;
|
|
914
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
915
|
+
db.run(`DELETE FROM thread_goals WHERE thread_id IN (${placeholders})`, chunk);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Delete snapshotted primary-key rows and commit each satellite write transaction. */
|
|
920
|
+
function deleteAndCommitSatellites(
|
|
921
|
+
locks: SatelliteWriteLocks,
|
|
922
|
+
backup: SatelliteBackup,
|
|
923
|
+
stageDir: string,
|
|
924
|
+
hooks?: ReconcileTestHooks,
|
|
925
|
+
): void {
|
|
926
|
+
try {
|
|
927
|
+
if (locks.logs && backup.logs) {
|
|
928
|
+
deleteLogsInTx(locks.logs.db, backup.logs.rows);
|
|
929
|
+
commitSatelliteLock(locks.logs);
|
|
930
|
+
locks.logs = undefined;
|
|
931
|
+
if (hooks?.failAfterLogsMutation) throw new Error("test_fail_after_logs");
|
|
932
|
+
}
|
|
933
|
+
if (locks.memories && backup.memories) {
|
|
934
|
+
deleteMemoriesInTx(locks.memories.db, backup.memories);
|
|
935
|
+
if (backup.memories.consolidateTouched) {
|
|
936
|
+
backup.memories.consolidatePostImage = readConsolidateGlobalJob(locks.memories.db);
|
|
937
|
+
writeSatelliteBackup(stageDir, backup);
|
|
938
|
+
}
|
|
939
|
+
commitSatelliteLock(locks.memories);
|
|
940
|
+
locks.memories = undefined;
|
|
941
|
+
if (hooks?.failAfterMemoriesMutation) throw new Error("test_fail_after_memories");
|
|
942
|
+
}
|
|
943
|
+
if (locks.goals && backup.goals) {
|
|
944
|
+
deleteGoalsInTx(locks.goals.db, backup.goals);
|
|
945
|
+
commitSatelliteLock(locks.goals);
|
|
946
|
+
locks.goals = undefined;
|
|
947
|
+
if (hooks?.failAfterGoalsMutation) throw new Error("test_fail_after_goals");
|
|
948
|
+
}
|
|
949
|
+
} catch (error) {
|
|
950
|
+
rollbackAllSatelliteLocks(locks);
|
|
951
|
+
throw error;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Restore only snapshotted rows; concurrent inserts/updates after commit stay intact. */
|
|
956
|
+
function restoreSatelliteBackup(
|
|
957
|
+
backup: SatelliteBackup,
|
|
958
|
+
busyTimeoutMs: number,
|
|
959
|
+
failRestore = false,
|
|
960
|
+
): boolean {
|
|
961
|
+
if (failRestore) return false;
|
|
962
|
+
try {
|
|
963
|
+
if (backup.logs) {
|
|
964
|
+
const restored = withWritableDb(backup.logs.path, busyTimeoutMs, db => {
|
|
965
|
+
if (!tableExists(db, "logs")) throw new Error("missing_logs_table");
|
|
966
|
+
insertRowsConflictIgnore(db, "logs", backup.logs!.rows);
|
|
967
|
+
});
|
|
968
|
+
if (!restored.ok) return false;
|
|
969
|
+
}
|
|
970
|
+
if (backup.memories) {
|
|
971
|
+
const mem = backup.memories;
|
|
972
|
+
const restored = withWritableDb(mem.path, busyTimeoutMs, db => {
|
|
973
|
+
if (!tableExists(db, "stage1_outputs")) throw new Error("missing_stage1_outputs_table");
|
|
974
|
+
insertRowsConflictIgnore(db, "stage1_outputs", mem.stage1);
|
|
975
|
+
if (tableExists(db, "jobs")) {
|
|
976
|
+
insertRowsConflictIgnore(db, "jobs", mem.stage1Jobs);
|
|
977
|
+
if (mem.consolidateTouched) {
|
|
978
|
+
restoreConsolidateGlobalJob(db, mem.consolidateJob, mem.consolidatePostImage);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
if (!restored.ok) return false;
|
|
983
|
+
}
|
|
984
|
+
if (backup.goals) {
|
|
985
|
+
const g = backup.goals;
|
|
986
|
+
const restored = withWritableDb(g.path, busyTimeoutMs, db => {
|
|
987
|
+
if (!tableExists(db, "thread_goals")) throw new Error("missing_thread_goals_table");
|
|
988
|
+
insertRowsConflictIgnore(db, "thread_goals", g.goals);
|
|
989
|
+
if (tableExists(db, "thread_goal_continuation_deferrals")) {
|
|
990
|
+
insertRowsConflictIgnore(db, "thread_goal_continuation_deferrals", g.deferrals);
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
if (!restored.ok) return false;
|
|
994
|
+
}
|
|
995
|
+
return true;
|
|
996
|
+
} catch {
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function withWritableDb(
|
|
1002
|
+
path: string,
|
|
1003
|
+
busyTimeoutMs: number,
|
|
1004
|
+
body: (db: Database) => void,
|
|
1005
|
+
): { ok: true } | ReconcileErr {
|
|
1006
|
+
let db: Database | undefined;
|
|
1007
|
+
try {
|
|
1008
|
+
db = openDbWritable(path, busyTimeoutMs);
|
|
1009
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1010
|
+
try {
|
|
1011
|
+
body(db);
|
|
1012
|
+
db.exec("COMMIT");
|
|
1013
|
+
return { ok: true };
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
try { db.exec("ROLLBACK"); } catch { /* */ }
|
|
1016
|
+
throw error;
|
|
1017
|
+
}
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
return { ok: false, error: mapDbError(error) };
|
|
1020
|
+
} finally {
|
|
1021
|
+
try { db?.close(); } catch { /* */ }
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Load matching archived threads and refuse referenced history — no deletes yet. */
|
|
1026
|
+
function loadThreadsForCleanup(
|
|
1027
|
+
stateDbPath: string,
|
|
1028
|
+
candidates: ArchivedCandidate[],
|
|
1029
|
+
codexHome: string,
|
|
1030
|
+
busyTimeoutMs: number,
|
|
1031
|
+
): ReconcileOk | ReconcileErr {
|
|
1032
|
+
if (!stateDbPath || !existsSync(stateDbPath)) return { ok: true, threads: [] };
|
|
1033
|
+
let db: Database | undefined;
|
|
1034
|
+
try {
|
|
1035
|
+
db = openDbWritable(stateDbPath, busyTimeoutMs);
|
|
1036
|
+
const threads = loadMatchingThreads(db, candidates, codexHome);
|
|
1037
|
+
if (findReferencedHistory(db, threads)) {
|
|
1038
|
+
return { ok: false, error: "referenced_history" };
|
|
1039
|
+
}
|
|
1040
|
+
return { ok: true, threads };
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
return { ok: false, error: mapDbError(error) };
|
|
1043
|
+
} finally {
|
|
1044
|
+
try { db?.close(); } catch { /* */ }
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Reconcile all Codex per-thread stores for the matched archived candidates.
|
|
1050
|
+
*
|
|
1051
|
+
* Freezes the thread-ID set under the state write lock, persists a complete
|
|
1052
|
+
* satellite backup, then mutates satellites (logs → memories → goals). Any later
|
|
1053
|
+
* failure restores satellite rows before the caller restores staged files.
|
|
1054
|
+
*/
|
|
1055
|
+
function reconcileDeletedThreads(
|
|
1056
|
+
paths: RuntimeDbPaths,
|
|
1057
|
+
candidates: ArchivedCandidate[],
|
|
1058
|
+
codexHome: string,
|
|
1059
|
+
busyTimeoutMs: number,
|
|
1060
|
+
stageDir: string,
|
|
1061
|
+
hooks?: ReconcileTestHooks,
|
|
1062
|
+
): ReconcileOk | ReconcileErr {
|
|
1063
|
+
if (!paths.state || !existsSync(paths.state)) return { ok: true, threads: [] };
|
|
1064
|
+
|
|
1065
|
+
let stateDb: Database | undefined;
|
|
1066
|
+
let backup: SatelliteBackup | undefined;
|
|
1067
|
+
let satellitesMutated = false;
|
|
1068
|
+
let satelliteLocks: SatelliteWriteLocks | undefined;
|
|
1069
|
+
|
|
1070
|
+
const failWithRestore = (error: CleanupErrorCode, mapped?: CleanupErrorCode): ReconcileErr => {
|
|
1071
|
+
const code = mapped ?? error;
|
|
1072
|
+
let satelliteRestoreFailed = false;
|
|
1073
|
+
if (satellitesMutated && backup) {
|
|
1074
|
+
satelliteRestoreFailed = !restoreSatelliteBackup(
|
|
1075
|
+
backup,
|
|
1076
|
+
busyTimeoutMs,
|
|
1077
|
+
Boolean(hooks?.failSatelliteRestore),
|
|
1078
|
+
);
|
|
1079
|
+
// Keep on-disk backup + manifest when restore cannot complete.
|
|
1080
|
+
if (!satelliteRestoreFailed) clearSatelliteBackup(stageDir);
|
|
1081
|
+
} else {
|
|
1082
|
+
clearSatelliteBackup(stageDir);
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
error: code,
|
|
1087
|
+
...(satelliteRestoreFailed ? { satelliteRestoreFailed: true } : {}),
|
|
1088
|
+
};
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
try {
|
|
1092
|
+
stateDb = openDbWritable(paths.state, busyTimeoutMs);
|
|
1093
|
+
stateDb.exec("BEGIN IMMEDIATE");
|
|
1094
|
+
|
|
1095
|
+
// Freeze the exact delete set under the write lock before any satellite mutation.
|
|
1096
|
+
const threads = loadMatchingThreads(stateDb, candidates, codexHome);
|
|
1097
|
+
if (findReferencedHistory(stateDb, threads)) {
|
|
1098
|
+
stateDb.exec("ROLLBACK");
|
|
1099
|
+
return { ok: false, error: "referenced_history" };
|
|
1100
|
+
}
|
|
1101
|
+
const threadIds = threads.map(t => t.id);
|
|
1102
|
+
|
|
1103
|
+
satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs);
|
|
1104
|
+
try {
|
|
1105
|
+
backup = snapshotSatelliteBackupInLocks(satelliteLocks, threadIds);
|
|
1106
|
+
try {
|
|
1107
|
+
if (hooks?.failSatelliteBackupWrite) {
|
|
1108
|
+
throw new Error("test_fail_satellite_backup_write");
|
|
1109
|
+
}
|
|
1110
|
+
writeSatelliteBackup(stageDir, backup);
|
|
1111
|
+
} catch {
|
|
1112
|
+
rollbackAllSatelliteLocks(satelliteLocks);
|
|
1113
|
+
stateDb.exec("ROLLBACK");
|
|
1114
|
+
clearSatelliteBackup(stageDir);
|
|
1115
|
+
return { ok: false, error: "fs_failed" };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const hasSatelliteWork = Boolean(backup.logs || backup.memories || backup.goals);
|
|
1119
|
+
if (hasSatelliteWork) {
|
|
1120
|
+
satellitesMutated = true;
|
|
1121
|
+
deleteAndCommitSatellites(satelliteLocks, backup, stageDir, hooks);
|
|
1122
|
+
} else {
|
|
1123
|
+
rollbackAllSatelliteLocks(satelliteLocks);
|
|
1124
|
+
}
|
|
1125
|
+
satelliteLocks = undefined;
|
|
1126
|
+
|
|
1127
|
+
if (hooks?.afterSatelliteMutations) hooks.afterSatelliteMutations();
|
|
1128
|
+
|
|
1129
|
+
// Re-check under the same lock before committing state deletes.
|
|
1130
|
+
if (findReferencedHistory(stateDb, threads)) {
|
|
1131
|
+
stateDb.exec("ROLLBACK");
|
|
1132
|
+
return failWithRestore("referenced_history");
|
|
1133
|
+
}
|
|
1134
|
+
deleteThreadsAndDependents(stateDb, threadIds);
|
|
1135
|
+
if (hooks?.failBeforeStateCommit) throw new Error("test_fail_before_state_commit");
|
|
1136
|
+
stateDb.exec("COMMIT");
|
|
1137
|
+
clearSatelliteBackup(stageDir);
|
|
1138
|
+
return { ok: true, threads };
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
|
|
1141
|
+
throw error;
|
|
1142
|
+
}
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
try { stateDb?.exec("ROLLBACK"); } catch { /* */ }
|
|
1145
|
+
return failWithRestore("db_reconcile_failed", mapDbError(error));
|
|
1146
|
+
} finally {
|
|
1147
|
+
try { stateDb?.close(); } catch { /* */ }
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
type StagedFile = { from: string; to: string; relPath: string };
|
|
1152
|
+
|
|
1153
|
+
function absFromRel(codexHome: string, relPath: string): string {
|
|
1154
|
+
if (relPath.includes("..") || isAbsolute(relPath) || /^[A-Za-z]:[\\/]/.test(relPath)) {
|
|
1155
|
+
throw new Error("invalid_rel_path");
|
|
1156
|
+
}
|
|
1157
|
+
const abs = resolve(codexHome, ...relPath.split("/"));
|
|
1158
|
+
const homeAbs = resolve(codexHome);
|
|
1159
|
+
const rel = toForwardSlash(relative(homeAbs, abs));
|
|
1160
|
+
if (!rel || rel.startsWith("..")) throw new Error("path_escape");
|
|
1161
|
+
return abs;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function stageCandidates(
|
|
1165
|
+
codexHome: string,
|
|
1166
|
+
candidates: ArchivedCandidate[],
|
|
1167
|
+
stageDir: string,
|
|
1168
|
+
opts?: { blockDestBasenames?: Set<string> },
|
|
1169
|
+
): { ok: true; staged: StagedFile[] } | { ok: false; staged: StagedFile[] } {
|
|
1170
|
+
const staged: StagedFile[] = [];
|
|
1171
|
+
const usedBasenames = new Set<string>();
|
|
1172
|
+
try {
|
|
1173
|
+
mkdirSync(stageDir, { recursive: true });
|
|
1174
|
+
for (const candidate of candidates) {
|
|
1175
|
+
for (const rel of candidate.physicalRelPaths) {
|
|
1176
|
+
const from = absFromRel(codexHome, rel);
|
|
1177
|
+
const base = basename(rel);
|
|
1178
|
+
// archived_sessions/ is flat today; refuse collisions so a future nested walk
|
|
1179
|
+
// cannot silently overwrite another staged file.
|
|
1180
|
+
if (usedBasenames.has(base)) {
|
|
1181
|
+
throw new Error("stage_basename_collision");
|
|
1182
|
+
}
|
|
1183
|
+
usedBasenames.add(base);
|
|
1184
|
+
const to = join(stageDir, base);
|
|
1185
|
+
if (opts?.blockDestBasenames?.has(base)) {
|
|
1186
|
+
mkdirSync(to, { recursive: true });
|
|
1187
|
+
}
|
|
1188
|
+
renameSync(from, to);
|
|
1189
|
+
staged.push({ from, to, relPath: rel });
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return { ok: true, staged };
|
|
1193
|
+
} catch {
|
|
1194
|
+
return { ok: false, staged };
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Rename staged files back to their originals.
|
|
1200
|
+
* Returns whether every staged file was restored. Unrestored entries stay in `remaining`.
|
|
1201
|
+
*/
|
|
1202
|
+
function rollbackStaged(
|
|
1203
|
+
staged: StagedFile[],
|
|
1204
|
+
opts?: { failBasenames?: Set<string> },
|
|
1205
|
+
): { restored: boolean; remaining: StagedFile[] } {
|
|
1206
|
+
const remaining: StagedFile[] = [];
|
|
1207
|
+
for (let i = staged.length - 1; i >= 0; i--) {
|
|
1208
|
+
const item = staged[i]!;
|
|
1209
|
+
const base = basename(item.to);
|
|
1210
|
+
if (opts?.failBasenames?.has(base)) {
|
|
1211
|
+
remaining.push(item);
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
try {
|
|
1215
|
+
if (existsSync(item.to) && !existsSync(item.from)) {
|
|
1216
|
+
renameSync(item.to, item.from);
|
|
1217
|
+
} else if (existsSync(item.to)) {
|
|
1218
|
+
// Destination occupied — cannot restore without clobbering.
|
|
1219
|
+
remaining.push(item);
|
|
1220
|
+
}
|
|
1221
|
+
} catch {
|
|
1222
|
+
remaining.push(item);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return { restored: remaining.length === 0, remaining };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function purgeStaged(
|
|
1229
|
+
staged: StagedFile[],
|
|
1230
|
+
opts?: { failBasenames?: Set<string> },
|
|
1231
|
+
): { purged: StagedFile[]; remaining: StagedFile[] } {
|
|
1232
|
+
const purged: StagedFile[] = [];
|
|
1233
|
+
const remaining: StagedFile[] = [];
|
|
1234
|
+
for (const item of staged) {
|
|
1235
|
+
const base = basename(item.to);
|
|
1236
|
+
if (opts?.failBasenames?.has(base)) {
|
|
1237
|
+
remaining.push(item);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
try {
|
|
1241
|
+
unlinkSync(item.to);
|
|
1242
|
+
purged.push(item);
|
|
1243
|
+
} catch {
|
|
1244
|
+
remaining.push(item);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return { purged, remaining };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** Remove stageDir only when it contains no unrestored staged files. */
|
|
1251
|
+
function removeStageIfEmpty(stageDir: string, remaining: StagedFile[]): void {
|
|
1252
|
+
if (remaining.length > 0) return;
|
|
1253
|
+
try { rmSync(stageDir, { recursive: true, force: true }); } catch { /* */ }
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function removeEmptyTrashRoot(codexHome: string): void {
|
|
1257
|
+
try {
|
|
1258
|
+
const trashRoot = join(codexHome, TRASH_DIR);
|
|
1259
|
+
if (existsSync(trashRoot) && readdirSync(trashRoot).length === 0) {
|
|
1260
|
+
rmSync(trashRoot, { recursive: true, force: true });
|
|
1261
|
+
}
|
|
1262
|
+
} catch { /* */ }
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function trashRelPath(codexHome: string, stageDir: string): string {
|
|
1266
|
+
return toForwardSlash(relative(codexHome, stageDir) || stageDir);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
export interface ExecuteCleanupOptions {
|
|
1270
|
+
percent: number;
|
|
1271
|
+
mode: CleanupMode;
|
|
1272
|
+
/** Required digest from preview; rejects when the candidate set drifted. */
|
|
1273
|
+
digest: string;
|
|
1274
|
+
codexHome?: string;
|
|
1275
|
+
/** Test-only: shrink busy_timeout so lock tests fail fast. */
|
|
1276
|
+
busyTimeoutMs?: number;
|
|
1277
|
+
now?: number;
|
|
1278
|
+
/** Test-only failure injection for atomicity regressions. */
|
|
1279
|
+
_test?: {
|
|
1280
|
+
failManifestWrite?: boolean;
|
|
1281
|
+
failPurgeBasenames?: string[];
|
|
1282
|
+
failRollbackBasenames?: string[];
|
|
1283
|
+
blockStageDestBasenames?: string[];
|
|
1284
|
+
failAfterLogsMutation?: boolean;
|
|
1285
|
+
failAfterMemoriesMutation?: boolean;
|
|
1286
|
+
failAfterGoalsMutation?: boolean;
|
|
1287
|
+
failBeforeStateCommit?: boolean;
|
|
1288
|
+
failSatelliteRestore?: boolean;
|
|
1289
|
+
failSatelliteBackupWrite?: boolean;
|
|
1290
|
+
afterSatelliteMutations?: () => void;
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** Serializable cleanup test hooks allowed on the management API wire. */
|
|
1295
|
+
export type CleanupWireTestHooks = Omit<
|
|
1296
|
+
NonNullable<ExecuteCleanupOptions["_test"]>,
|
|
1297
|
+
"afterSatelliteMutations"
|
|
1298
|
+
>;
|
|
1299
|
+
|
|
1300
|
+
function isStringArray(v: unknown): v is string[] {
|
|
1301
|
+
return Array.isArray(v) && v.every(e => typeof e === "string");
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** Pick only allowlisted serializable hooks; drops afterSatelliteMutations and unknown keys. */
|
|
1305
|
+
export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | undefined {
|
|
1306
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
1307
|
+
const o = raw as Record<string, unknown>;
|
|
1308
|
+
const out: CleanupWireTestHooks = {};
|
|
1309
|
+
if (typeof o.failManifestWrite === "boolean") out.failManifestWrite = o.failManifestWrite;
|
|
1310
|
+
if (isStringArray(o.failPurgeBasenames)) out.failPurgeBasenames = o.failPurgeBasenames;
|
|
1311
|
+
if (isStringArray(o.failRollbackBasenames)) out.failRollbackBasenames = o.failRollbackBasenames;
|
|
1312
|
+
if (isStringArray(o.blockStageDestBasenames)) out.blockStageDestBasenames = o.blockStageDestBasenames;
|
|
1313
|
+
if (typeof o.failAfterLogsMutation === "boolean") out.failAfterLogsMutation = o.failAfterLogsMutation;
|
|
1314
|
+
if (typeof o.failAfterMemoriesMutation === "boolean") out.failAfterMemoriesMutation = o.failAfterMemoriesMutation;
|
|
1315
|
+
if (typeof o.failAfterGoalsMutation === "boolean") out.failAfterGoalsMutation = o.failAfterGoalsMutation;
|
|
1316
|
+
if (typeof o.failBeforeStateCommit === "boolean") out.failBeforeStateCommit = o.failBeforeStateCommit;
|
|
1317
|
+
if (typeof o.failSatelliteRestore === "boolean") out.failSatelliteRestore = o.failSatelliteRestore;
|
|
1318
|
+
if (typeof o.failSatelliteBackupWrite === "boolean") out.failSatelliteBackupWrite = o.failSatelliteBackupWrite;
|
|
1319
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function fail(
|
|
1323
|
+
mode: CleanupMode,
|
|
1324
|
+
percent: number,
|
|
1325
|
+
error: CleanupErrorCode,
|
|
1326
|
+
extra?: { trashDir?: string },
|
|
1327
|
+
): CleanupResult {
|
|
1328
|
+
return {
|
|
1329
|
+
ok: false,
|
|
1330
|
+
mode,
|
|
1331
|
+
percent,
|
|
1332
|
+
count: 0,
|
|
1333
|
+
bytes: 0,
|
|
1334
|
+
removedPaths: [],
|
|
1335
|
+
error,
|
|
1336
|
+
...(extra?.trashDir ? { trashDir: extra.trashDir } : {}),
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Execute archived cleanup bound to a preview digest.
|
|
1342
|
+
* Stages every physical file, writes the recovery manifest, then commits DB deletes.
|
|
1343
|
+
* Rollback never deletes a stage directory that still holds unrestored files.
|
|
1344
|
+
*/
|
|
1345
|
+
export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupResult {
|
|
1346
|
+
const codexHome = options.codexHome ?? resolveCodexHomeDir();
|
|
1347
|
+
const mode = options.mode;
|
|
1348
|
+
const percent = clampPercent(options.percent);
|
|
1349
|
+
const busyTimeoutMs = options.busyTimeoutMs ?? 100;
|
|
1350
|
+
const failRollback = new Set(options._test?.failRollbackBasenames ?? []);
|
|
1351
|
+
const failPurge = new Set(options._test?.failPurgeBasenames ?? []);
|
|
1352
|
+
const blockStageDest = new Set(options._test?.blockStageDestBasenames ?? []);
|
|
1353
|
+
|
|
1354
|
+
if (mode !== "quarantine" && mode !== "permanent") {
|
|
1355
|
+
return fail(mode, percent, "invalid_mode");
|
|
1356
|
+
}
|
|
1357
|
+
if (typeof options.digest !== "string" || !/^[a-f0-9]{64}$/i.test(options.digest)) {
|
|
1358
|
+
return fail(mode, percent, "invalid_digest");
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const preview = previewArchivedCleanup(percent, codexHome);
|
|
1362
|
+
if (preview.digest.toLowerCase() !== options.digest.toLowerCase()) {
|
|
1363
|
+
return fail(mode, percent, "stale_preview");
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (preview.candidates.length === 0) {
|
|
1367
|
+
return {
|
|
1368
|
+
ok: true,
|
|
1369
|
+
mode,
|
|
1370
|
+
percent,
|
|
1371
|
+
count: 0,
|
|
1372
|
+
bytes: 0,
|
|
1373
|
+
removedPaths: [],
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const paths = discoverRuntimeDbPaths(codexHome);
|
|
1378
|
+
const probe = probeStateDbWritable(codexHome, busyTimeoutMs);
|
|
1379
|
+
if (!probe.ok) {
|
|
1380
|
+
return fail(mode, percent, probe.error);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Preflight referenced-history / matching while DB is free, before any rename.
|
|
1384
|
+
const loaded = loadThreadsForCleanup(paths.state ?? "", preview.candidates, codexHome, busyTimeoutMs);
|
|
1385
|
+
if (!loaded.ok) {
|
|
1386
|
+
return fail(mode, percent, loaded.error);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const epoch = options.now ?? Date.now();
|
|
1390
|
+
let stageDir: string;
|
|
1391
|
+
try {
|
|
1392
|
+
stageDir = createExclusiveStageDir(codexHome, epoch);
|
|
1393
|
+
} catch {
|
|
1394
|
+
return fail(mode, percent, "fs_failed");
|
|
1395
|
+
}
|
|
1396
|
+
const trashDir = trashRelPath(codexHome, stageDir);
|
|
1397
|
+
|
|
1398
|
+
const threadByRelPath = new Map<string, ThreadSnapshot>();
|
|
1399
|
+
for (const thread of loaded.threads) {
|
|
1400
|
+
const normalized = normalizeArchivedRolloutPath(thread.rollout_path, codexHome);
|
|
1401
|
+
if (normalized) threadByRelPath.set(normalized, thread);
|
|
1402
|
+
}
|
|
1403
|
+
const manifestEntries: CleanupManifestEntry[] = preview.candidates.map(candidate => {
|
|
1404
|
+
const thread = threadByRelPath.get(candidate.relPath);
|
|
1405
|
+
return {
|
|
1406
|
+
relPath: candidate.relPath,
|
|
1407
|
+
bytes: candidate.bytes,
|
|
1408
|
+
mtimeMs: candidate.mtimeMs,
|
|
1409
|
+
physicalRelPaths: candidate.physicalRelPaths,
|
|
1410
|
+
...(thread
|
|
1411
|
+
? { threadId: thread.id, rolloutPath: thread.rollout_path, archived: thread.archived }
|
|
1412
|
+
: {}),
|
|
1413
|
+
};
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
const writeManifest = (extra: Record<string, unknown> = {}) => {
|
|
1417
|
+
writePrivateFile(
|
|
1418
|
+
join(stageDir, "manifest.json"),
|
|
1419
|
+
JSON.stringify({
|
|
1420
|
+
quarantinedAt: epoch,
|
|
1421
|
+
mode,
|
|
1422
|
+
percent,
|
|
1423
|
+
digest: preview.digest,
|
|
1424
|
+
entries: manifestEntries,
|
|
1425
|
+
...extra,
|
|
1426
|
+
}, null, 2),
|
|
1427
|
+
);
|
|
1428
|
+
};
|
|
1429
|
+
|
|
1430
|
+
// Journal staged paths before the first rename so a crash mid-stage is recoverable.
|
|
1431
|
+
try {
|
|
1432
|
+
if (options._test?.failManifestWrite) {
|
|
1433
|
+
throw new Error("test_fail_manifest_write");
|
|
1434
|
+
}
|
|
1435
|
+
writeManifest({ staging: true });
|
|
1436
|
+
} catch {
|
|
1437
|
+
removeStageIfEmpty(stageDir, []);
|
|
1438
|
+
return fail(mode, percent, "fs_failed");
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const stageResult = stageCandidates(codexHome, preview.candidates, stageDir, {
|
|
1442
|
+
blockDestBasenames: blockStageDest.size > 0 ? blockStageDest : undefined,
|
|
1443
|
+
});
|
|
1444
|
+
if (!stageResult.ok) {
|
|
1445
|
+
const rolled = rollbackStaged(stageResult.staged, { failBasenames: failRollback });
|
|
1446
|
+
removeStageIfEmpty(stageDir, rolled.remaining);
|
|
1447
|
+
return fail(mode, percent, "fs_failed", rolled.restored ? undefined : { trashDir });
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// Final manifest before DB deletion so a mid-flight crash still has recovery metadata.
|
|
1451
|
+
try {
|
|
1452
|
+
writeManifest();
|
|
1453
|
+
} catch {
|
|
1454
|
+
const rolled = rollbackStaged(stageResult.staged, { failBasenames: failRollback });
|
|
1455
|
+
removeStageIfEmpty(stageDir, rolled.remaining);
|
|
1456
|
+
return fail(mode, percent, "fs_failed", rolled.restored ? undefined : { trashDir });
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const deleted = reconcileDeletedThreads(
|
|
1460
|
+
paths,
|
|
1461
|
+
preview.candidates,
|
|
1462
|
+
codexHome,
|
|
1463
|
+
busyTimeoutMs,
|
|
1464
|
+
stageDir,
|
|
1465
|
+
options._test,
|
|
1466
|
+
);
|
|
1467
|
+
if (!deleted.ok) {
|
|
1468
|
+
const rolled = rollbackStaged(stageResult.staged, { failBasenames: failRollback });
|
|
1469
|
+
// Keep the stage (and recovery manifest) when files or satellite DB rows remain unrestored.
|
|
1470
|
+
const keepTrash = Boolean(deleted.satelliteRestoreFailed) || !rolled.restored;
|
|
1471
|
+
if (!keepTrash) {
|
|
1472
|
+
removeStageIfEmpty(stageDir, rolled.remaining);
|
|
1473
|
+
removeEmptyTrashRoot(codexHome);
|
|
1474
|
+
}
|
|
1475
|
+
return fail(mode, percent, deleted.error, keepTrash ? { trashDir } : undefined);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const removedPaths = preview.candidates.map(c => c.relPath);
|
|
1479
|
+
const bytes = preview.candidates.reduce((sum, c) => sum + c.bytes, 0);
|
|
1480
|
+
|
|
1481
|
+
if (mode === "quarantine") {
|
|
1482
|
+
return {
|
|
1483
|
+
ok: true,
|
|
1484
|
+
mode,
|
|
1485
|
+
percent,
|
|
1486
|
+
count: removedPaths.length,
|
|
1487
|
+
bytes,
|
|
1488
|
+
trashDir,
|
|
1489
|
+
removedPaths,
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// Permanent: purge staged files only after a successful DB commit.
|
|
1494
|
+
const purge = purgeStaged(stageResult.staged, { failBasenames: failPurge });
|
|
1495
|
+
if (purge.remaining.length > 0) {
|
|
1496
|
+
// Overwrite the pre-commit manifest so recovery reflects what actually survived.
|
|
1497
|
+
const survivingRelPaths = new Set(purge.remaining.map(item => item.relPath));
|
|
1498
|
+
try {
|
|
1499
|
+
writePrivateFile(
|
|
1500
|
+
join(stageDir, "manifest.json"),
|
|
1501
|
+
JSON.stringify({
|
|
1502
|
+
quarantinedAt: epoch,
|
|
1503
|
+
mode: "permanent",
|
|
1504
|
+
percent,
|
|
1505
|
+
digest: preview.digest,
|
|
1506
|
+
purgeIncomplete: true,
|
|
1507
|
+
purgedRelPaths: purge.purged.map(item => item.relPath),
|
|
1508
|
+
entries: manifestEntries.filter(entry =>
|
|
1509
|
+
entry.physicalRelPaths.some(rel => survivingRelPaths.has(rel)),
|
|
1510
|
+
),
|
|
1511
|
+
}, null, 2),
|
|
1512
|
+
);
|
|
1513
|
+
} catch { /* best-effort: the pre-commit manifest is still on disk */ }
|
|
1514
|
+
return {
|
|
1515
|
+
ok: false,
|
|
1516
|
+
mode,
|
|
1517
|
+
percent,
|
|
1518
|
+
count: 0,
|
|
1519
|
+
bytes: 0,
|
|
1520
|
+
trashDir,
|
|
1521
|
+
removedPaths: [],
|
|
1522
|
+
error: "fs_failed",
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
try { rmSync(stageDir, { recursive: true, force: true }); } catch { /* empty dir */ }
|
|
1527
|
+
// Drop an empty `.trash` root so permanent cleanup leaves no quarantine tree behind.
|
|
1528
|
+
removeEmptyTrashRoot(codexHome);
|
|
1529
|
+
|
|
1530
|
+
return {
|
|
1531
|
+
ok: true,
|
|
1532
|
+
mode,
|
|
1533
|
+
percent,
|
|
1534
|
+
count: removedPaths.length,
|
|
1535
|
+
bytes,
|
|
1536
|
+
removedPaths,
|
|
1537
|
+
};
|
|
1538
|
+
}
|