@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913

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.
Files changed (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,258 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import type { CleanupErrorCode } from "./types";
5
+
6
+ const STATE_DB_FILE = /^state_(\d+)\.sqlite$/;
7
+
8
+ const LOGS_DB_FILE = /^logs_(\d+)\.sqlite$/;
9
+
10
+ const GOALS_DB_FILE = /^goals_(\d+)\.sqlite$/;
11
+
12
+ const MEMORIES_DB_FILE = /^memories_(\d+)\.sqlite$/;
13
+
14
+ /** Chunk size for `IN (...)` binds; spawn-edge checks bind each id twice. */
15
+ export const SQLITE_ID_CHUNK = 200;
16
+
17
+ export function chunkIds(ids: string[], chunkSize: number): string[][] {
18
+ const chunks: string[][] = [];
19
+ for (let i = 0; i < ids.length; i += chunkSize) chunks.push(ids.slice(i, i + chunkSize));
20
+ return chunks;
21
+ }
22
+
23
+ /** Newest `prefix_N.sqlite` under CODEX_HOME, or null when absent. */
24
+ function newestVersionedDb(codexHome: string, pattern: RegExp): string | null {
25
+ let best: string | null = null;
26
+ let bestVersion = -1;
27
+ let names: string[] = [];
28
+ try {
29
+ names = readdirSync(codexHome);
30
+ } catch {
31
+ return null;
32
+ }
33
+ for (const name of names) {
34
+ const match = name.match(pattern);
35
+ if (!match) continue;
36
+ const version = Number(match[1]);
37
+ if (version > bestVersion) {
38
+ bestVersion = version;
39
+ best = name;
40
+ }
41
+ }
42
+ return best ? join(codexHome, best) : null;
43
+ }
44
+
45
+ function newestStateDb(codexHome: string): string | null {
46
+ return newestVersionedDb(codexHome, STATE_DB_FILE);
47
+ }
48
+
49
+ export interface RuntimeDbPaths {
50
+ state: string | null;
51
+ logs: string | null;
52
+ goals: string | null;
53
+ memories: string | null;
54
+ }
55
+
56
+ export function discoverRuntimeDbPaths(codexHome: string): RuntimeDbPaths {
57
+ return {
58
+ state: newestVersionedDb(codexHome, STATE_DB_FILE),
59
+ logs: newestVersionedDb(codexHome, LOGS_DB_FILE),
60
+ goals: newestVersionedDb(codexHome, GOALS_DB_FILE),
61
+ memories: newestVersionedDb(codexHome, MEMORIES_DB_FILE),
62
+ };
63
+ }
64
+
65
+ export function openDbWritable(dbPath: string, busyTimeoutMs = 100): Database {
66
+ const db = new Database(dbPath);
67
+ try {
68
+ // bun:sqlite exposes a binding-level timeout; set both so Windows lock waits
69
+ // honor the caller's budget (pragma alone has been flaky under CI contention).
70
+ (db as Database & { timeout?: number }).timeout = busyTimeoutMs;
71
+ } catch {
72
+ /* older bindings */
73
+ }
74
+ try {
75
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
76
+ } catch {
77
+ /* older sqlite */
78
+ }
79
+ try {
80
+ db.exec("PRAGMA foreign_keys = ON");
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ return db;
85
+ }
86
+
87
+ function isBusyError(error: unknown): boolean {
88
+ const msg = error instanceof Error ? error.message : String(error);
89
+ const code = (error as { code?: string })?.code ?? "";
90
+ return (
91
+ code === "SQLITE_BUSY" ||
92
+ code === "SQLITE_LOCKED" ||
93
+ /SQLITE_BUSY|SQLITE_LOCKED|database is locked|database table is locked/i.test(msg)
94
+ );
95
+ }
96
+
97
+ export function mapDbError(error: unknown): CleanupErrorCode {
98
+ if (isBusyError(error)) return "codex_busy";
99
+ return "db_reconcile_failed";
100
+ }
101
+
102
+ /** Probe a single DB with BEGIN IMMEDIATE; missing path is a no-op success. */
103
+ function probeDbWritable(
104
+ path: string | null,
105
+ busyTimeoutMs: number,
106
+ ): { ok: true } | { ok: false; error: CleanupErrorCode } {
107
+ if (!path || !existsSync(path)) return { ok: true };
108
+ let db: Database | undefined;
109
+ try {
110
+ db = openDbWritable(path, busyTimeoutMs);
111
+ db.exec("BEGIN IMMEDIATE");
112
+ db.exec("ROLLBACK");
113
+ return { ok: true };
114
+ } catch (error) {
115
+ if (isBusyError(error)) return { ok: false, error: "codex_busy" };
116
+ return { ok: false, error: "db_reconcile_failed" };
117
+ } finally {
118
+ try { db?.close(); } catch { /* */ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * True when every present Codex runtime DB can be written (BEGIN IMMEDIATE).
124
+ * Busy / corrupt stores abort cleanup before any filesystem mutation.
125
+ */
126
+ export function probeStateDbWritable(
127
+ codexHome: string,
128
+ busyTimeoutMs = 100,
129
+ ): { ok: true; path: string } | { ok: false; error: CleanupErrorCode } {
130
+ const paths = discoverRuntimeDbPaths(codexHome);
131
+ for (const path of [paths.state, paths.logs, paths.goals, paths.memories]) {
132
+ const probed = probeDbWritable(path, busyTimeoutMs);
133
+ if (!probed.ok) return probed;
134
+ }
135
+ return { ok: true, path: paths.state ?? "" };
136
+ }
137
+
138
+ export function tableExists(db: Database, name: string): boolean {
139
+ const row = db.query<{ name: string }, [string]>(
140
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
141
+ ).get(name);
142
+ return Boolean(row);
143
+ }
144
+
145
+ export function columnExists(db: Database, table: string, column: string): boolean {
146
+ if (!tableExists(db, table)) return false;
147
+ // `table` is only ever a hardcoded identifier already verified via sqlite_master.
148
+ const rows = db.query<{ name: string }, []>(
149
+ `PRAGMA table_info("${table.replaceAll('"', '""')}")`,
150
+ ).all();
151
+ return rows.some(r => r.name === column);
152
+ }
153
+
154
+ export interface ReconcileErr {
155
+ ok: false;
156
+ error: CleanupErrorCode;
157
+ /** True when satellite rows were mutated and could not all be restored. */
158
+ satelliteRestoreFailed?: boolean;
159
+ }
160
+
161
+ export type SqlRow = Record<string, string | number | bigint | null | Uint8Array>;
162
+
163
+ function quoteIdent(name: string): string {
164
+ return `"${name.replaceAll('"', '""')}"`;
165
+ }
166
+
167
+ export function selectRows(db: Database, sql: string, params: Array<string | number>): SqlRow[] {
168
+ return db.query<SqlRow, Array<string | number>>(sql).all(...params) as SqlRow[];
169
+ }
170
+
171
+ export function tableColumnNames(db: Database, table: string): Set<string> {
172
+ if (!tableExists(db, table)) return new Set();
173
+ const rows = db.query<{ name: string }, []>(
174
+ `PRAGMA table_info("${table.replaceAll('"', '""')}")`,
175
+ ).all();
176
+ return new Set(rows.map(r => r.name));
177
+ }
178
+
179
+ /** Insert rows with ON CONFLICT DO NOTHING; returns only rows that were newly inserted. */
180
+ export function insertRowsConflictIgnore(db: Database, table: string, rows: SqlRow[]): SqlRow[] {
181
+ const inserted: SqlRow[] = [];
182
+ if (rows.length === 0) return inserted;
183
+ const allowed = tableColumnNames(db, table);
184
+ for (const row of rows) {
185
+ const cols = Object.keys(row).filter(c => allowed.has(c));
186
+ if (cols.length === 0) continue;
187
+ const result = db.run(
188
+ `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`,
189
+ cols.map(c => row[c] as string | number | bigint | null | Uint8Array),
190
+ );
191
+ if (result.changes > 0) inserted.push(row);
192
+ }
193
+ return inserted;
194
+ }
195
+
196
+ export function updateRowFromSnapshot(
197
+ db: Database,
198
+ table: string,
199
+ row: SqlRow,
200
+ pkCols: string[],
201
+ ): void {
202
+ const cols = Object.keys(row).filter(c => !pkCols.includes(c));
203
+ if (cols.length === 0) return;
204
+ const sets = cols.map(c => `${quoteIdent(c)} = ?`).join(", ");
205
+ const where = pkCols.map(c => `${quoteIdent(c)} = ?`).join(" AND ");
206
+ db.run(
207
+ `UPDATE ${quoteIdent(table)} SET ${sets} WHERE ${where}`,
208
+ [
209
+ ...cols.map(c => row[c] as string | number | bigint | null | Uint8Array),
210
+ ...pkCols.map(c => row[c] as string | number | bigint | null | Uint8Array),
211
+ ],
212
+ );
213
+ }
214
+
215
+ function normalizeSqlValue(
216
+ v: string | number | bigint | null | Uint8Array | undefined,
217
+ ): string {
218
+ if (v === null || v === undefined) return "";
219
+ if (typeof v === "bigint") return v.toString();
220
+ if (v instanceof Uint8Array) return Buffer.from(v).toString("base64");
221
+ return String(v);
222
+ }
223
+
224
+ export function sqlRowEqual(a: SqlRow, b: SqlRow): boolean {
225
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
226
+ for (const key of keys) {
227
+ if (normalizeSqlValue(a[key]) !== normalizeSqlValue(b[key])) return false;
228
+ }
229
+ return true;
230
+ }
231
+
232
+ export function withWritableDb(
233
+ path: string,
234
+ busyTimeoutMs: number,
235
+ body: (db: Database) => void,
236
+ ): { ok: true } | ReconcileErr {
237
+ let db: Database | undefined;
238
+ try {
239
+ db = openDbWritable(path, busyTimeoutMs);
240
+ db.exec("BEGIN IMMEDIATE");
241
+ try {
242
+ body(db);
243
+ db.exec("COMMIT");
244
+ return { ok: true };
245
+ } catch (error) {
246
+ try { db.exec("ROLLBACK"); } catch { /* */ }
247
+ throw error;
248
+ }
249
+ } catch (error) {
250
+ return { ok: false, error: mapDbError(error) };
251
+ } finally {
252
+ try { db?.close(); } catch { /* */ }
253
+ }
254
+ }
255
+
256
+ export function isSqlRowArray(value: unknown): value is SqlRow[] {
257
+ return Array.isArray(value) && value.every(row => row && typeof row === "object" && !Array.isArray(row));
258
+ }
@@ -0,0 +1,358 @@
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveCodexHomeDir } from "../../codex/home";
4
+ import { discoverRuntimeDbPaths, probeStateDbWritable } from "./db";
5
+ import { createExclusiveStageDir, normalizeArchivedRolloutPath, writePrivateFile } from "./paths";
6
+ import { collectRestorePendingAcceptedDestRels } from "./pending";
7
+ import { candidateOverlapsPendingRestore, clampPercent, computeExactPreviewDigest, computePreviewDigest, listArchivedCandidates, previewArchivedCleanup, previewExactArchivedCleanup, resolveExactArchivedCandidates, selectOldestPercent } from "./preview";
8
+ import { loadThreadsForCleanup, reconcileDeletedThreads } from "./reconcile";
9
+ import type { ThreadSnapshot } from "./reconcile";
10
+ import { purgeStaged, removeEmptyTrashRoot, removeStageIfEmpty, rollbackStaged, stageCandidates, trashRelPath } from "./staging";
11
+ import type { ArchivedCandidate, CleanupErrorCode, CleanupManifestEntry, CleanupMode, CleanupPreview, CleanupResult } from "./types";
12
+
13
+ export interface ExecuteCleanupOptions {
14
+ percent: number;
15
+ mode: CleanupMode;
16
+ /** Required digest from preview; rejects when the candidate set drifted. */
17
+ digest: string;
18
+ /**
19
+ * Optional exact candidate set (logical relPaths). When set, selection bypasses
20
+ * percent rounding and the digest must match `computeExactPreviewDigest`.
21
+ */
22
+ candidateRelPaths?: string[];
23
+ codexHome?: string;
24
+ /** Test-only: shrink busy_timeout so lock tests fail fast. */
25
+ busyTimeoutMs?: number;
26
+ now?: number;
27
+ /** Test-only failure injection for atomicity regressions. */
28
+ _test?: {
29
+ failManifestWrite?: boolean;
30
+ /** Observe the complete temp and prior destination before publication. Never serialized. */
31
+ beforeManifestReplace?: (
32
+ temporaryPath: string,
33
+ targetPath: string,
34
+ phase: "staging" | "pre-commit" | "purge-incomplete",
35
+ ) => void;
36
+ failPurgeBasenames?: string[];
37
+ failRollbackBasenames?: string[];
38
+ blockStageDestBasenames?: string[];
39
+ failAfterLogsMutation?: boolean;
40
+ failAfterMemoriesMutation?: boolean;
41
+ failAfterGoalsMutation?: boolean;
42
+ failBeforeStateCommit?: boolean;
43
+ failSatelliteRestore?: boolean;
44
+ failSatelliteBackupWrite?: boolean;
45
+ failSatelliteBackupReplace?: boolean;
46
+ afterSatelliteMutations?: () => void;
47
+ beforeReconcileLock?: () => void;
48
+ };
49
+ }
50
+
51
+ /** Serializable cleanup test hooks allowed on the management API wire. */
52
+ export type CleanupWireTestHooks = Omit<
53
+ NonNullable<ExecuteCleanupOptions["_test"]>,
54
+ "afterSatelliteMutations" | "beforeReconcileLock" | "beforeManifestReplace"
55
+ >;
56
+
57
+ function isStringArray(v: unknown): v is string[] {
58
+ return Array.isArray(v) && v.every(e => typeof e === "string");
59
+ }
60
+
61
+ /** Pick only allowlisted serializable hooks; drops all function hooks and unknown keys. */
62
+ export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | undefined {
63
+ if (!raw || typeof raw !== "object") return undefined;
64
+ const o = raw as Record<string, unknown>;
65
+ const out: CleanupWireTestHooks = {};
66
+ if (typeof o.failManifestWrite === "boolean") out.failManifestWrite = o.failManifestWrite;
67
+ if (isStringArray(o.failPurgeBasenames)) out.failPurgeBasenames = o.failPurgeBasenames;
68
+ if (isStringArray(o.failRollbackBasenames)) out.failRollbackBasenames = o.failRollbackBasenames;
69
+ if (isStringArray(o.blockStageDestBasenames)) out.blockStageDestBasenames = o.blockStageDestBasenames;
70
+ if (typeof o.failAfterLogsMutation === "boolean") out.failAfterLogsMutation = o.failAfterLogsMutation;
71
+ if (typeof o.failAfterMemoriesMutation === "boolean") out.failAfterMemoriesMutation = o.failAfterMemoriesMutation;
72
+ if (typeof o.failAfterGoalsMutation === "boolean") out.failAfterGoalsMutation = o.failAfterGoalsMutation;
73
+ if (typeof o.failBeforeStateCommit === "boolean") out.failBeforeStateCommit = o.failBeforeStateCommit;
74
+ if (typeof o.failSatelliteRestore === "boolean") out.failSatelliteRestore = o.failSatelliteRestore;
75
+ if (typeof o.failSatelliteBackupWrite === "boolean") out.failSatelliteBackupWrite = o.failSatelliteBackupWrite;
76
+ if (typeof o.failSatelliteBackupReplace === "boolean") out.failSatelliteBackupReplace = o.failSatelliteBackupReplace;
77
+ return Object.keys(out).length > 0 ? out : undefined;
78
+ }
79
+
80
+ function fail(
81
+ mode: CleanupMode,
82
+ percent: number,
83
+ error: CleanupErrorCode,
84
+ extra?: { trashDir?: string },
85
+ ): CleanupResult {
86
+ return {
87
+ ok: false,
88
+ mode,
89
+ percent,
90
+ count: 0,
91
+ bytes: 0,
92
+ removedPaths: [],
93
+ error,
94
+ ...(extra?.trashDir ? { trashDir: extra.trashDir } : {}),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Execute archived cleanup bound to a preview digest.
100
+ * Stages every physical file, writes the recovery manifest, then commits DB deletes.
101
+ * Rollback never deletes a stage directory that still holds unrestored files.
102
+ */
103
+ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupResult {
104
+ const codexHome = options.codexHome ?? resolveCodexHomeDir();
105
+ const mode = options.mode;
106
+ const percent = clampPercent(options.percent);
107
+ const busyTimeoutMs = options.busyTimeoutMs ?? 100;
108
+ const failRollback = new Set(options._test?.failRollbackBasenames ?? []);
109
+ const failPurge = new Set(options._test?.failPurgeBasenames ?? []);
110
+ const blockStageDest = new Set(options._test?.blockStageDestBasenames ?? []);
111
+
112
+ if (mode !== "quarantine" && mode !== "permanent") {
113
+ return fail(mode, percent, "invalid_mode");
114
+ }
115
+ if (typeof options.digest !== "string" || !/^[a-f0-9]{64}$/i.test(options.digest)) {
116
+ return fail(mode, percent, "invalid_digest");
117
+ }
118
+
119
+ let preview: CleanupPreview;
120
+ let unfilteredSelected: ArchivedCandidate[];
121
+ if (options.candidateRelPaths !== undefined) {
122
+ const selected = resolveExactArchivedCandidates(options.candidateRelPaths, codexHome);
123
+ if (selected === null) {
124
+ return fail(mode, percent, "stale_preview");
125
+ }
126
+ unfilteredSelected = selected;
127
+ preview = previewExactArchivedCleanup(selected, codexHome);
128
+ } else {
129
+ const all = listArchivedCandidates(codexHome);
130
+ unfilteredSelected = selectOldestPercent(all, percent);
131
+ preview = previewArchivedCleanup(percent, codexHome);
132
+ }
133
+ if (preview.digest.toLowerCase() !== options.digest.toLowerCase()) {
134
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
135
+ const blocked = unfilteredSelected.filter(c => candidateOverlapsPendingRestore(c, pendingDestRels));
136
+ const unfilteredDigest = options.candidateRelPaths !== undefined
137
+ ? computeExactPreviewDigest(unfilteredSelected)
138
+ : computePreviewDigest(unfilteredSelected, percent);
139
+ if (
140
+ unfilteredDigest.toLowerCase() === options.digest.toLowerCase()
141
+ && blocked.length > 0
142
+ ) {
143
+ return fail(mode, percent, "restore_pending_overlap");
144
+ }
145
+ return fail(mode, percent, "stale_preview");
146
+ }
147
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
148
+ if (preview.candidates.some(c => candidateOverlapsPendingRestore(c, pendingDestRels))) {
149
+ return fail(mode, percent, "restore_pending_overlap");
150
+ }
151
+
152
+ if (preview.candidates.length === 0) {
153
+ return {
154
+ ok: true,
155
+ mode,
156
+ percent,
157
+ count: 0,
158
+ bytes: 0,
159
+ removedPaths: [],
160
+ };
161
+ }
162
+
163
+ const paths = discoverRuntimeDbPaths(codexHome);
164
+ const probe = probeStateDbWritable(codexHome, busyTimeoutMs);
165
+ if (!probe.ok) {
166
+ return fail(mode, percent, probe.error);
167
+ }
168
+
169
+ // Preflight referenced-history / matching while DB is free, before any rename.
170
+ const loaded = loadThreadsForCleanup(paths.state ?? "", preview.candidates, codexHome, busyTimeoutMs);
171
+ if (!loaded.ok) {
172
+ return fail(mode, percent, loaded.error);
173
+ }
174
+
175
+ const epoch = options.now ?? Date.now();
176
+ let stageDir: string;
177
+ try {
178
+ stageDir = createExclusiveStageDir(codexHome, epoch);
179
+ } catch {
180
+ return fail(mode, percent, "fs_failed");
181
+ }
182
+ const trashDir = trashRelPath(codexHome, stageDir);
183
+
184
+ const threadByRelPath = new Map<string, ThreadSnapshot>();
185
+ for (const thread of loaded.threads) {
186
+ const normalized = normalizeArchivedRolloutPath(thread.rollout_path, codexHome);
187
+ if (normalized) threadByRelPath.set(normalized, thread);
188
+ }
189
+ const skippedReferencedPaths = loaded.skipped
190
+ .map(thread => normalizeArchivedRolloutPath(thread.rollout_path, codexHome))
191
+ .filter((path): path is string => path !== null);
192
+ const matchedPaths = new Set([
193
+ ...threadByRelPath.keys(),
194
+ ...skippedReferencedPaths,
195
+ ]);
196
+ const candidates = preview.candidates.filter(candidate => {
197
+ return !matchedPaths.has(candidate.relPath) || threadByRelPath.has(candidate.relPath);
198
+ });
199
+ if (candidates.length === 0) {
200
+ removeStageIfEmpty(stageDir, []);
201
+ removeEmptyTrashRoot(codexHome);
202
+ return {
203
+ ok: true,
204
+ mode,
205
+ percent,
206
+ count: 0,
207
+ bytes: 0,
208
+ removedPaths: [],
209
+ ...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}),
210
+ };
211
+ }
212
+ const manifestEntries: CleanupManifestEntry[] = candidates.map(candidate => {
213
+ const thread = threadByRelPath.get(candidate.relPath);
214
+ return {
215
+ relPath: candidate.relPath,
216
+ bytes: candidate.bytes,
217
+ mtimeMs: candidate.mtimeMs,
218
+ physicalRelPaths: candidate.physicalRelPaths,
219
+ ...(thread
220
+ ? { threadId: thread.id, rolloutPath: thread.rollout_path, archived: thread.archived }
221
+ : {}),
222
+ };
223
+ });
224
+
225
+ const writeManifest = (extra: Record<string, unknown> = {}) => {
226
+ writePrivateFile(
227
+ join(stageDir, "manifest.json"),
228
+ JSON.stringify({
229
+ quarantinedAt: epoch,
230
+ mode,
231
+ percent,
232
+ digest: preview.digest,
233
+ entries: manifestEntries,
234
+ ...extra,
235
+ }, null, 2),
236
+ (temporaryPath, targetPath) => options._test?.beforeManifestReplace?.(
237
+ temporaryPath, targetPath, extra.staging ? "staging" : "pre-commit",
238
+ ),
239
+ );
240
+ };
241
+
242
+ // Journal staged paths before the first rename so a crash mid-stage is recoverable.
243
+ try {
244
+ if (options._test?.failManifestWrite) {
245
+ throw new Error("test_fail_manifest_write");
246
+ }
247
+ writeManifest({ staging: true });
248
+ } catch {
249
+ removeStageIfEmpty(stageDir, []);
250
+ return fail(mode, percent, "fs_failed");
251
+ }
252
+
253
+ const stageResult = stageCandidates(codexHome, candidates, stageDir, {
254
+ blockDestBasenames: blockStageDest.size > 0 ? blockStageDest : undefined,
255
+ });
256
+ if (!stageResult.ok) {
257
+ const rolled = rollbackStaged(stageResult.staged, { failBasenames: failRollback });
258
+ removeStageIfEmpty(stageDir, rolled.remaining);
259
+ return fail(mode, percent, "fs_failed", rolled.restored ? undefined : { trashDir });
260
+ }
261
+
262
+ // Final manifest before DB deletion so a mid-flight crash still has recovery metadata.
263
+ try {
264
+ writeManifest();
265
+ } catch {
266
+ const rolled = rollbackStaged(stageResult.staged, { failBasenames: failRollback });
267
+ removeStageIfEmpty(stageDir, rolled.remaining);
268
+ return fail(mode, percent, "fs_failed", rolled.restored ? undefined : { trashDir });
269
+ }
270
+
271
+ const deleted = reconcileDeletedThreads(
272
+ paths,
273
+ candidates,
274
+ codexHome,
275
+ busyTimeoutMs,
276
+ stageDir,
277
+ options._test,
278
+ );
279
+ if (!deleted.ok) {
280
+ const rolled = rollbackStaged(stageResult.staged, { failBasenames: failRollback });
281
+ // Keep the stage (and recovery manifest) when files or satellite DB rows remain unrestored.
282
+ const keepTrash = Boolean(deleted.satelliteRestoreFailed) || !rolled.restored;
283
+ if (!keepTrash) {
284
+ removeStageIfEmpty(stageDir, rolled.remaining);
285
+ removeEmptyTrashRoot(codexHome);
286
+ }
287
+ return fail(mode, percent, deleted.error, keepTrash ? { trashDir } : undefined);
288
+ }
289
+
290
+ const removedPaths = candidates.map(c => c.relPath);
291
+ const bytes = candidates.reduce((sum, c) => sum + c.bytes, 0);
292
+
293
+ if (mode === "quarantine") {
294
+ return {
295
+ ok: true,
296
+ mode,
297
+ percent,
298
+ count: removedPaths.length,
299
+ bytes,
300
+ trashDir,
301
+ removedPaths,
302
+ ...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}),
303
+ };
304
+ }
305
+
306
+ // Permanent: purge staged files only after a successful DB commit.
307
+ const purge = purgeStaged(stageResult.staged, { failBasenames: failPurge });
308
+ if (purge.remaining.length > 0) {
309
+ // Overwrite the pre-commit manifest so recovery reflects what actually survived.
310
+ const survivingRelPaths = new Set(purge.remaining.map(item => item.relPath));
311
+ try {
312
+ writePrivateFile(
313
+ join(stageDir, "manifest.json"),
314
+ JSON.stringify({
315
+ quarantinedAt: epoch,
316
+ mode: "permanent",
317
+ percent,
318
+ digest: preview.digest,
319
+ purgeIncomplete: true,
320
+ purgedRelPaths: purge.purged.map(item => item.relPath),
321
+ entries: manifestEntries
322
+ .map(entry => ({
323
+ ...entry,
324
+ physicalRelPaths: entry.physicalRelPaths.filter(rel => survivingRelPaths.has(rel)),
325
+ }))
326
+ .filter(entry => entry.physicalRelPaths.length > 0),
327
+ }, null, 2),
328
+ (temporaryPath, targetPath) => options._test?.beforeManifestReplace?.(
329
+ temporaryPath, targetPath, "purge-incomplete",
330
+ ),
331
+ );
332
+ } catch { /* best-effort: the pre-commit manifest is still on disk */ }
333
+ return {
334
+ ok: false,
335
+ mode,
336
+ percent,
337
+ count: 0,
338
+ bytes: 0,
339
+ trashDir,
340
+ removedPaths: [],
341
+ error: "fs_failed",
342
+ };
343
+ }
344
+
345
+ try { rmSync(stageDir, { recursive: true, force: true }); } catch { /* empty dir */ }
346
+ // Drop an empty `.trash` root so permanent cleanup leaves no quarantine tree behind.
347
+ removeEmptyTrashRoot(codexHome);
348
+
349
+ return {
350
+ ok: true,
351
+ mode,
352
+ percent,
353
+ count: removedPaths.length,
354
+ bytes,
355
+ removedPaths,
356
+ ...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}),
357
+ };
358
+ }