agentrinse 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +278 -143
  2. package/dist/adapters/codex-database.d.ts +64 -0
  3. package/dist/adapters/codex-database.d.ts.map +1 -0
  4. package/dist/adapters/codex-database.js +409 -0
  5. package/dist/adapters/codex-database.js.map +1 -0
  6. package/dist/adapters/git/adapter.d.ts +14 -1
  7. package/dist/adapters/git/adapter.d.ts.map +1 -1
  8. package/dist/adapters/git/adapter.js +217 -56
  9. package/dist/adapters/git/adapter.js.map +1 -1
  10. package/dist/adapters/git/refs.d.ts +6 -0
  11. package/dist/adapters/git/refs.d.ts.map +1 -0
  12. package/dist/adapters/git/refs.js +24 -0
  13. package/dist/adapters/git/refs.js.map +1 -0
  14. package/dist/adapters/git/status.d.ts +1 -0
  15. package/dist/adapters/git/status.d.ts.map +1 -1
  16. package/dist/adapters/git/status.js +6 -0
  17. package/dist/adapters/git/status.js.map +1 -1
  18. package/dist/adapters/provider-adapter.d.ts +4 -0
  19. package/dist/adapters/provider-adapter.d.ts.map +1 -1
  20. package/dist/adapters/provider-adapter.js +144 -3
  21. package/dist/adapters/provider-adapter.js.map +1 -1
  22. package/dist/adapters/provider-specs.d.ts.map +1 -1
  23. package/dist/adapters/provider-specs.js +15 -0
  24. package/dist/adapters/provider-specs.js.map +1 -1
  25. package/dist/adapters/registry.d.ts +3 -1
  26. package/dist/adapters/registry.d.ts.map +1 -1
  27. package/dist/adapters/registry.js +7 -2
  28. package/dist/adapters/registry.js.map +1 -1
  29. package/dist/commands/adapters.d.ts.map +1 -1
  30. package/dist/commands/adapters.js +3 -1
  31. package/dist/commands/adapters.js.map +1 -1
  32. package/dist/commands/apply.d.ts +2 -0
  33. package/dist/commands/apply.d.ts.map +1 -1
  34. package/dist/commands/apply.js +27 -2
  35. package/dist/commands/apply.js.map +1 -1
  36. package/dist/commands/audit.d.ts +1 -0
  37. package/dist/commands/audit.d.ts.map +1 -1
  38. package/dist/commands/audit.js +3 -1
  39. package/dist/commands/audit.js.map +1 -1
  40. package/dist/commands/clean.d.ts +2 -0
  41. package/dist/commands/clean.d.ts.map +1 -1
  42. package/dist/commands/clean.js +9 -3
  43. package/dist/commands/clean.js.map +1 -1
  44. package/dist/commands/completion.d.ts.map +1 -1
  45. package/dist/commands/completion.js +3 -1
  46. package/dist/commands/completion.js.map +1 -1
  47. package/dist/commands/confirmation.d.ts +6 -0
  48. package/dist/commands/confirmation.d.ts.map +1 -0
  49. package/dist/commands/confirmation.js +21 -0
  50. package/dist/commands/confirmation.js.map +1 -0
  51. package/dist/commands/doctor.d.ts.map +1 -1
  52. package/dist/commands/doctor.js +36 -2
  53. package/dist/commands/doctor.js.map +1 -1
  54. package/dist/commands/plan.d.ts +2 -0
  55. package/dist/commands/plan.d.ts.map +1 -1
  56. package/dist/commands/plan.js +11 -1
  57. package/dist/commands/plan.js.map +1 -1
  58. package/dist/commands/purge.d.ts +35 -0
  59. package/dist/commands/purge.d.ts.map +1 -0
  60. package/dist/commands/purge.js +191 -0
  61. package/dist/commands/purge.js.map +1 -0
  62. package/dist/commands/show.d.ts.map +1 -1
  63. package/dist/commands/show.js +17 -2
  64. package/dist/commands/show.js.map +1 -1
  65. package/dist/commands/undo.d.ts +24 -0
  66. package/dist/commands/undo.d.ts.map +1 -0
  67. package/dist/commands/undo.js +84 -0
  68. package/dist/commands/undo.js.map +1 -0
  69. package/dist/config/defaults.d.ts.map +1 -1
  70. package/dist/config/defaults.js +4 -0
  71. package/dist/config/defaults.js.map +1 -1
  72. package/dist/config/load.d.ts.map +1 -1
  73. package/dist/config/load.js +4 -0
  74. package/dist/config/load.js.map +1 -1
  75. package/dist/config/schema.d.ts +4 -0
  76. package/dist/config/schema.d.ts.map +1 -1
  77. package/dist/config/schema.js +18 -0
  78. package/dist/config/schema.js.map +1 -1
  79. package/dist/contracts/action.d.ts +241 -0
  80. package/dist/contracts/action.d.ts.map +1 -1
  81. package/dist/contracts/action.js +77 -1
  82. package/dist/contracts/action.js.map +1 -1
  83. package/dist/contracts/database-backup.d.ts +192 -0
  84. package/dist/contracts/database-backup.d.ts.map +1 -0
  85. package/dist/contracts/database-backup.js +70 -0
  86. package/dist/contracts/database-backup.js.map +1 -0
  87. package/dist/contracts/finding.d.ts +77 -0
  88. package/dist/contracts/finding.d.ts.map +1 -1
  89. package/dist/contracts/plan.d.ts +78 -0
  90. package/dist/contracts/plan.d.ts.map +1 -1
  91. package/dist/contracts/plan.js +29 -1
  92. package/dist/contracts/plan.js.map +1 -1
  93. package/dist/contracts/quarantine.d.ts +79 -0
  94. package/dist/contracts/quarantine.d.ts.map +1 -0
  95. package/dist/contracts/quarantine.js +60 -0
  96. package/dist/contracts/quarantine.js.map +1 -0
  97. package/dist/contracts/report.d.ts +77 -0
  98. package/dist/contracts/report.d.ts.map +1 -1
  99. package/dist/contracts/run.d.ts +233 -5
  100. package/dist/contracts/run.d.ts.map +1 -1
  101. package/dist/contracts/run.js +26 -3
  102. package/dist/contracts/run.js.map +1 -1
  103. package/dist/core/apply.d.ts +12 -1
  104. package/dist/core/apply.d.ts.map +1 -1
  105. package/dist/core/apply.js +209 -47
  106. package/dist/core/apply.js.map +1 -1
  107. package/dist/core/database-exclusion.d.ts +23 -0
  108. package/dist/core/database-exclusion.d.ts.map +1 -0
  109. package/dist/core/database-exclusion.js +133 -0
  110. package/dist/core/database-exclusion.js.map +1 -0
  111. package/dist/core/database-executor.d.ts +45 -0
  112. package/dist/core/database-executor.d.ts.map +1 -0
  113. package/dist/core/database-executor.js +436 -0
  114. package/dist/core/database-executor.js.map +1 -0
  115. package/dist/core/database-recovery.d.ts +25 -0
  116. package/dist/core/database-recovery.d.ts.map +1 -0
  117. package/dist/core/database-recovery.js +628 -0
  118. package/dist/core/database-recovery.js.map +1 -0
  119. package/dist/core/database-revalidation.d.ts +19 -0
  120. package/dist/core/database-revalidation.d.ts.map +1 -0
  121. package/dist/core/database-revalidation.js +70 -0
  122. package/dist/core/database-revalidation.js.map +1 -0
  123. package/dist/core/git-operation-state.d.ts +3 -0
  124. package/dist/core/git-operation-state.d.ts.map +1 -0
  125. package/dist/core/git-operation-state.js +23 -0
  126. package/dist/core/git-operation-state.js.map +1 -0
  127. package/dist/core/measure.d.ts +1 -0
  128. package/dist/core/measure.d.ts.map +1 -1
  129. package/dist/core/measure.js +3 -0
  130. package/dist/core/measure.js.map +1 -1
  131. package/dist/core/no-clobber-rename.d.ts +3 -0
  132. package/dist/core/no-clobber-rename.d.ts.map +1 -0
  133. package/dist/core/no-clobber-rename.js +102 -0
  134. package/dist/core/no-clobber-rename.js.map +1 -0
  135. package/dist/core/plan.d.ts.map +1 -1
  136. package/dist/core/plan.js +1 -0
  137. package/dist/core/plan.js.map +1 -1
  138. package/dist/core/worktree-executor.d.ts +51 -0
  139. package/dist/core/worktree-executor.d.ts.map +1 -0
  140. package/dist/core/worktree-executor.js +676 -0
  141. package/dist/core/worktree-executor.js.map +1 -0
  142. package/dist/core/worktree-lock.d.ts +15 -0
  143. package/dist/core/worktree-lock.d.ts.map +1 -0
  144. package/dist/core/worktree-lock.js +139 -0
  145. package/dist/core/worktree-lock.js.map +1 -0
  146. package/dist/core/worktree-recovery.d.ts +37 -0
  147. package/dist/core/worktree-recovery.d.ts.map +1 -0
  148. package/dist/core/worktree-recovery.js +1018 -0
  149. package/dist/core/worktree-recovery.js.map +1 -0
  150. package/dist/core/worktree-revalidation.d.ts +25 -0
  151. package/dist/core/worktree-revalidation.d.ts.map +1 -0
  152. package/dist/core/worktree-revalidation.js +107 -0
  153. package/dist/core/worktree-revalidation.js.map +1 -0
  154. package/dist/index.d.ts +4 -2
  155. package/dist/index.d.ts.map +1 -1
  156. package/dist/index.js +4 -2
  157. package/dist/index.js.map +1 -1
  158. package/dist/main.d.ts.map +1 -1
  159. package/dist/main.js +50 -0
  160. package/dist/main.js.map +1 -1
  161. package/dist/state/layout.d.ts +2 -0
  162. package/dist/state/layout.d.ts.map +1 -1
  163. package/dist/state/layout.js +2 -0
  164. package/dist/state/layout.js.map +1 -1
  165. package/dist/state/records.d.ts +6 -0
  166. package/dist/state/records.d.ts.map +1 -1
  167. package/dist/state/records.js +9 -1
  168. package/dist/state/records.js.map +1 -1
  169. package/dist/state/run-journal.d.ts.map +1 -1
  170. package/dist/state/run-journal.js +3 -0
  171. package/dist/state/run-journal.js.map +1 -1
  172. package/dist/version.d.ts +1 -1
  173. package/dist/version.js +1 -1
  174. package/package.json +2 -1
  175. package/schemas/audit.schema.json +355 -0
  176. package/schemas/database-backup.schema.json +648 -0
  177. package/schemas/plan.schema.json +346 -0
  178. package/schemas/quarantine.schema.json +238 -0
  179. package/schemas/run.schema.json +245 -52
@@ -0,0 +1,628 @@
1
+ import { lstat, rm } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { codexDatabaseContractMatches, inspectCodexDatabase, inspectCodexProcesses, inspectDatabaseOpenHandles, verifyCodexDatabaseIntegrity, } from "../adapters/codex-database.js";
4
+ import { databaseBackupEntrySchema, } from "../contracts/database-backup.js";
5
+ import { syncDirectory, writeJsonAtomic } from "../state/json-file.js";
6
+ import { acquireDatabaseExclusion, lockedFileIdentityMatches, } from "./database-exclusion.js";
7
+ import { exchangePaths, renameNoReplace } from "./no-clobber-rename.js";
8
+ async function pathExists(path) {
9
+ try {
10
+ await lstat(path);
11
+ return true;
12
+ }
13
+ catch (error) {
14
+ if (error instanceof Error &&
15
+ "code" in error &&
16
+ error.code === "ENOENT") {
17
+ return false;
18
+ }
19
+ throw error;
20
+ }
21
+ }
22
+ async function persist(options, entry) {
23
+ const parsed = databaseBackupEntrySchema.parse(entry);
24
+ await writeJsonAtomic(options.manifestPath, parsed, {
25
+ privateDirectories: [options.backupDirectory],
26
+ });
27
+ return parsed;
28
+ }
29
+ function assertRecoveryPaths(entry, options) {
30
+ const backupDirectory = resolve(options.backupDirectory);
31
+ const expectedBackupPath = join(backupDirectory, `${entry.entryId}-${entry.target.filename}.original`);
32
+ const expectedTemporaryPath = join(dirname(entry.originalPath), `.${entry.target.filename}.${entry.entryId}.vacuum-work`, entry.target.filename);
33
+ const expectedManifestPath = join(backupDirectory, `${entry.entryId}.json`);
34
+ if (resolve(entry.originalPath) !== resolve(entry.target.path) ||
35
+ resolve(entry.backupPath) !== expectedBackupPath ||
36
+ resolve(entry.temporaryPath) !== resolve(expectedTemporaryPath) ||
37
+ resolve(options.manifestPath) !== expectedManifestPath ||
38
+ (entry.backupWalPath !== undefined &&
39
+ resolve(entry.backupWalPath) !== `${expectedBackupPath}-wal`) ||
40
+ (entry.backupShmPath !== undefined &&
41
+ resolve(entry.backupShmPath) !== `${expectedBackupPath}-shm`)) {
42
+ throw new Error("database recovery manifest contains paths outside its owned backup set");
43
+ }
44
+ }
45
+ async function assertOffline(entry, dependencies, allowedHandlePids = new Set()) {
46
+ const processes = await (dependencies.inspectProcesses ?? inspectCodexProcesses)(dependencies);
47
+ if (processes.status !== "idle") {
48
+ throw new Error(processes.status === "busy"
49
+ ? "Codex is running; database recovery is refused"
50
+ : `Codex process state is unknown: ${processes.reason}`);
51
+ }
52
+ for (const path of [entry.originalPath, entry.backupPath, entry.temporaryPath]) {
53
+ const handles = await (dependencies.inspectOpenHandles ?? inspectDatabaseOpenHandles)(path, dependencies);
54
+ const externalPids = handles.status === "busy" ? handles.pids.filter((pid) => !allowedHandlePids.has(pid)) : [];
55
+ if (handles.status === "unknown" || (handles.status === "busy" && externalPids.length !== 0)) {
56
+ throw new Error(handles.status === "busy"
57
+ ? `a process has a database recovery path open: ${path}`
58
+ : `database descriptor state is unknown: ${handles.reason}`);
59
+ }
60
+ }
61
+ }
62
+ function assertLockedIdentity(exclusion, path, identity) {
63
+ if (!lockedFileIdentityMatches(exclusion.identities.get(path), identity)) {
64
+ throw new Error(`database identity changed while acquiring exclusive access: ${path}`);
65
+ }
66
+ }
67
+ async function markRestored(options, entry, clock) {
68
+ return persist(options, {
69
+ ...entry,
70
+ status: "restored",
71
+ restoredAt: clock().toISOString(),
72
+ diagnostic: undefined,
73
+ });
74
+ }
75
+ function backupSidecars(entry) {
76
+ return [
77
+ ...(entry.backupWalPath === undefined
78
+ ? []
79
+ : [{ backup: entry.backupWalPath, original: `${entry.originalPath}-wal` }]),
80
+ ...(entry.backupShmPath === undefined
81
+ ? []
82
+ : [{ backup: entry.backupShmPath, original: `${entry.originalPath}-shm` }]),
83
+ ];
84
+ }
85
+ async function restoreSidecars(entry, rename) {
86
+ for (const sidecar of backupSidecars(entry)) {
87
+ if (await pathExists(sidecar.backup)) {
88
+ if (await pathExists(sidecar.original)) {
89
+ throw new Error(`database sidecar restore destination is occupied: ${sidecar.original}`);
90
+ }
91
+ await rename(sidecar.backup, sidecar.original);
92
+ }
93
+ }
94
+ }
95
+ async function sidecarMatches(path, planned) {
96
+ try {
97
+ const stats = await lstat(path);
98
+ return (stats.isFile() &&
99
+ !stats.isSymbolicLink() &&
100
+ stats.dev === planned.device &&
101
+ stats.ino === planned.inode &&
102
+ stats.mode === planned.mode &&
103
+ stats.mtimeMs === planned.mtimeMs &&
104
+ stats.size === planned.measuredBytes);
105
+ }
106
+ catch (error) {
107
+ if (error instanceof Error &&
108
+ "code" in error &&
109
+ error.code === "ENOENT") {
110
+ return false;
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+ async function assertRecoverableSidecars(entry) {
116
+ for (const sidecar of [
117
+ {
118
+ planned: entry.target.wal,
119
+ canonical: `${entry.originalPath}-wal`,
120
+ backup: entry.backupWalPath,
121
+ label: "WAL",
122
+ },
123
+ {
124
+ planned: entry.target.shm,
125
+ canonical: `${entry.originalPath}-shm`,
126
+ backup: entry.backupShmPath,
127
+ label: "SHM",
128
+ },
129
+ ]) {
130
+ const canonicalExists = await pathExists(sidecar.canonical);
131
+ const backupExists = sidecar.backup === undefined ? false : await pathExists(sidecar.backup);
132
+ if (sidecar.planned === undefined) {
133
+ if (canonicalExists || backupExists) {
134
+ throw new Error(`unexpected database ${sidecar.label} appeared during recovery`);
135
+ }
136
+ continue;
137
+ }
138
+ if (canonicalExists === backupExists) {
139
+ throw new Error(`database ${sidecar.label} recovery location is ambiguous`);
140
+ }
141
+ const path = canonicalExists ? sidecar.canonical : sidecar.backup;
142
+ if (!(await sidecarMatches(path, sidecar.planned))) {
143
+ throw new Error(`database ${sidecar.label} no longer matches the recovery manifest`);
144
+ }
145
+ }
146
+ }
147
+ async function assertPurgeSidecars(entry) {
148
+ for (const sidecar of [
149
+ {
150
+ planned: entry.target.wal,
151
+ canonical: `${entry.originalPath}-wal`,
152
+ backup: entry.backupWalPath,
153
+ label: "WAL",
154
+ },
155
+ {
156
+ planned: entry.target.shm,
157
+ canonical: `${entry.originalPath}-shm`,
158
+ backup: entry.backupShmPath,
159
+ label: "SHM",
160
+ },
161
+ ]) {
162
+ if (await pathExists(sidecar.canonical)) {
163
+ throw new Error(`database ${sidecar.label} appeared at the canonical purge path`);
164
+ }
165
+ if (sidecar.backup === undefined) {
166
+ continue;
167
+ }
168
+ const backupExists = await pathExists(sidecar.backup);
169
+ if (!backupExists) {
170
+ if (entry.status === "purging") {
171
+ continue;
172
+ }
173
+ throw new Error(`database ${sidecar.label} rollback copy is missing`);
174
+ }
175
+ if (sidecar.planned === undefined || !(await sidecarMatches(sidecar.backup, sidecar.planned))) {
176
+ throw new Error(`database ${sidecar.label} no longer matches the purge manifest`);
177
+ }
178
+ }
179
+ }
180
+ async function removeTemporaryWorkspace(entry) {
181
+ await rm(dirname(entry.temporaryPath), { recursive: true, force: true });
182
+ await syncDirectory(dirname(entry.originalPath));
183
+ }
184
+ function movedSidecarMatches(actual, planned) {
185
+ return ((actual === undefined && planned === undefined) ||
186
+ (actual !== undefined &&
187
+ planned !== undefined &&
188
+ actual.device === planned.device &&
189
+ actual.inode === planned.inode &&
190
+ actual.mode === planned.mode &&
191
+ actual.mtimeMs === planned.mtimeMs &&
192
+ actual.measuredBytes === planned.measuredBytes));
193
+ }
194
+ function movedSidecarMatchesWhenPresent(actual, planned) {
195
+ return actual === undefined || (planned !== undefined && movedSidecarMatches(actual, planned));
196
+ }
197
+ function databaseModeMatches(actual, planned, allowSealedMode) {
198
+ return (actual === planned ||
199
+ (allowSealedMode && (actual & 0o222) === 0 && actual === (planned & ~0o222)));
200
+ }
201
+ function databaseMainIdentityMatches(actual, planned, allowSealedMode = false) {
202
+ return (actual.database === planned.database &&
203
+ actual.filename === planned.filename &&
204
+ actual.device === planned.device &&
205
+ actual.inode === planned.inode &&
206
+ databaseModeMatches(actual.mode, planned.mode, allowSealedMode) &&
207
+ actual.mtimeMs === planned.mtimeMs &&
208
+ actual.measuredBytes === planned.measuredBytes &&
209
+ actual.pageSize === planned.pageSize &&
210
+ actual.pageCount === planned.pageCount &&
211
+ actual.freelistCount === planned.freelistCount &&
212
+ actual.journalMode === planned.journalMode &&
213
+ actual.autoVacuum === planned.autoVacuum &&
214
+ actual.migrationVersion === planned.migrationVersion &&
215
+ actual.migrationDigest === planned.migrationDigest &&
216
+ actual.tables.join("\0") === planned.tables.join("\0") &&
217
+ actual.schemaDigest === planned.schemaDigest &&
218
+ actual.fingerprint === planned.fingerprint);
219
+ }
220
+ function movedOriginalMatches(actual, planned, allowSealedMode = false) {
221
+ return (databaseMainIdentityMatches(actual, planned, allowSealedMode) &&
222
+ movedSidecarMatches(actual.wal, planned.wal) &&
223
+ movedSidecarMatches(actual.shm, planned.shm));
224
+ }
225
+ async function verifyRetainedOriginal(entry, dependencies, inspect, allowMissingSidecars = false) {
226
+ await (dependencies.verifyIntegrity ?? verifyCodexDatabaseIntegrity)(entry.backupPath, dependencies);
227
+ const backup = await inspect(entry.backupPath, dependencies, entry.originalPath);
228
+ const planned = entry.backupIdentity ?? entry.target;
229
+ const allowSealedMode = ["installing", "restoring", "partial", "purging"].includes(entry.status);
230
+ const matches = databaseMainIdentityMatches(backup.identity, planned, allowSealedMode) &&
231
+ (allowMissingSidecars
232
+ ? movedSidecarMatchesWhenPresent(backup.identity.wal, planned.wal) &&
233
+ movedSidecarMatchesWhenPresent(backup.identity.shm, planned.shm)
234
+ : movedSidecarMatches(backup.identity.wal, planned.wal) &&
235
+ movedSidecarMatches(backup.identity.shm, planned.shm));
236
+ if (!matches || (backup.identity.wal?.measuredBytes ?? 0) > 0) {
237
+ throw new Error("the retained original database no longer matches its recovery manifest");
238
+ }
239
+ return { ...backup.identity, mode: planned.mode };
240
+ }
241
+ export async function undoDatabaseVacuum(input, options) {
242
+ let entry = databaseBackupEntrySchema.parse(input);
243
+ const interruptedState = ["installing", "restoring", "partial"].includes(entry.status);
244
+ assertRecoveryPaths(entry, options);
245
+ const dependencies = options.dependencies ?? {};
246
+ const clock = dependencies.clock ?? (() => new Date());
247
+ const rename = dependencies.rename ?? renameNoReplace;
248
+ const exchange = dependencies.exchange ?? exchangePaths;
249
+ const acquireExclusion = dependencies.acquireExclusion ?? acquireDatabaseExclusion;
250
+ const inspect = dependencies.inspectDatabase ?? inspectCodexDatabase;
251
+ await assertOffline(entry, dependencies);
252
+ const originalExists = await pathExists(entry.originalPath);
253
+ const backupExists = await pathExists(entry.backupPath);
254
+ const temporaryExists = await pathExists(entry.temporaryPath);
255
+ if (entry.status === "preparing") {
256
+ if (!originalExists || backupExists) {
257
+ throw new Error("interrupted database preparation has ambiguous file state");
258
+ }
259
+ await restoreSidecars(entry, rename);
260
+ if (temporaryExists) {
261
+ await removeTemporaryWorkspace(entry);
262
+ }
263
+ return markRestored(options, entry, clock);
264
+ }
265
+ if (entry.status === "vacuumed") {
266
+ if (originalExists && !backupExists) {
267
+ await restoreSidecars(entry, rename);
268
+ if (temporaryExists) {
269
+ await removeTemporaryWorkspace(entry);
270
+ }
271
+ return markRestored(options, entry, clock);
272
+ }
273
+ throw new Error("interrupted database vacuum has ambiguous file state");
274
+ }
275
+ if (entry.status === "installing" || entry.status === "partial") {
276
+ if (originalExists && backupExists && !temporaryExists) {
277
+ const original = await inspect(entry.originalPath, dependencies);
278
+ const backup = await inspect(entry.backupPath, dependencies, entry.originalPath, entry.originalPath);
279
+ const retainedOriginal = entry.backupIdentity ?? entry.target;
280
+ if (entry.installedIdentity !== undefined &&
281
+ databaseMainIdentityMatches(original.identity, entry.installedIdentity, true) &&
282
+ movedOriginalMatches(backup.identity, retainedOriginal, true)) {
283
+ entry = await persist(options, { ...entry, status: "installed" });
284
+ }
285
+ else if (entry.installedIdentity !== undefined &&
286
+ databaseMainIdentityMatches(original.identity, entry.target, true) &&
287
+ databaseMainIdentityMatches(backup.identity, entry.installedIdentity, true)) {
288
+ const exclusion = await acquireExclusion([entry.originalPath, entry.backupPath], process.platform, new Map([
289
+ [entry.originalPath, entry.target.mode],
290
+ [entry.backupPath, entry.installedIdentity.mode],
291
+ ]));
292
+ try {
293
+ assertLockedIdentity(exclusion, entry.originalPath, {
294
+ ...original.identity,
295
+ mode: entry.target.mode,
296
+ });
297
+ assertLockedIdentity(exclusion, entry.backupPath, {
298
+ ...backup.identity,
299
+ mode: entry.installedIdentity.mode,
300
+ });
301
+ await assertOffline(entry, dependencies, new Set([process.pid]));
302
+ await assertRecoverableSidecars(entry);
303
+ await restoreSidecars(entry, rename);
304
+ await rm(entry.backupPath);
305
+ await syncDirectory(options.backupDirectory);
306
+ await removeTemporaryWorkspace(entry);
307
+ return markRestored(options, entry, clock);
308
+ }
309
+ finally {
310
+ await exclusion.release();
311
+ }
312
+ }
313
+ else {
314
+ throw new Error("interrupted database installation has ambiguous file identities");
315
+ }
316
+ }
317
+ if (originalExists && !backupExists && !temporaryExists) {
318
+ const original = await inspect(entry.originalPath, dependencies);
319
+ if (!databaseMainIdentityMatches(original.identity, entry.target, true)) {
320
+ throw new Error("interrupted database rollback no longer contains the planned original");
321
+ }
322
+ const exclusion = await acquireExclusion([entry.originalPath], process.platform, new Map([[entry.originalPath, entry.target.mode]]));
323
+ try {
324
+ assertLockedIdentity(exclusion, entry.originalPath, {
325
+ ...original.identity,
326
+ mode: entry.target.mode,
327
+ });
328
+ await assertOffline(entry, dependencies, new Set([process.pid]));
329
+ await assertRecoverableSidecars(entry);
330
+ await restoreSidecars(entry, rename);
331
+ await removeTemporaryWorkspace(entry);
332
+ return markRestored(options, entry, clock);
333
+ }
334
+ finally {
335
+ await exclusion.release();
336
+ }
337
+ }
338
+ if (originalExists && !backupExists && temporaryExists) {
339
+ const original = await inspect(entry.originalPath, dependencies);
340
+ if (databaseMainIdentityMatches(original.identity, entry.target, true)) {
341
+ const temporary = await inspect(entry.temporaryPath, dependencies, entry.originalPath, entry.originalPath);
342
+ if (entry.installedIdentity === undefined ||
343
+ !databaseMainIdentityMatches(temporary.identity, entry.installedIdentity, true)) {
344
+ throw new Error("interrupted database exchange no longer contains the compacted copy");
345
+ }
346
+ const exclusion = await acquireExclusion([entry.originalPath, entry.temporaryPath], process.platform, new Map([
347
+ [entry.originalPath, entry.target.mode],
348
+ [entry.temporaryPath, entry.installedIdentity.mode],
349
+ ]));
350
+ try {
351
+ assertLockedIdentity(exclusion, entry.originalPath, {
352
+ ...original.identity,
353
+ mode: entry.target.mode,
354
+ });
355
+ assertLockedIdentity(exclusion, entry.temporaryPath, {
356
+ ...temporary.identity,
357
+ mode: entry.installedIdentity.mode,
358
+ });
359
+ await assertOffline(entry, dependencies, new Set([process.pid]));
360
+ await assertRecoverableSidecars(entry);
361
+ await restoreSidecars(entry, rename);
362
+ await removeTemporaryWorkspace(entry);
363
+ return markRestored(options, entry, clock);
364
+ }
365
+ finally {
366
+ await exclusion.release();
367
+ }
368
+ }
369
+ if (entry.installedIdentity === undefined ||
370
+ !databaseMainIdentityMatches(original.identity, entry.installedIdentity, true)) {
371
+ throw new Error("the compacted database changed after interrupted installation; automatic undo is no longer safe");
372
+ }
373
+ const temporary = await inspect(entry.temporaryPath, dependencies, entry.originalPath, entry.originalPath);
374
+ if (!databaseMainIdentityMatches(temporary.identity, entry.target, true)) {
375
+ throw new Error("interrupted database exchange no longer contains the planned original");
376
+ }
377
+ const exclusion = await acquireExclusion([entry.originalPath, entry.temporaryPath], process.platform, new Map([
378
+ [entry.originalPath, entry.installedIdentity.mode],
379
+ [entry.temporaryPath, entry.target.mode],
380
+ ]));
381
+ try {
382
+ assertLockedIdentity(exclusion, entry.originalPath, {
383
+ ...original.identity,
384
+ mode: entry.installedIdentity.mode,
385
+ });
386
+ assertLockedIdentity(exclusion, entry.temporaryPath, {
387
+ ...temporary.identity,
388
+ mode: entry.target.mode,
389
+ });
390
+ await assertOffline(entry, dependencies, new Set([process.pid]));
391
+ await assertRecoverableSidecars(entry);
392
+ await exchange(entry.originalPath, entry.temporaryPath);
393
+ await restoreSidecars(entry, rename);
394
+ await removeTemporaryWorkspace(entry);
395
+ return markRestored(options, entry, clock);
396
+ }
397
+ finally {
398
+ await exclusion.release();
399
+ }
400
+ }
401
+ if (["installing", "partial"].includes(entry.status)) {
402
+ throw new Error("interrupted database installation has ambiguous file state");
403
+ }
404
+ }
405
+ if (!["installed", "restoring"].includes(entry.status)) {
406
+ throw new Error(`database backup entry cannot be restored from ${entry.status}`);
407
+ }
408
+ const currentOriginalExists = await pathExists(entry.originalPath);
409
+ const currentBackupExists = await pathExists(entry.backupPath);
410
+ const currentTemporaryExists = await pathExists(entry.temporaryPath);
411
+ if (entry.status === "restoring") {
412
+ if (currentOriginalExists && !currentBackupExists && !currentTemporaryExists) {
413
+ const restored = await inspect(entry.originalPath, dependencies);
414
+ if (!databaseMainIdentityMatches(restored.identity, entry.target, true)) {
415
+ throw new Error("interrupted database restore no longer matches the retained original");
416
+ }
417
+ const exclusion = await acquireExclusion([entry.originalPath], process.platform, new Map([[entry.originalPath, entry.target.mode]]));
418
+ try {
419
+ assertLockedIdentity(exclusion, entry.originalPath, {
420
+ ...restored.identity,
421
+ mode: entry.target.mode,
422
+ });
423
+ await assertOffline(entry, dependencies, new Set([process.pid]));
424
+ await assertRecoverableSidecars(entry);
425
+ await restoreSidecars(entry, rename);
426
+ return markRestored(options, entry, clock);
427
+ }
428
+ finally {
429
+ await exclusion.release();
430
+ }
431
+ }
432
+ if (!(currentOriginalExists && currentBackupExists && !currentTemporaryExists)) {
433
+ throw new Error("interrupted database restore has ambiguous file state");
434
+ }
435
+ const restored = await inspect(entry.originalPath, dependencies);
436
+ const displaced = await inspect(entry.backupPath, dependencies, entry.originalPath, entry.originalPath);
437
+ if (databaseMainIdentityMatches(restored.identity, entry.target, true) &&
438
+ entry.installedIdentity !== undefined &&
439
+ databaseMainIdentityMatches(displaced.identity, entry.installedIdentity, true)) {
440
+ const exclusion = await acquireExclusion([entry.originalPath, entry.backupPath], process.platform, new Map([
441
+ [entry.originalPath, entry.target.mode],
442
+ [entry.backupPath, entry.installedIdentity.mode],
443
+ ]));
444
+ try {
445
+ assertLockedIdentity(exclusion, entry.originalPath, {
446
+ ...restored.identity,
447
+ mode: entry.target.mode,
448
+ });
449
+ assertLockedIdentity(exclusion, entry.backupPath, {
450
+ ...displaced.identity,
451
+ mode: entry.installedIdentity.mode,
452
+ });
453
+ await assertOffline(entry, dependencies, new Set([process.pid]));
454
+ await assertRecoverableSidecars(entry);
455
+ await restoreSidecars(entry, rename);
456
+ await rm(entry.backupPath);
457
+ await syncDirectory(options.backupDirectory);
458
+ return markRestored(options, entry, clock);
459
+ }
460
+ finally {
461
+ await exclusion.release();
462
+ }
463
+ }
464
+ }
465
+ if (!currentOriginalExists || !currentBackupExists || currentTemporaryExists) {
466
+ throw new Error("database restore paths do not match a recoverable state");
467
+ }
468
+ const installed = await inspect(entry.originalPath, dependencies);
469
+ if (entry.installedIdentity === undefined ||
470
+ (interruptedState
471
+ ? !databaseMainIdentityMatches(installed.identity, entry.installedIdentity, true)
472
+ : installed.identity.fingerprint !== entry.installedIdentity.fingerprint) ||
473
+ installed.sidecarsPresent) {
474
+ throw new Error("the compacted database changed after installation; automatic undo is no longer safe");
475
+ }
476
+ const backupIdentity = await verifyRetainedOriginal(entry, dependencies, inspect);
477
+ await assertOffline(entry, dependencies);
478
+ const boundaryInstalled = await inspect(entry.originalPath, dependencies);
479
+ if (entry.installedIdentity === undefined ||
480
+ (interruptedState
481
+ ? !databaseMainIdentityMatches(boundaryInstalled.identity, entry.installedIdentity, true)
482
+ : boundaryInstalled.identity.fingerprint !== entry.installedIdentity.fingerprint) ||
483
+ boundaryInstalled.sidecarsPresent) {
484
+ throw new Error("the compacted database changed before the restore boundary; automatic undo is no longer safe");
485
+ }
486
+ const installedIdentity = entry.installedIdentity;
487
+ entry = await persist(options, { ...entry, status: "restoring", backupIdentity });
488
+ const exclusion = await acquireExclusion([entry.originalPath, entry.backupPath], process.platform, new Map([
489
+ [entry.originalPath, installedIdentity.mode],
490
+ [entry.backupPath, backupIdentity.mode],
491
+ ]));
492
+ let exchanged = false;
493
+ try {
494
+ // Keep both inodes excluded until the restored layout and manifest agree;
495
+ // a crash before then is resumed from exact identities above.
496
+ assertLockedIdentity(exclusion, entry.originalPath, {
497
+ ...boundaryInstalled.identity,
498
+ mode: installedIdentity.mode,
499
+ });
500
+ assertLockedIdentity(exclusion, entry.backupPath, backupIdentity);
501
+ await assertOffline(entry, dependencies, new Set([process.pid]));
502
+ await assertRecoverableSidecars(entry);
503
+ const lockedInstalled = await inspect(entry.originalPath, dependencies, entry.originalPath, entry.originalPath, exclusion.handles?.get(entry.originalPath));
504
+ const lockedBackup = await inspect(entry.backupPath, dependencies, entry.originalPath, entry.originalPath, exclusion.handles?.get(entry.backupPath));
505
+ if (!databaseMainIdentityMatches(lockedInstalled.identity, installedIdentity, true) ||
506
+ lockedInstalled.sidecarsPresent ||
507
+ !movedOriginalMatches(lockedBackup.identity, backupIdentity, true)) {
508
+ throw new Error("database content changed while acquiring the restore boundary; automatic undo is no longer safe");
509
+ }
510
+ await exchange(entry.originalPath, entry.backupPath);
511
+ exchanged = true;
512
+ await restoreSidecars(entry, rename);
513
+ await syncDirectory(dirname(entry.originalPath));
514
+ await rm(entry.backupPath);
515
+ await syncDirectory(options.backupDirectory);
516
+ return markRestored(options, entry, clock);
517
+ }
518
+ catch (error) {
519
+ await persist(options, {
520
+ ...entry,
521
+ status: "restoring",
522
+ diagnostic: {
523
+ severity: "error",
524
+ code: exchanged ? "DATABASE_UNDO_RESUMABLE" : "DATABASE_UNDO_BLOCKED",
525
+ message: error instanceof Error ? error.message : String(error),
526
+ adapter: "codex",
527
+ resourceId: entry.resourceId,
528
+ },
529
+ });
530
+ throw error;
531
+ }
532
+ finally {
533
+ await exclusion.release();
534
+ }
535
+ }
536
+ export async function purgeDatabaseBackup(input, options) {
537
+ let entry = databaseBackupEntrySchema.parse(input);
538
+ assertRecoveryPaths(entry, options);
539
+ if (!["installed", "purging"].includes(entry.status)) {
540
+ throw new Error(`database backup entry cannot be purged from ${entry.status}`);
541
+ }
542
+ const dependencies = options.dependencies ?? {};
543
+ const clock = dependencies.clock ?? (() => new Date());
544
+ const acquireExclusion = dependencies.acquireExclusion ?? acquireDatabaseExclusion;
545
+ const inspect = dependencies.inspectDatabase ?? inspectCodexDatabase;
546
+ await assertOffline(entry, dependencies);
547
+ const originalExists = await pathExists(entry.originalPath);
548
+ const backupExists = await pathExists(entry.backupPath);
549
+ if (!originalExists || (entry.status === "installed" && !backupExists)) {
550
+ throw new Error("database purge paths do not match the recovery manifest");
551
+ }
552
+ const current = await inspect(entry.originalPath, dependencies);
553
+ if (current.sidecarsPresent) {
554
+ throw new Error("database WAL or SHM companion exists; purge is refused");
555
+ }
556
+ if (!codexDatabaseContractMatches(current.identity)) {
557
+ throw new Error("canonical database no longer matches the supported Codex contract");
558
+ }
559
+ if (entry.status === "purging" &&
560
+ (entry.installedIdentity === undefined ||
561
+ !databaseMainIdentityMatches(current.identity, entry.installedIdentity, true))) {
562
+ throw new Error("interrupted database purge no longer matches its locked boundary");
563
+ }
564
+ const restoreMode = entry.status === "purging" ? entry.installedIdentity.mode : current.identity.mode;
565
+ const canonicalIdentity = {
566
+ ...current.identity,
567
+ mode: restoreMode,
568
+ };
569
+ await (dependencies.verifyIntegrity ?? verifyCodexDatabaseIntegrity)(entry.originalPath, dependencies);
570
+ let reclaimedBytes = 0;
571
+ let backupIdentity;
572
+ if (backupExists) {
573
+ backupIdentity = await verifyRetainedOriginal(entry, dependencies, inspect, entry.status === "purging");
574
+ const backupStats = await lstat(entry.backupPath);
575
+ if (!backupStats.isFile() || backupStats.isSymbolicLink()) {
576
+ throw new Error("database rollback copy is not a regular file");
577
+ }
578
+ reclaimedBytes += backupStats.size;
579
+ }
580
+ const exclusionPaths = [
581
+ entry.originalPath,
582
+ ...(backupIdentity === undefined ? [] : [entry.backupPath]),
583
+ ];
584
+ const exclusion = await acquireExclusion(exclusionPaths, process.platform, new Map([
585
+ [entry.originalPath, canonicalIdentity.mode],
586
+ ...(backupIdentity === undefined
587
+ ? []
588
+ : [[entry.backupPath, backupIdentity.mode]]),
589
+ ]));
590
+ try {
591
+ assertLockedIdentity(exclusion, entry.originalPath, canonicalIdentity);
592
+ if (backupIdentity !== undefined) {
593
+ assertLockedIdentity(exclusion, entry.backupPath, backupIdentity);
594
+ }
595
+ await assertOffline(entry, dependencies, new Set([process.pid]));
596
+ await assertPurgeSidecars(entry);
597
+ entry = await persist(options, {
598
+ ...entry,
599
+ status: "purging",
600
+ installedIdentity: canonicalIdentity,
601
+ });
602
+ for (const sidecar of backupSidecars(entry)) {
603
+ if (await pathExists(sidecar.backup)) {
604
+ const stats = await lstat(sidecar.backup);
605
+ if (!stats.isFile() || stats.isSymbolicLink()) {
606
+ throw new Error("database rollback sidecar is not a regular file");
607
+ }
608
+ reclaimedBytes += stats.size;
609
+ await rm(sidecar.backup);
610
+ }
611
+ }
612
+ if (await pathExists(entry.backupPath)) {
613
+ await rm(entry.backupPath);
614
+ }
615
+ await syncDirectory(options.backupDirectory);
616
+ entry = await persist(options, {
617
+ ...entry,
618
+ status: "purged",
619
+ purgedAt: clock().toISOString(),
620
+ diagnostic: undefined,
621
+ });
622
+ return { entry, reclaimedBytes };
623
+ }
624
+ finally {
625
+ await exclusion.release();
626
+ }
627
+ }
628
+ //# sourceMappingURL=database-recovery.js.map