runlist 0.0.0 → 0.94.2

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1112 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +296 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/public-inspection.mjs +19 -0
  136. package/src/query.mjs +977 -0
  137. package/src/record-action.mjs +11 -0
  138. package/src/record-create.mjs +82 -0
  139. package/src/reference-planner.mjs +388 -0
  140. package/src/reference-values.mjs +11 -0
  141. package/src/rename.mjs +107 -0
  142. package/src/render.mjs +678 -0
  143. package/src/roadmap.mjs +184 -0
  144. package/src/runlist.mjs +944 -0
  145. package/src/section.mjs +225 -0
  146. package/src/semantic-search-client.mjs +58 -0
  147. package/src/ship.mjs +166 -0
  148. package/src/show.mjs +101 -0
  149. package/src/skill-drift.mjs +98 -0
  150. package/src/source-editor.mjs +491 -0
  151. package/src/stages.mjs +73 -0
  152. package/src/state-migration.mjs +123 -0
  153. package/src/stats.mjs +200 -0
  154. package/src/status-metadata.mjs +87 -0
  155. package/src/statuses.mjs +721 -0
  156. package/src/summary.mjs +74 -0
  157. package/src/surfaces.mjs +28 -0
  158. package/src/sync-status.mjs +316 -0
  159. package/src/template-store.mjs +62 -0
  160. package/src/update.mjs +338 -0
  161. package/src/use.mjs +58 -0
  162. package/src/util.mjs +337 -0
  163. package/src/validate.mjs +795 -0
  164. package/src/watch.mjs +56 -0
  165. package/src/xref.mjs +406 -0
  166. package/src/yardstick-command.mjs +178 -0
  167. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,1736 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fchmodSync,
5
+ fstatSync,
6
+ fsyncSync,
7
+ linkSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ realpathSync,
14
+ renameSync,
15
+ rmdirSync,
16
+ statSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
20
+ import { createHash, randomUUID } from 'node:crypto';
21
+ import { execFileSync } from 'node:child_process';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { captureGitIndexGeneration, gitIndexProvablyUnpublished, gitIndexPublicationRaced, reclaimPreparedGitIndex, restoreGitIndexCas, sameGitIndexGeneration, stageMovePathsCas } from './git.mjs';
25
+ import { authorizeManagedDestination, authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
26
+ import { ARTIFACT_PREFIX, LEGACY_ARTIFACT_PREFIX, LEGACY_STATE_DIR, STATE_DIR, isOwnedArtifact, stateDir } from './naming.mjs';
27
+ import { commitRename } from './durable-rename.mjs';
28
+
29
+ const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
30
+ let tempSequence = 0;
31
+
32
+ // How long a peer waits to acquire a path lock before MutationLockError. Any
33
+ // retry budget spent while the lock is held has to fit well inside this.
34
+ export const MUTATION_LOCK_TIMEOUT_MS = 2000;
35
+
36
+ // Bounded re-stage budget for a Git index publication that provably lost a
37
+ // race. Sized like durable-rename's retry: total backoff (25 + 50ms) plus the
38
+ // re-prepare has to stay well inside MUTATION_LOCK_TIMEOUT_MS, since peers are
39
+ // waiting on the participant locks this loop holds.
40
+ export const GIT_INDEX_STAGE_ATTEMPTS = 3;
41
+ const GIT_INDEX_STAGE_BACKOFF_MS = 25;
42
+
43
+ export function processStartIdentity(pid) {
44
+ try {
45
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
46
+ const afterName = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/);
47
+ return `linux:${afterName[19]}`;
48
+ } catch { /* non-Linux or proc unavailable */ }
49
+ if (process.platform === 'win32') return null;
50
+ try {
51
+ const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 1000 }).trim();
52
+ return started ? `ps:${started}` : null;
53
+ } catch { return null; }
54
+ }
55
+
56
+ export const PROCESS_STARTED_AT = new Date(Date.now() - Math.floor(process.uptime() * 1000)).toISOString();
57
+ export const PROCESS_START_IDENTITY = processStartIdentity(process.pid);
58
+
59
+ export function currentProcessOwner() {
60
+ return {
61
+ pid: process.pid,
62
+ hostname: os.hostname(),
63
+ processStartedAt: PROCESS_STARTED_AT,
64
+ processStartIdentity: PROCESS_START_IDENTITY,
65
+ };
66
+ }
67
+
68
+ export function processOwnerLiveness(owner) {
69
+ if (!owner || owner.hostname !== os.hostname() || !Number.isInteger(owner.pid)) return 'unverifiable';
70
+ try {
71
+ process.kill(owner.pid, 0);
72
+ const currentStart = processStartIdentity(owner.pid);
73
+ // If the host cannot expose process start times, an existing PID is still
74
+ // conservatively live. This may delay stale-lock recovery after PID reuse,
75
+ // but can never steal a live process's lock.
76
+ if (!owner.processStartIdentity || !currentStart) return 'live';
77
+ return owner.processStartIdentity === currentStart ? 'live' : 'dead';
78
+ } catch (err) {
79
+ if (err?.code === 'ESRCH') return 'dead';
80
+ if (err?.code === 'EPERM') return 'live';
81
+ return 'unverifiable';
82
+ }
83
+ }
84
+
85
+ export class MutationConflictError extends Error {
86
+ constructor(message) {
87
+ super(message);
88
+ this.name = 'MutationConflictError';
89
+ this.code = 'RUNLIST_MUTATION_CONFLICT';
90
+ }
91
+ }
92
+
93
+ export class MutationLockError extends Error {
94
+ constructor(message) {
95
+ super(message);
96
+ this.name = 'MutationLockError';
97
+ this.code = 'RUNLIST_MUTATION_LOCK_TIMEOUT';
98
+ }
99
+ }
100
+
101
+ function contentHash(content) {
102
+ return createHash('sha256').update(content).digest('hex');
103
+ }
104
+
105
+ const TRANSACTION_SCHEMA = 2;
106
+ const TRANSACTION_OPERATIONS = new Set(['move', 'rename', 'lifecycle-move']);
107
+ const TRANSACTION_PHASES = new Set(['staging', 'manifest', 'reservation', 'source-move', 'target-publication', 'referrer-publication', 'ownership-publication', 'creation-publication', 'deletion-publication', 'canonical-commit', 'backup-deletion', 'manifest-completion', 'cleanup', 'recovery', 'recovered', 'completed', 'failed-manual']);
108
+ const TRANSACTION_RESULTS = new Set([null, 'committed', 'rolled-back', 'rolled-forward', 'failed-manual']);
109
+ const TRANSACTION_STATUSES = new Set(['active', 'complete', 'failed-manual']);
110
+
111
+ function contained(parent, child) {
112
+ const relative = path.relative(parent, child);
113
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
114
+ }
115
+
116
+ function safeGeneratedPath(filePath, repoRoot, options, kind) {
117
+ return authorizeRepoGeneratedPath(filePath, options.config ?? { repoRoot }, { kind }).path;
118
+ }
119
+
120
+ function transactionRoot(repoRoot, options = {}) {
121
+ return safeGeneratedPath(path.join(stateDir(repoRoot), 'transactions'), repoRoot, options, 'Transaction root');
122
+ }
123
+
124
+ function durableJson(filePath, value, options = {}) {
125
+ const content = JSON.stringify(value, null, 2) + '\n';
126
+ const temp = writeCompleteTemp(filePath, content, 0o600);
127
+ commitRename(temp.path, filePath, options.testHooks);
128
+ fsyncDirectory(path.dirname(filePath), options, 'transaction-manifest');
129
+ }
130
+
131
+ function writeTransactionArtifact(directory, name, content, mode, options) {
132
+ const artifact = path.join(directory, name);
133
+ const temp = writeCompleteTemp(artifact, content, mode);
134
+ renameSync(temp.path, artifact);
135
+ fsyncDirectory(directory, options, 'transaction-artifact');
136
+ return artifact;
137
+ }
138
+
139
+ function participantState(participant) {
140
+ if (!existsSync(participant.path)) {
141
+ if (!participant.old.exists) return 'old';
142
+ if (!participant.new.exists) return 'new';
143
+ return 'unknown';
144
+ }
145
+ let hash;
146
+ try { hash = contentHash(readFileSync(participant.path)); }
147
+ catch { return 'unknown'; }
148
+ if (participant.old.exists && hash === participant.old.hash) return 'old';
149
+ if (participant.new.exists && hash === participant.new.hash) return 'new';
150
+ if (participant.transientHashes?.includes(hash)) return 'transient';
151
+ return 'unknown';
152
+ }
153
+
154
+ function publishParticipantGeneration(participant, generation, repoRoot) {
155
+ const current = participantState(participant);
156
+ if (current === 'unknown') throw new MutationConflictError(`Transaction recovery found an unrecognized generation at ${participant.path}.`);
157
+ const alreadyDesired = generation.exists
158
+ ? existsSync(participant.path) && contentHash(readFileSync(participant.path)) === generation.hash
159
+ : !existsSync(participant.path);
160
+ if (alreadyDesired) return;
161
+ if (!generation.exists) {
162
+ if (existsSync(participant.path)) unlinkSync(participant.path);
163
+ } else {
164
+ const content = readFileSync(generation.artifact, 'utf8');
165
+ if (contentHash(content) !== generation.hash) throw new MutationConflictError(`Transaction recovery artifact hash mismatch: ${generation.artifact}.`);
166
+ if (existsSync(participant.path)) {
167
+ const snapshot = snapshotFile(participant.path);
168
+ replaceSnapshot(snapshot, content, { repoRoot, locked: true });
169
+ } else {
170
+ createFileExclusive(participant.path, content, { repoRoot, mode: generation.mode, locked: true });
171
+ }
172
+ }
173
+ fsyncDirectory(path.dirname(participant.path), {}, 'transaction-recovery');
174
+ }
175
+
176
+ function cleanupTransactionDirectory(directory, manifest, options) {
177
+ const artifacts = new Set([
178
+ ...manifest.participants.flatMap(item => [item.old.artifact, item.new.artifact]),
179
+ ...manifest.recoveryArtifacts,
180
+ ].filter(Boolean));
181
+ for (const artifact of artifacts) {
182
+ if (!existsSync(artifact)) continue;
183
+ validateTransactionArtifact(artifact, directory, 'cleanup artifact');
184
+ unlinkSync(artifact);
185
+ fsyncDirectory(directory, options, 'transaction-cleanup-artifact-delete');
186
+ }
187
+ const manifestPath = path.join(directory, 'manifest.json');
188
+ if (existsSync(manifestPath)) {
189
+ unlinkSync(manifestPath);
190
+ fsyncDirectory(directory, options, 'transaction-cleanup-manifest-delete');
191
+ }
192
+ const leftovers = readdirSync(directory);
193
+ if (leftovers.length) throw new Error(`Unexpected transaction evidence remains: ${leftovers.join(', ')}`);
194
+ rmdirSync(directory);
195
+ fsyncDirectory(path.dirname(directory), options, 'transaction-cleanup-directory-delete');
196
+ }
197
+
198
+ function transactionRepairMessage(manifestPath, manifest, reason) {
199
+ const artifacts = [
200
+ ...manifest.participants.flatMap(item => [item.old.artifact, item.new.artifact]),
201
+ manifest.gitIndex?.prepared?.path,
202
+ manifest.gitIndex?.prepared?.tempPath,
203
+ ...(manifest.gitIndex?.retainedPaths ?? []),
204
+ ].filter(Boolean);
205
+ return `${reason}\nTransaction recovery refused to guess. Manifest: ${manifestPath}\n` +
206
+ `Start with \`runlist doctor --transactions\` — it reports this transaction's state and resolves it when the canonical files agree on one generation.\n` +
207
+ `If it reports the generations as ambiguous, inspect the canonical files and recovery artifacts, then restore one complete generation and remove the manifest:\n` +
208
+ `${artifacts.map(item => ` ${item}`).join('\n') || ' (no content artifacts)'}`;
209
+ }
210
+
211
+ function assertString(value, label, { nullable = false } = {}) {
212
+ if ((nullable && value === null) || typeof value === 'string') return;
213
+ throw new MutationConflictError(`Invalid transaction manifest ${label}.`);
214
+ }
215
+
216
+ function validateGitSnapshot(snapshot, label) {
217
+ if (snapshot === null) return;
218
+ if (!snapshot || typeof snapshot.exists !== 'boolean' || !Number.isInteger(snapshot.size) || typeof snapshot.indexPath !== 'string' || !path.isAbsolute(snapshot.indexPath)) {
219
+ throw new MutationConflictError(`Invalid transaction manifest ${label}.`);
220
+ }
221
+ if (!snapshot.exists) {
222
+ if (snapshot.hash !== null || snapshot.content !== null || snapshot.size !== 0 || snapshot.mode !== null) throw new MutationConflictError(`Invalid absent Git index generation ${label}.`);
223
+ return;
224
+ }
225
+ if (typeof snapshot.hash !== 'string' || !/^[a-f\d]{64}$/.test(snapshot.hash) || typeof snapshot.content !== 'string'
226
+ || !Number.isInteger(snapshot.mode) || snapshot.mode < 0 || snapshot.mode > 0o7777) throw new MutationConflictError(`Invalid Git index generation ${label}.`);
227
+ const bytes = Buffer.from(snapshot.content, 'base64');
228
+ if (bytes.length !== snapshot.size || contentHash(bytes) !== snapshot.hash) throw new MutationConflictError(`Git index generation ${label} hash mismatch.`);
229
+ }
230
+
231
+ function validatePreparedGitIndex(prepared, label, directory) {
232
+ if (prepared === null) return;
233
+ if (!prepared || typeof prepared.path !== 'string' || !path.isAbsolute(prepared.path)
234
+ || !Number.isInteger(prepared.dev) || !Number.isInteger(prepared.ino) || !Number.isInteger(prepared.mode) || prepared.mode < 0 || prepared.mode > 0o7777
235
+ || !Number.isInteger(prepared.size) || typeof prepared.hash !== 'string' || !/^[a-f\d]{64}$/.test(prepared.hash)
236
+ || !['preparing', 'prepared'].includes(prepared.state) || typeof prepared.tempPath !== 'string' || !path.isAbsolute(prepared.tempPath)) {
237
+ throw new MutationConflictError(`Invalid transaction manifest ${label}.`);
238
+ }
239
+ validateGitSnapshot(prepared.generation, `${label}.generation`);
240
+ validateTransactionArtifact(prepared.path, directory, label);
241
+ if (path.dirname(prepared.tempPath) !== path.dirname(prepared.generation.indexPath) || !isOwnedArtifact(path.basename(prepared.tempPath), 'index')) {
242
+ throw new MutationConflictError(`Unsafe prepared Git index path in ${label}.`);
243
+ }
244
+ if ((prepared.generation.exists && (prepared.hash !== prepared.generation.hash || prepared.size !== prepared.generation.size))
245
+ || (!prepared.generation.exists && prepared.size !== 0)) throw new MutationConflictError(`Prepared Git artifact does not match ${label}.generation.`);
246
+ if (prepared.work !== null) {
247
+ if (!prepared.work || prepared.work.path !== prepared.tempPath || !Number.isInteger(prepared.work.dev) || !Number.isInteger(prepared.work.ino)
248
+ || !Number.isInteger(prepared.work.mode) || !Number.isInteger(prepared.work.size) || typeof prepared.work.hash !== 'string') {
249
+ throw new MutationConflictError(`Invalid prepared Git working inode in ${label}.`);
250
+ }
251
+ }
252
+ }
253
+
254
+ function validateGeneration(generation, directory, label) {
255
+ if (!generation || typeof generation.exists !== 'boolean') throw new MutationConflictError(`Invalid transaction manifest ${label}.`);
256
+ if (!generation.exists) {
257
+ if (generation.artifact !== null) throw new MutationConflictError(`Invalid absent generation artifact for ${label}.`);
258
+ return;
259
+ }
260
+ if (typeof generation.hash !== 'string' || !/^[a-f\d]{64}$/.test(generation.hash)
261
+ || !Number.isInteger(generation.mode) || typeof generation.artifact !== 'string') {
262
+ throw new MutationConflictError(`Invalid transaction generation for ${label}.`);
263
+ }
264
+ validateTransactionArtifact(generation.artifact, directory, label);
265
+ }
266
+
267
+ function validateTransactionArtifact(candidate, directory, label) {
268
+ const absolute = path.resolve(candidate);
269
+ if (!contained(directory, absolute) || absolute === directory) throw new MutationConflictError(`Transaction ${label} escapes its transaction directory: ${candidate}`);
270
+ let canonicalDirectory;
271
+ try {
272
+ canonicalDirectory = realpathSync(directory);
273
+ if (existsSync(absolute)) {
274
+ if (lstatSync(absolute).isSymbolicLink()) throw new Error('symlink artifact');
275
+ if (!contained(canonicalDirectory, realpathSync(absolute))) throw new Error('artifact resolves outside transaction directory');
276
+ return;
277
+ }
278
+ let ancestor = path.dirname(absolute);
279
+ while (!existsSync(ancestor)) ancestor = path.dirname(ancestor);
280
+ if (!contained(canonicalDirectory, realpathSync(ancestor))) throw new Error('artifact parent resolves outside transaction directory');
281
+ } catch (err) {
282
+ throw new MutationConflictError(`Transaction ${label} is missing or unsafe: ${candidate} (${err.message})`);
283
+ }
284
+ }
285
+
286
+ function validateParticipantPath(participant, repoRoot, options, label) {
287
+ if (!participant || typeof participant.path !== 'string' || !path.isAbsolute(participant.path)
288
+ || !['managed', 'generated'].includes(participant.policy)
289
+ || !['source', 'target', 'referrer', 'ownership', 'creation', 'deletion'].includes(participant.label)) {
290
+ throw new MutationConflictError(`Invalid transaction participant ${label}.`);
291
+ }
292
+ const txRoot = transactionRoot(repoRoot, options);
293
+ const lockRoot = safeGeneratedPath(path.join(stateDir(repoRoot), 'locks'), repoRoot, options, 'Lock root');
294
+ if (contained(txRoot, participant.path) || contained(lockRoot, participant.path)) {
295
+ throw new MutationConflictError(`Transaction participant targets transaction/lock state: ${participant.path}`);
296
+ }
297
+ if (participant.policy === 'managed' && options.config) {
298
+ if (existsSync(participant.path)) authorizeManagedSource(participant.path, options.config, { kind: 'Transaction participant' });
299
+ else authorizeManagedDestination(participant.path, options.config, { kind: 'Transaction participant' });
300
+ } else {
301
+ const authorized = safeGeneratedPath(participant.path, repoRoot, options, 'Transaction generated participant');
302
+ if (options.config) {
303
+ const ownershipRoot = path.join(stateDir(repoRoot), 'ownership');
304
+ if (!contained(ownershipRoot, authorized)) throw new MutationConflictError(`Generated transaction participant is outside session ownership state: ${participant.path}`);
305
+ if (participant.label !== 'ownership') throw new MutationConflictError(`Session-generated participant must be classified as ownership: ${participant.path}`);
306
+ }
307
+ }
308
+ }
309
+
310
+ function validateManifest(manifest, manifestPath, directory, repoRoot, options) {
311
+ if (!manifest || manifest.schema !== TRANSACTION_SCHEMA || typeof manifest.id !== 'string'
312
+ || !TRANSACTION_OPERATIONS.has(manifest.operation) || !TRANSACTION_PHASES.has(manifest.phase)
313
+ || !TRANSACTION_RESULTS.has(manifest.result) || !TRANSACTION_STATUSES.has(manifest.status) || !Array.isArray(manifest.participants)
314
+ || !Array.isArray(manifest.recoveryArtifacts) || !Array.isArray(manifest.createdDirectories) || typeof manifest.createdAt !== 'string'
315
+ || typeof manifest.directoryToken !== 'string') {
316
+ throw new MutationConflictError(`Invalid transaction manifest schema at ${manifestPath}. Preserve it for manual repair.`);
317
+ }
318
+ assertString(manifest.sessionId, 'sessionId', { nullable: true });
319
+ if (!manifest.owner || !Number.isInteger(manifest.owner.pid) || typeof manifest.owner.hostname !== 'string'
320
+ || typeof manifest.owner.processStartedAt !== 'string' || (manifest.owner.processStartIdentity !== null && typeof manifest.owner.processStartIdentity !== 'string')) {
321
+ throw new MutationConflictError(`Invalid transaction manifest owner at ${manifestPath}.`);
322
+ }
323
+ if (manifest.directory !== directory) throw new MutationConflictError(`Transaction manifest directory binding mismatch at ${manifestPath}.`);
324
+ for (let index = 0; index < manifest.participants.length; index++) {
325
+ const participant = manifest.participants[index];
326
+ validateParticipantPath(participant, repoRoot, options, `participants[${index}]`);
327
+ validateGeneration(participant.old, directory, `participants[${index}].old`);
328
+ validateGeneration(participant.new, directory, `participants[${index}].new`);
329
+ const shape = `${participant.old.exists ? 1 : 0}${participant.new.exists ? 1 : 0}`;
330
+ const expectedShapes = {
331
+ source: ['10'], target: ['01'], referrer: ['11'], creation: ['01'], deletion: ['10'], ownership: ['01', '10'],
332
+ }[participant.label];
333
+ if (!expectedShapes.includes(shape)) throw new MutationConflictError(`Invalid generation shape for transaction ${participant.label} participant.`);
334
+ if (!Array.isArray(participant.transientHashes) || !participant.transientHashes.every(hash => typeof hash === 'string' && /^[a-f\d]{64}$/.test(hash))) {
335
+ throw new MutationConflictError(`Invalid transaction transient generations at ${manifestPath}.`);
336
+ }
337
+ }
338
+ if (manifest.participants.filter(item => item.label === 'source').length !== 1
339
+ || manifest.participants.filter(item => item.label === 'target').length !== 1) {
340
+ throw new MutationConflictError(`Transaction manifest must contain exactly one source and target at ${manifestPath}.`);
341
+ }
342
+ for (const artifact of manifest.recoveryArtifacts) validateTransactionArtifact(artifact, directory, 'recovery artifact');
343
+ for (const createdDirectory of manifest.createdDirectories) {
344
+ if (!createdDirectory || typeof createdDirectory.path !== 'string' || typeof createdDirectory.marker !== 'string'
345
+ || typeof createdDirectory.participantPath !== 'string' || typeof createdDirectory.token !== 'string'
346
+ || !path.isAbsolute(createdDirectory.path) || !path.isAbsolute(createdDirectory.marker) || !path.isAbsolute(createdDirectory.participantPath)) {
347
+ throw new MutationConflictError(`Invalid created directory in ${manifestPath}.`);
348
+ }
349
+ const participant = manifest.participants.find(item => item.path === createdDirectory.participantPath && !item.old.exists && item.new.exists);
350
+ if (!participant || !contained(createdDirectory.path, participant.path) || createdDirectory.path === participant.path) {
351
+ throw new MutationConflictError(`Transaction-created directory is not bound to a destination participant: ${createdDirectory.path}`);
352
+ }
353
+ const expectedMarkers = [ARTIFACT_PREFIX, LEGACY_ARTIFACT_PREFIX]
354
+ .map(prefix => path.join(createdDirectory.path, `${prefix}transaction-${manifest.id}`));
355
+ if (!expectedMarkers.includes(createdDirectory.marker) || createdDirectory.token !== manifest.directoryToken) {
356
+ throw new MutationConflictError(`Transaction-created directory marker binding mismatch: ${createdDirectory.path}`);
357
+ }
358
+ if (!['intended', 'created', 'removing', 'removed'].includes(createdDirectory.markerState)) throw new MutationConflictError(`Invalid transaction directory marker state: ${createdDirectory.path}`);
359
+ if (createdDirectory.identity !== null && (!createdDirectory.identity || !Number.isInteger(createdDirectory.identity.dev) || !Number.isInteger(createdDirectory.identity.ino))) {
360
+ throw new MutationConflictError(`Invalid transaction-created directory identity: ${createdDirectory.path}`);
361
+ }
362
+ if (createdDirectory.markerState !== 'intended' && createdDirectory.identity === null) throw new MutationConflictError(`Transaction-created directory state lacks an identity: ${createdDirectory.path}`);
363
+ if (participant.policy === 'managed' && options.config) authorizeManagedDestination(path.join(createdDirectory.path, `${ARTIFACT_PREFIX}directory-check.md`), options.config, { kind: 'Transaction-created directory' });
364
+ else safeGeneratedPath(createdDirectory.path, repoRoot, options, 'Transaction-created directory');
365
+ }
366
+ const createdPaths = new Set(manifest.createdDirectories.map(item => item.path));
367
+ if (createdPaths.size !== manifest.createdDirectories.length) throw new MutationConflictError(`Duplicate transaction-created directory in ${manifestPath}.`);
368
+ for (const participantPath of new Set(manifest.createdDirectories.map(item => item.participantPath))) {
369
+ const chain = manifest.createdDirectories
370
+ .filter(item => item.participantPath === participantPath)
371
+ .sort((left, right) => left.path.split(path.sep).length - right.path.split(path.sep).length);
372
+ if (chain.at(-1)?.path !== path.dirname(participantPath)) throw new MutationConflictError(`Transaction-created directory chain does not end at destination parent: ${participantPath}`);
373
+ for (let index = 1; index < chain.length; index++) {
374
+ if (path.dirname(chain[index].path) !== chain[index - 1].path) throw new MutationConflictError(`Transaction-created directory chain is not contiguous: ${participantPath}`);
375
+ }
376
+ }
377
+ if (manifest.gitMove !== null) {
378
+ if (!manifest.gitMove || typeof manifest.gitMove.source !== 'string' || typeof manifest.gitMove.target !== 'string') throw new MutationConflictError(`Invalid transaction Git move at ${manifestPath}.`);
379
+ for (const gitPath of [manifest.gitMove.source, manifest.gitMove.target]) {
380
+ if (path.isAbsolute(gitPath) || gitPath.split(/[\\/]/).includes('..')) throw new MutationConflictError(`Unsafe Git move path in transaction manifest: ${gitPath}`);
381
+ }
382
+ }
383
+ if (!manifest.gitIndex || !Object.hasOwn(manifest.gitIndex, 'before') || !Object.hasOwn(manifest.gitIndex, 'prepared') || !Object.hasOwn(manifest.gitIndex, 'ownedAfter')) throw new MutationConflictError(`Invalid transaction Git index state at ${manifestPath}.`);
384
+ if (!Array.isArray(manifest.gitIndex.retainedPaths) || !manifest.gitIndex.retainedPaths.every(item => typeof item === 'string' && path.isAbsolute(item))) throw new MutationConflictError(`Invalid retained Git index paths at ${manifestPath}.`);
385
+ validateGitSnapshot(manifest.gitIndex.before, 'gitIndex.before');
386
+ validatePreparedGitIndex(manifest.gitIndex.prepared, 'gitIndex.prepared', directory);
387
+ validateGitSnapshot(manifest.gitIndex.ownedAfter, 'gitIndex.ownedAfter');
388
+ if (manifest.gitIndex.before && manifest.gitIndex.ownedAfter && manifest.gitIndex.before.indexPath !== manifest.gitIndex.ownedAfter.indexPath) throw new MutationConflictError(`Transaction Git index generations target different selected indexes.`);
389
+ return manifest;
390
+ }
391
+
392
+ export function recoverAbandonedTransactions(repoRoot, options = {}) {
393
+ const root = transactionRoot(repoRoot, options);
394
+ if (!existsSync(root)) return [];
395
+ const recovered = [];
396
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
397
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new MutationConflictError(`Unsafe entry in transaction root: ${path.join(root, entry.name)}`);
398
+ const directory = path.join(root, entry.name);
399
+ const manifestPath = path.join(directory, 'manifest.json');
400
+ let manifest;
401
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); }
402
+ catch (err) {
403
+ if (err?.code === 'ENOENT' && readdirSync(directory).length === 0) {
404
+ rmdirSync(directory);
405
+ fsyncDirectory(root, options, 'transaction-resume-empty-cleanup');
406
+ recovered.push({ id: entry.name, result: 'cleanup-completed' });
407
+ continue;
408
+ }
409
+ throw new MutationConflictError(`Unreadable transaction manifest: ${manifestPath}. Preserve its artifacts and repair it manually.`);
410
+ }
411
+ validateManifest(manifest, manifestPath, directory, repoRoot, options);
412
+ if (manifest.status === 'complete') {
413
+ const retainedDirectories = manifest.createdDirectories.filter(item => existsSync(item.path)).map(item => item.path);
414
+ const retainedGitPaths = manifest.gitIndex.retainedPaths.filter(item => existsSync(item));
415
+ const completedResult = manifest.result === 'committed' ? 'rolled-forward' : manifest.result;
416
+ if (retainedDirectories.length === 0 && retainedGitPaths.length === 0) {
417
+ cleanupTransactionDirectory(directory, manifest, options);
418
+ recovered.push({ id: manifest.id, result: completedResult, retainedDirectories: [], retainedGitPaths: [] });
419
+ continue;
420
+ }
421
+ recovered.push({
422
+ id: manifest.id,
423
+ result: completedResult,
424
+ retainedDirectories,
425
+ retainedGitPaths,
426
+ });
427
+ continue;
428
+ }
429
+ if (manifest.status === 'failed-manual') throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Transaction is marked failed/manual and will not be retried automatically.'));
430
+ const liveness = processOwnerLiveness(manifest.owner);
431
+ if (liveness === 'live') continue;
432
+ if (liveness !== 'dead') throw new MutationConflictError(`Cannot prove transaction owner is dead for ${manifestPath}; refusing recovery.`);
433
+ if (manifest.gitIndex.before) {
434
+ const selected = captureGitIndexGeneration(repoRoot);
435
+ if (selected.indexPath !== manifest.gitIndex.before.indexPath) throw new MutationConflictError(`Recovery environment selects a different Git index than ${manifestPath}; refusing recovery.`);
436
+ try {
437
+ const reclaimed = reclaimPreparedGitIndex(manifest.gitIndex, repoRoot, options);
438
+ manifest.gitIndex.retainedPaths = [...new Set([...manifest.gitIndex.retainedPaths, ...reclaimed.retainedPaths])];
439
+ if (reclaimed.retainedPaths.length) {
440
+ manifest.phase = 'failed-manual';
441
+ manifest.result = 'failed-manual';
442
+ manifest.status = 'failed-manual';
443
+ durableJson(manifestPath, manifest, options);
444
+ const retainedError = new MutationConflictError(transactionRepairMessage(manifestPath, manifest,
445
+ `Git preparation left unverified work that was preserved and requires manual recovery: ${reclaimed.retainedPaths.join(', ')}`));
446
+ retainedError.retainedGitPaths = reclaimed.retainedPaths;
447
+ throw retainedError;
448
+ }
449
+ }
450
+ catch (err) {
451
+ if (err?.retainedGitPaths) throw err;
452
+ throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, `Prepared Git index recovery failed: ${err.message}`));
453
+ }
454
+ }
455
+ const states = manifest.participants.map(participantState);
456
+ if (states.includes('unknown')) {
457
+ throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations are ambiguous.'));
458
+ }
459
+ const rollForward = states.every(state => state === 'new');
460
+ // Lock acquisition is the one step here that fails for a reason that is not
461
+ // evidence of damage: another process holds these paths, almost always
462
+ // because it is recovering this same abandoned transaction. Nothing inside
463
+ // the callback acquires a lock, so a MutationLockError always means the
464
+ // callback never ran and no generation was touched — the manifest is exactly
465
+ // as it was. Marking it failed-manual on that (see the catch below, which
466
+ // is otherwise correct to retain evidence) turned transient contention into
467
+ // a permanent repo-wide brick: every later mutation hit the failed-manual
468
+ // check above and refused, for a transaction unrelated to it.
469
+ let locked = false;
470
+ try {
471
+ withPathLocks(manifest.participants.map(item => item.path), { repoRoot, ...options }, () => {
472
+ locked = true;
473
+ const lockedStates = manifest.participants.map(participantState);
474
+ if (lockedStates.includes('unknown')) {
475
+ throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations changed while recovery acquired locks.'));
476
+ }
477
+ const lockedRollForward = lockedStates.every(state => state === 'new');
478
+ if (lockedRollForward !== rollForward) throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations changed during recovery classification.'));
479
+ if (!lockedRollForward) {
480
+ for (let i = manifest.participants.length - 1; i >= 0; i--) {
481
+ publishParticipantGeneration(manifest.participants[i], manifest.participants[i].old, repoRoot);
482
+ }
483
+ if (manifest.gitIndex.before) {
484
+ if (manifest.gitIndex.ownedAfter) {
485
+ const currentIndex = captureGitIndexGeneration(repoRoot);
486
+ if (!sameGitIndexGeneration(currentIndex, manifest.gitIndex.before)) restoreGitIndexCas(manifest.gitIndex.before, manifest.gitIndex.ownedAfter, repoRoot, {
487
+ artifactPath: path.join(directory, 'git-index-restore'),
488
+ testHooks: {
489
+ ...options.testHooks,
490
+ afterGitRestoreArtifact: info => {
491
+ manifest.gitIndex.prepared = info.prepared;
492
+ durableJson(manifestPath, manifest, options);
493
+ options.testHooks?.afterGitRestoreArtifact?.(info);
494
+ },
495
+ afterGitRestorePrepared: info => {
496
+ manifest.gitIndex.prepared = info.prepared;
497
+ durableJson(manifestPath, manifest, options);
498
+ options.testHooks?.afterGitRestorePrepared?.(info);
499
+ },
500
+ },
501
+ });
502
+ }
503
+ }
504
+ } else if (manifest.gitMove) {
505
+ const before = manifest.gitIndex.before;
506
+ const current = before ? captureGitIndexGeneration(repoRoot) : null;
507
+ if (manifest.gitIndex.ownedAfter) {
508
+ if (!sameGitIndexGeneration(current, manifest.gitIndex.ownedAfter)) throw new Error('Git index no longer matches the transaction-owned committed generation; current staging was preserved.');
509
+ } else if (manifest.gitIndex.prepared?.state === 'prepared' && sameGitIndexGeneration(current, manifest.gitIndex.prepared.generation)) {
510
+ manifest.gitIndex.ownedAfter = manifest.gitIndex.prepared.generation;
511
+ } else {
512
+ if (before && !sameGitIndexGeneration(current, before)) throw new Error('Git index changed before recovery could finalize the move; current staging was preserved.');
513
+ manifest.gitIndex.ownedAfter = stageMovePathsCas(manifest.gitMove.source, manifest.gitMove.target, repoRoot, before, {
514
+ artifactPath: path.join(directory, 'git-index-prepared'),
515
+ testHooks: {
516
+ ...options.testHooks,
517
+ afterGitIndexArtifact: info => {
518
+ manifest.gitIndex.prepared = info.prepared;
519
+ durableJson(manifestPath, manifest, options);
520
+ options.testHooks?.afterGitIndexArtifact?.(info);
521
+ },
522
+ afterGitIndexWorkSeed: info => {
523
+ manifest.gitIndex.prepared = info.prepared;
524
+ durableJson(manifestPath, manifest, options);
525
+ options.testHooks?.afterGitIndexWorkSeed?.(info);
526
+ },
527
+ afterGitIndexPrepareStep: info => {
528
+ manifest.gitIndex.prepared = info.prepared;
529
+ durableJson(manifestPath, manifest, options);
530
+ options.testHooks?.afterGitIndexPrepareStep?.(info);
531
+ },
532
+ afterGitIndexSubprocessBeforeCheckpoint: info => {
533
+ options.testHooks?.afterTransactionPhase?.('git-index-post-subprocess', { manifestPath, manifest, ...info });
534
+ options.testHooks?.afterGitIndexSubprocessBeforeCheckpoint?.(info);
535
+ },
536
+ afterGitIndexPrepared: info => {
537
+ manifest.gitIndex.prepared = info.prepared;
538
+ durableJson(manifestPath, manifest, options);
539
+ options.testHooks?.afterGitIndexPrepared?.(info);
540
+ },
541
+ },
542
+ });
543
+ }
544
+ }
545
+ });
546
+ manifest.phase = 'recovered';
547
+ manifest.result = rollForward ? 'rolled-forward' : 'rolled-back';
548
+ manifest.status = 'complete';
549
+ durableJson(manifestPath, manifest, options);
550
+ options.testHooks?.afterTransactionPhase?.('recovery', { manifestPath, manifest });
551
+ const retainedDirectories = manifest.createdDirectories.filter(item => existsSync(item.path)).map(item => item.path);
552
+ const retainedGitPaths = manifest.gitIndex.retainedPaths.filter(item => existsSync(item));
553
+ if (retainedDirectories.length === 0 && retainedGitPaths.length === 0) {
554
+ setMoveManifestPhase({ manifest, manifestPath }, 'cleanup', options);
555
+ cleanupTransactionDirectory(directory, manifest, options);
556
+ }
557
+ recovered.push({
558
+ id: manifest.id,
559
+ result: manifest.result,
560
+ retainedDirectories,
561
+ retainedGitPaths,
562
+ });
563
+ } catch (err) {
564
+ if (!locked && err instanceof MutationLockError) {
565
+ // Whoever holds the lock finishes (or re-abandons) this transaction;
566
+ // either way it stays recoverable. The caller's own withPathLocks still
567
+ // guards the paths it actually mutates, so proceeding is safe.
568
+ recovered.push({ id: manifest.id, result: 'deferred-locked' });
569
+ continue;
570
+ }
571
+ try {
572
+ manifest.phase = 'failed-manual';
573
+ manifest.result = 'failed-manual';
574
+ manifest.status = 'failed-manual';
575
+ durableJson(manifestPath, manifest, options);
576
+ } catch { /* retain original manifest and every artifact */ }
577
+ throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, `Recovery failed and evidence was retained: ${err.message}`));
578
+ }
579
+ }
580
+ return recovered;
581
+ }
582
+
583
+ // Report on every transaction manifest without mutating anything, so a wedged
584
+ // repo can be diagnosed. `recoverAbandonedTransactions` deliberately refuses to
585
+ // guess and throws; that left no way to even SEE the state short of reading
586
+ // JSON by hand, and a single failed-manual manifest blocks every mutation in
587
+ // the repo. Never throws for a manifest it cannot parse — an unreadable one is
588
+ // itself the finding.
589
+ export function inspectTransactions(repoRoot, options = {}) {
590
+ const root = transactionRoot(repoRoot, options);
591
+ if (!existsSync(root)) return [];
592
+ const report = [];
593
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
594
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
595
+ report.push({ id: entry.name, readable: false, reason: 'Unsafe entry in transaction root', resolvable: false });
596
+ continue;
597
+ }
598
+ const directory = path.join(root, entry.name);
599
+ const manifestPath = path.join(directory, 'manifest.json');
600
+ let manifest;
601
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); }
602
+ catch (err) {
603
+ report.push({ id: entry.name, directory, manifestPath, readable: false, reason: err?.code === 'ENOENT' ? 'No manifest' : `Unreadable manifest: ${err.message}`, resolvable: false });
604
+ continue;
605
+ }
606
+ try { validateManifest(manifest, manifestPath, directory, repoRoot, options); }
607
+ catch (err) {
608
+ report.push({ id: entry.name, directory, manifestPath, readable: false, reason: err.message, resolvable: false });
609
+ continue;
610
+ }
611
+ const participants = manifest.participants.map(item => ({ path: item.path, state: participantState(item) }));
612
+ const states = new Set(participants.map(item => item.state));
613
+ const retainedGitPaths = (manifest.gitIndex?.retainedPaths ?? []).filter(item => existsSync(item));
614
+ const retainedDirectories = manifest.createdDirectories.filter(item => existsSync(item.path)).map(item => item.path);
615
+ // Safe to clear only when the filesystem already shows one uniform
616
+ // generation: that IS the atomicity the transaction existed to guarantee,
617
+ // so the manifest is bookkeeping for work that is fully done or fully
618
+ // undone. Anything mixed, unknown, or with retained artifacts stays put.
619
+ const uniform = states.size === 1 && (states.has('old') || states.has('new'));
620
+ report.push({
621
+ id: manifest.id,
622
+ directory,
623
+ manifestPath,
624
+ readable: true,
625
+ status: manifest.status,
626
+ phase: manifest.phase,
627
+ result: manifest.result,
628
+ operation: manifest.operation,
629
+ createdAt: manifest.createdAt,
630
+ ownerLiveness: processOwnerLiveness(manifest.owner),
631
+ participants,
632
+ generation: uniform ? [...states][0] : null,
633
+ retainedGitPaths,
634
+ retainedDirectories,
635
+ resolvable: uniform && retainedGitPaths.length === 0 && retainedDirectories.length === 0,
636
+ reason: uniform
637
+ ? (retainedGitPaths.length || retainedDirectories.length ? 'Retained artifacts need manual review' : null)
638
+ : 'Canonical files do not agree on one generation',
639
+ });
640
+ }
641
+ return report;
642
+ }
643
+
644
+ // Clear the manifests inspectTransactions marked resolvable. Touches no
645
+ // canonical file — those already agree on one generation; this only removes the
646
+ // bookkeeping that is wedging later mutations.
647
+ export function resolveTransactions(repoRoot, options = {}) {
648
+ const cleared = [];
649
+ for (const item of inspectTransactions(repoRoot, options)) {
650
+ if (!item.resolvable) continue;
651
+ let manifest;
652
+ try { manifest = JSON.parse(readFileSync(item.manifestPath, 'utf8')); } catch { continue; }
653
+ withPathLocks(manifest.participants.map(entry => entry.path), { repoRoot, ...options }, () => {
654
+ // Re-check under the lock: nothing may have moved since inspection.
655
+ const states = new Set(manifest.participants.map(participantState));
656
+ if (states.size !== 1 || !(states.has('old') || states.has('new'))) return;
657
+ cleanupTransactionDirectory(item.directory, manifest, options);
658
+ cleared.push({ id: item.id, generation: [...states][0] });
659
+ });
660
+ }
661
+ return cleared;
662
+ }
663
+
664
+ function ensureDirectoryDurable(directory, options, phase) {
665
+ if (existsSync(directory)) return false;
666
+ const parent = path.dirname(directory);
667
+ if (!existsSync(parent)) ensureDirectoryDurable(parent, options, phase);
668
+ try { mkdirSync(directory); }
669
+ catch (err) { if (err?.code === 'EEXIST') return false; throw err; }
670
+ fsyncDirectory(parent, options, phase);
671
+ return true;
672
+ }
673
+
674
+ function beginMoveManifest({ repoRoot, transactionId, operation, sessionId, source, targetPath, targetContent, updates, creations, deletions, gitIndex, tracked }, options) {
675
+ const root = transactionRoot(repoRoot, options);
676
+ ensureDirectoryDurable(root, options, 'transaction-root-create');
677
+ const directory = path.join(root, transactionId);
678
+ mkdirSync(directory);
679
+ try { fsyncDirectory(root, options, 'transaction-directory-create'); }
680
+ catch (err) {
681
+ try { rmdirSync(directory); fsyncDirectory(root, options, 'transaction-directory-create-rollback'); } catch { /* preserve durability error */ }
682
+ throw err;
683
+ }
684
+ const participants = [];
685
+ const add = (filePath, oldGeneration, newGeneration, label) => {
686
+ const index = participants.length;
687
+ const old = oldGeneration.exists ? {
688
+ exists: true, hash: oldGeneration.hash, mode: oldGeneration.mode,
689
+ artifact: path.join(directory, `${index}-old`),
690
+ } : { exists: false, artifact: null };
691
+ const next = newGeneration.exists ? {
692
+ exists: true, hash: contentHash(newGeneration.content), mode: newGeneration.mode,
693
+ artifact: path.join(directory, `${index}-new`),
694
+ } : { exists: false, artifact: null };
695
+ const policy = String(filePath).endsWith('.md') ? 'managed' : 'generated';
696
+ participants.push({ path: path.resolve(filePath), policy, label, old, new: next, transientHashes: [], _oldContent: oldGeneration.content, _newContent: newGeneration.content });
697
+ };
698
+ add(source.path, { exists: true, content: source.content, hash: source.hash, mode: source.identity.mode }, { exists: false }, 'source');
699
+ add(targetPath, { exists: false }, { exists: true, content: targetContent, mode: source.identity.mode }, 'target');
700
+ for (const item of updates) add(item.path,
701
+ { exists: true, content: item.snapshot.content, hash: item.snapshot.hash, mode: item.snapshot.identity.mode },
702
+ { exists: true, content: item.content, mode: item.snapshot.identity.mode }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'referrer');
703
+ for (const item of creations) add(item.path, { exists: false }, { exists: true, content: item.content, mode: item.mode ?? (0o666 & ~process.umask()) }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'creation');
704
+ for (const item of deletions) add(item.path,
705
+ { exists: true, content: item.snapshot.content, hash: item.snapshot.hash, mode: item.snapshot.identity.mode }, { exists: false }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'deletion');
706
+ const backup = path.join(directory, 'source-backup');
707
+ const stagedPath = path.join(directory, 'target-staged');
708
+ const gitPreparedArtifact = path.join(directory, 'git-index-prepared');
709
+ const gitRestoreArtifact = path.join(directory, 'git-index-restore');
710
+ const manifest = {
711
+ schema: TRANSACTION_SCHEMA,
712
+ id: transactionId,
713
+ operation: operation ?? 'move',
714
+ sessionId: sessionId ?? null,
715
+ owner: currentProcessOwner(),
716
+ createdAt: new Date().toISOString(),
717
+ phase: 'manifest',
718
+ status: 'active',
719
+ result: null,
720
+ directory,
721
+ directoryToken: randomUUID(),
722
+ participants: participants.map(({ _oldContent, _newContent, ...participant }) => participant),
723
+ gitIndex: { before: gitIndex ?? null, prepared: null, ownedAfter: null, retainedPaths: [] },
724
+ gitMove: tracked ? { source: path.relative(repoRoot, source.path).split(path.sep).join('/'), target: path.relative(repoRoot, targetPath).split(path.sep).join('/') } : null,
725
+ recoveryArtifacts: [backup, stagedPath, ...(tracked ? [gitPreparedArtifact, gitRestoreArtifact] : [])],
726
+ createdDirectories: [],
727
+ };
728
+ const manifestPath = path.join(directory, 'manifest.json');
729
+ try { durableJson(manifestPath, manifest, options); }
730
+ catch (err) {
731
+ try { cleanupTransactionDirectory(directory, manifest, options); }
732
+ catch (cleanupError) { err.message += `\nCould not clean failed manifest publication: ${cleanupError.message}`; }
733
+ throw err;
734
+ }
735
+ return { directory, manifestPath, manifest, participants, backup, stagedPath, gitPreparedArtifact, gitRestoreArtifact, createdDirectories: [], createdDirectoryOwnership: new Set() };
736
+ }
737
+
738
+ function stageMoveManifest(transaction, targetContent, sourceMode, options) {
739
+ for (let index = 0; index < transaction.participants.length; index++) {
740
+ const participant = transaction.participants[index];
741
+ if (participant.old.exists) writeTransactionArtifact(transaction.directory, `${index}-old`, participant._oldContent, participant.old.mode, options);
742
+ if (participant.new.exists) writeTransactionArtifact(transaction.directory, `${index}-new`, participant._newContent, participant.new.mode, options);
743
+ }
744
+ writeTransactionArtifact(transaction.directory, 'target-staged', targetContent, sourceMode, options);
745
+ setMoveManifestPhase(transaction, 'staging', options);
746
+ }
747
+
748
+ function ensureTransactionDirectory(directory, transaction, options, participantPath) {
749
+ if (existsSync(directory)) return;
750
+ const missing = [];
751
+ let cursor = directory;
752
+ while (!existsSync(cursor)) { missing.unshift(cursor); cursor = path.dirname(cursor); }
753
+ for (const item of missing) {
754
+ const intent = {
755
+ path: item,
756
+ participantPath: path.resolve(participantPath),
757
+ marker: path.join(item, `${ARTIFACT_PREFIX}transaction-${transaction.manifest.id}`),
758
+ token: transaction.manifest.directoryToken,
759
+ markerState: 'intended',
760
+ identity: null,
761
+ };
762
+ transaction.manifest.createdDirectories.push(intent);
763
+ transaction.createdDirectories.push(intent);
764
+ durableJson(transaction.manifestPath, transaction.manifest, options);
765
+ options.testHooks?.afterTransactionPhase?.('directory-intent', { directory: item, intent, manifestPath: transaction.manifestPath });
766
+ mkdirSync(item);
767
+ transaction.createdDirectoryOwnership.add(item);
768
+ fsyncDirectory(path.dirname(item), options, 'canonical-directory-create');
769
+ options.testHooks?.afterTransactionPhase?.('directory-create', { directory: item, intent, manifestPath: transaction.manifestPath });
770
+ const createdIdentity = lstatSync(item);
771
+ intent.identity = { dev: createdIdentity.dev, ino: createdIdentity.ino };
772
+ durableJson(transaction.manifestPath, transaction.manifest, options);
773
+ const fd = openSync(intent.marker, 'wx', 0o600);
774
+ try { writeFileSync(fd, `${intent.token}\n`); fsyncSync(fd); } finally { closeSync(fd); }
775
+ fsyncDirectory(item, options, 'canonical-directory-marker');
776
+ intent.markerState = 'created';
777
+ durableJson(transaction.manifestPath, transaction.manifest, options);
778
+ options.testHooks?.afterTransactionPhase?.('directory-marker', { directory: item, intent, manifestPath: transaction.manifestPath });
779
+ }
780
+ }
781
+
782
+ function cleanupCreatedDirectories(transaction, options) {
783
+ let complete = true;
784
+ const manifest = transaction.manifest ?? transaction;
785
+ for (const intent of [...transaction.createdDirectories].reverse()) {
786
+ try {
787
+ const directory = intent.path;
788
+ if (!(transaction.createdDirectoryOwnership instanceof Set) || !transaction.createdDirectoryOwnership.has(directory)) continue;
789
+ if (!existsSync(directory)) continue;
790
+ const currentIdentity = lstatSync(directory);
791
+ if (!currentIdentity.isDirectory() || !intent.identity || currentIdentity.dev !== intent.identity.dev || currentIdentity.ino !== intent.identity.ino) {
792
+ complete = false;
793
+ continue;
794
+ }
795
+ if (!existsSync(intent.marker)) {
796
+ if (['removing', 'removed'].includes(intent.markerState)) {
797
+ if (readdirSync(directory).length === 0) {
798
+ rmdirSync(directory);
799
+ fsyncDirectory(path.dirname(directory), options, 'canonical-directory-rollback');
800
+ }
801
+ continue;
802
+ }
803
+ complete = false;
804
+ continue;
805
+ }
806
+ if (lstatSync(intent.marker).isSymbolicLink() || readFileSync(intent.marker, 'utf8') !== `${intent.token}\n`) {
807
+ complete = false;
808
+ continue;
809
+ }
810
+ const entries = readdirSync(directory).filter(entry => path.join(directory, entry) !== intent.marker);
811
+ intent.markerState = 'removing';
812
+ if (transaction.manifestPath) durableJson(transaction.manifestPath, manifest, options);
813
+ unlinkSync(intent.marker);
814
+ fsyncDirectory(directory, options, 'canonical-directory-marker-delete');
815
+ if (entries.length === 0) {
816
+ rmdirSync(directory);
817
+ fsyncDirectory(path.dirname(directory), options, 'canonical-directory-rollback');
818
+ } else {
819
+ intent.markerState = 'removed';
820
+ if (transaction.manifestPath) durableJson(transaction.manifestPath, manifest, options);
821
+ }
822
+ } catch { complete = false; }
823
+ }
824
+ return complete;
825
+ }
826
+
827
+ // The Git index snapshot a move stages against is captured before the content
828
+ // phase, which in a repo with thousands of docs takes seconds. Anything that
829
+ // rewrites `.git/index` in that window — including a bare `git status`, which
830
+ // rewrites it to refresh its stat cache — used to fail the publication CAS and
831
+ // abort the whole move. Re-base on the generation that is current at staging
832
+ // time instead: nothing of ours has reached the index yet, so our staging
833
+ // belongs on top of whatever landed meanwhile, which is exactly what a plain
834
+ // `git mv` would produce. The manifest records the new base durably BEFORE any
835
+ // index work, so recovery of a crash mid-stage still restores what it must.
836
+ function rebaseGitIndexSnapshot(transaction, repoRoot, options) {
837
+ const current = captureGitIndexGeneration(repoRoot);
838
+ if (current.indexPath !== transaction.manifest.gitIndex.before.indexPath) {
839
+ throw new MutationConflictError('The selected Git index changed while the move was in flight; staging was not attempted.');
840
+ }
841
+ if (sameGitIndexGeneration(current, transaction.manifest.gitIndex.before)) return;
842
+ transaction.manifest.gitIndex.before = current;
843
+ durableJson(transaction.manifestPath, transaction.manifest, options);
844
+ options.testHooks?.afterTransactionPhase?.('git-index-rebase', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
845
+ }
846
+
847
+ function setMoveManifestPhase(transaction, phase, options, detail = null) {
848
+ transaction.manifest.phase = phase;
849
+ if (detail !== null) transaction.manifest.detail = detail;
850
+ durableJson(transaction.manifestPath, transaction.manifest, options);
851
+ options.testHooks?.afterTransactionPhase?.(phase, { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
852
+ }
853
+
854
+ function companionPhase(item, fallback) {
855
+ const ownershipPath = [STATE_DIR, LEGACY_STATE_DIR]
856
+ .some(dir => item.path.includes(`${path.sep}${dir}${path.sep}ownership${path.sep}`));
857
+ return item.label === 'ownership' || ownershipPath ? 'ownership-publication' : fallback;
858
+ }
859
+
860
+ function statIdentity(stat) {
861
+ return {
862
+ dev: stat.dev.toString(),
863
+ ino: stat.ino.toString(),
864
+ size: stat.size.toString(),
865
+ mtimeNs: stat.mtimeNs.toString(),
866
+ ctimeNs: stat.ctimeNs.toString(),
867
+ mode: Number(stat.mode & 0o7777n),
868
+ };
869
+ }
870
+
871
+ function sameIdentity(a, b) {
872
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size
873
+ && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
874
+ }
875
+
876
+ function sameGenerationIdentity(a, b) {
877
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size;
878
+ }
879
+
880
+ export function snapshotFile(filePath) {
881
+ const resolved = path.resolve(filePath);
882
+ const before = statSync(resolved, { bigint: true });
883
+ if (!before.isFile()) throw new Error(`Mutation source is not a file: ${resolved}`);
884
+ const content = readFileSync(resolved, 'utf8');
885
+ const after = statSync(resolved, { bigint: true });
886
+ const identity = statIdentity(after);
887
+ if (!sameIdentity(statIdentity(before), identity) || BigInt(Buffer.byteLength(content)) !== after.size) {
888
+ throw new MutationConflictError(`File changed while it was being read: ${resolved}`);
889
+ }
890
+ return { path: resolved, content, hash: contentHash(content), identity };
891
+ }
892
+
893
+ export function assertSnapshotCurrent(snapshot) {
894
+ let current;
895
+ try { current = snapshotFile(snapshot.path); }
896
+ catch (err) {
897
+ if (err?.code === 'ENOENT') throw new MutationConflictError(`File disappeared after it was read: ${snapshot.path}`);
898
+ throw err;
899
+ }
900
+ const identityMatches = snapshot.identityKind === 'inode-content'
901
+ ? sameGenerationIdentity(snapshot.identity, current.identity)
902
+ : sameIdentity(snapshot.identity, current.identity);
903
+ if (!identityMatches || snapshot.hash !== current.hash) {
904
+ throw new MutationConflictError(`File changed after it was read; refusing to overwrite newer content: ${snapshot.path}`);
905
+ }
906
+ }
907
+
908
+ function canonicalPath(filePath) {
909
+ const resolved = path.resolve(filePath);
910
+ if (existsSync(resolved)) return realpathSync(resolved);
911
+ const suffix = [];
912
+ let cursor = resolved;
913
+ while (!existsSync(cursor)) {
914
+ suffix.unshift(path.basename(cursor));
915
+ const parent = path.dirname(cursor);
916
+ if (parent === cursor) break;
917
+ cursor = parent;
918
+ }
919
+ return path.join(realpathSync(cursor), ...suffix);
920
+ }
921
+
922
+ function lockPathFor(canonical, repoRoot) {
923
+ const key = createHash('sha256').update(canonical).digest('hex');
924
+ return path.join(stateDir(repoRoot), 'locks', `${key}.lock`);
925
+ }
926
+
927
+ function ownerDescription(lockPath) {
928
+ try {
929
+ const owner = JSON.parse(readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
930
+ const age = Math.max(0, Date.now() - Date.parse(owner.createdAt));
931
+ return `pid ${owner.pid} on ${owner.hostname} (process started ${owner.processStartedAt ?? 'unknown'}, lock age ${age}ms)`;
932
+ } catch {
933
+ return 'another process';
934
+ }
935
+ }
936
+
937
+ function lockOwner(lockPath) {
938
+ try { return JSON.parse(readFileSync(path.join(lockPath, 'owner.json'), 'utf8')); }
939
+ catch { return null; }
940
+ }
941
+
942
+ function ownerIsDemonstrablyDead(owner) {
943
+ return processOwnerLiveness(owner) === 'dead';
944
+ }
945
+
946
+ function removeLockDirectory(lockPath, lockRoot) {
947
+ if (!contained(lockRoot, lockPath) || lstatSync(lockPath).isSymbolicLink()) return false;
948
+ for (const entry of readdirSync(lockPath)) {
949
+ if (!/^owner\.json$|^reclaiming$|^\.owner-[\w-]+\.tmp$/.test(entry)) return false;
950
+ unlinkSync(path.join(lockPath, entry));
951
+ }
952
+ rmdirSync(lockPath);
953
+ return true;
954
+ }
955
+
956
+ function reclaimLock(lockPath, lockRoot) {
957
+ try { statSync(lockPath); } catch { return true; }
958
+ const owner = lockOwner(lockPath);
959
+ if (!ownerIsDemonstrablyDead(owner)) return false;
960
+
961
+ // A marker inside the old directory serializes reclaimers. A live owner is
962
+ // never reclaimed. An ownerless directory is deliberately left for bounded
963
+ // timeout/manual recovery: reclaiming it could steal from a live process
964
+ // paused between mkdir and atomic owner publication.
965
+ let marker;
966
+ try {
967
+ marker = openSync(path.join(lockPath, 'reclaiming'), 'wx');
968
+ closeSync(marker);
969
+ } catch { return false; }
970
+ const tombstone = `${lockPath}.reclaimed-${process.pid}-${Date.now()}-${tempSequence++}`;
971
+ try {
972
+ renameSync(lockPath, tombstone);
973
+ return removeLockDirectory(tombstone, path.dirname(tombstone));
974
+ } catch {
975
+ return false;
976
+ }
977
+ }
978
+
979
+ export function withPathLocks(filePaths, options, callback) {
980
+ const { repoRoot, timeoutMs = MUTATION_LOCK_TIMEOUT_MS, retryMs = 20 } = options;
981
+ if (!repoRoot) throw new Error('withPathLocks requires repoRoot.');
982
+ const canonicals = [...new Set(filePaths.map(canonicalPath))].sort();
983
+ const lockRoot = safeGeneratedPath(path.join(stateDir(repoRoot), 'locks'), repoRoot, options, 'Lock root');
984
+ ensureDirectoryDurable(lockRoot, options, 'lock-root-create');
985
+ const acquired = [];
986
+ const deadline = Date.now() + timeoutMs;
987
+ try {
988
+ for (const canonical of canonicals) {
989
+ const lockPath = lockPathFor(canonical, repoRoot);
990
+ while (true) {
991
+ const token = randomUUID();
992
+ let madeLock = false;
993
+ try {
994
+ mkdirSync(lockPath);
995
+ madeLock = true;
996
+ const ownerTemp = path.join(lockPath, `.owner-${token}.tmp`);
997
+ writeFileSync(ownerTemp, JSON.stringify({
998
+ token,
999
+ ...currentProcessOwner(),
1000
+ createdAt: new Date().toISOString(),
1001
+ path: canonical,
1002
+ }) + '\n', { flag: 'wx' });
1003
+ renameSync(ownerTemp, path.join(lockPath, 'owner.json'));
1004
+ // Deliberately not flushed. Every reader of owner.json — the token
1005
+ // check below, a peer's reclaim check — reads it back through the
1006
+ // page cache, which needs no flush. Flushing the lock directory
1007
+ // never made the record survive a crash either: a directory fsync
1008
+ // orders the entry, not the file's bytes, so all it could do was
1009
+ // make an entry outlive the contents it names. That shape is the
1010
+ // worst one available: an ownerless lock is never auto-reclaimed
1011
+ // (liveness is unverifiable without an owner), so it wedges the repo
1012
+ // until someone deletes it by hand, whereas an unflushed lock simply
1013
+ // does not survive the crash — which is the correct post-crash state
1014
+ // for a lock nobody holds. It also cost a real disk flush per lock.
1015
+ if (lockOwner(lockPath)?.token !== token) {
1016
+ throw new MutationConflictError(`Mutation lock ownership changed while claiming ${canonical}.`);
1017
+ }
1018
+ acquired.push({ lockPath, token });
1019
+ break;
1020
+ } catch (err) {
1021
+ if (madeLock) {
1022
+ try {
1023
+ const owner = lockOwner(lockPath);
1024
+ if (!owner || owner.token === token) removeLockDirectory(lockPath, lockRoot);
1025
+ } catch { /* best effort */ }
1026
+ }
1027
+ if (err?.code !== 'EEXIST') throw err;
1028
+ if (reclaimLock(lockPath, lockRoot)) continue;
1029
+ if (Date.now() >= deadline) {
1030
+ throw new MutationLockError(`Timed out after ${timeoutMs}ms waiting for mutation lock on ${canonical} (held by ${ownerDescription(lockPath)}).`);
1031
+ }
1032
+ Atomics.wait(sleepBuffer, 0, 0, Math.min(retryMs, Math.max(1, deadline - Date.now())));
1033
+ }
1034
+ }
1035
+ }
1036
+ // One flush for the whole set, not one per lock. Mutual exclusion comes
1037
+ // from mkdir's atomicity, which is in-memory and needs no flush; this
1038
+ // fsync only has to make every lock durable before the callback mutates
1039
+ // anything, and one flush of the lock root does that for all of them. Per
1040
+ // lock it cost a full directory flush each (~6ms on APFS), so a reference
1041
+ // sweep that locks every doc in a large repo spent MINUTES here — long
1042
+ // enough that a concurrent `git status` would routinely invalidate the
1043
+ // transaction's Git index CAS mid-move.
1044
+ if (acquired.length) fsyncDirectory(lockRoot, options, 'lock-directory-create');
1045
+ return callback();
1046
+ } finally {
1047
+ let released = false;
1048
+ for (const { lockPath, token } of acquired.reverse()) {
1049
+ try {
1050
+ if (lockOwner(lockPath)?.token === token) {
1051
+ removeLockDirectory(lockPath, lockRoot);
1052
+ released = true;
1053
+ }
1054
+ } catch { /* preserve original error */ }
1055
+ }
1056
+ // Same batching on release. A lock directory that outlives a crash is
1057
+ // reclaimed by liveness check, so flushing each removal buys nothing.
1058
+ if (released) try { fsyncDirectory(lockRoot, options, 'lock-directory-delete'); } catch { /* preserve original error */ }
1059
+ }
1060
+ }
1061
+
1062
+ function tempPathFor(filePath) {
1063
+ const base = path.basename(filePath);
1064
+ return path.join(path.dirname(filePath), `.${base}${ARTIFACT_PREFIX}tmp-${process.pid}-${Date.now()}-${tempSequence++}`);
1065
+ }
1066
+
1067
+ function fsyncDirectory(dirPath, options = {}, phase = 'directory') {
1068
+ let fd;
1069
+ try {
1070
+ fd = openSync(dirPath, 'r');
1071
+ options.testHooks?.beforeDirectoryFsync?.(phase, dirPath);
1072
+ fsyncSync(fd);
1073
+ } catch (err) {
1074
+ if (!['EINVAL', 'ENOTSUP', 'EBADF', 'EISDIR', 'EPERM'].includes(err?.code)) throw err;
1075
+ } finally {
1076
+ if (fd !== undefined) try { closeSync(fd); } catch { /* best effort */ }
1077
+ }
1078
+ }
1079
+
1080
+ function writeCompleteTemp(filePath, content, mode) {
1081
+ const tempPath = tempPathFor(filePath);
1082
+ let fd;
1083
+ try {
1084
+ fd = openSync(tempPath, 'wx', mode);
1085
+ writeFileSync(fd, content, 'utf8');
1086
+ fchmodSync(fd, mode);
1087
+ fsyncSync(fd);
1088
+ const finalStat = fstatSync(fd, { bigint: true });
1089
+ closeSync(fd);
1090
+ fd = undefined;
1091
+ return {
1092
+ path: tempPath,
1093
+ content,
1094
+ hash: contentHash(content),
1095
+ identity: statIdentity(finalStat),
1096
+ };
1097
+ } catch (err) {
1098
+ if (fd !== undefined) try { closeSync(fd); } catch { /* best effort */ }
1099
+ try { unlinkSync(tempPath); } catch { /* best effort */ }
1100
+ throw err;
1101
+ }
1102
+ }
1103
+
1104
+ function committedGeneration(prepared, finalPath) {
1105
+ return {
1106
+ path: path.resolve(finalPath),
1107
+ content: prepared.content,
1108
+ hash: prepared.hash,
1109
+ identity: prepared.identity,
1110
+ identityKind: 'inode-content',
1111
+ };
1112
+ }
1113
+
1114
+ function recoveryArtifact(filePath, content, mode, label) {
1115
+ const artifact = path.join(path.dirname(filePath), `.${path.basename(filePath)}${ARTIFACT_PREFIX}recovery-${label}-${randomUUID()}`);
1116
+ const temp = writeCompleteTemp(artifact, content, mode);
1117
+ renameSync(temp.path, artifact);
1118
+ fsyncDirectory(path.dirname(artifact));
1119
+ return artifact;
1120
+ }
1121
+
1122
+ function rollbackConflict(error, message) {
1123
+ error.message += `\nRollback conflict: ${message}`;
1124
+ }
1125
+
1126
+ function removeCommitted(snapshot, error, label) {
1127
+ try {
1128
+ assertSnapshotCurrent(snapshot);
1129
+ unlinkSync(snapshot.path);
1130
+ try { fsyncDirectory(path.dirname(snapshot.path)); }
1131
+ catch (durabilityError) {
1132
+ rollbackConflict(error, `${label} was removed, but its directory could not be durably synced: ${durabilityError.message}`);
1133
+ }
1134
+ return true;
1135
+ } catch (conflict) {
1136
+ rollbackConflict(error, `${label} was replaced; left current file untouched at ${snapshot.path}.`);
1137
+ return false;
1138
+ }
1139
+ }
1140
+
1141
+ function restoreUpdate(committed, original, error) {
1142
+ try {
1143
+ assertSnapshotCurrent(committed);
1144
+ replaceSnapshot(committed, original.content, { repoRoot: path.dirname(original.path), locked: true });
1145
+ return true;
1146
+ } catch (conflict) {
1147
+ let artifact = 'could not write recovery artifact';
1148
+ try { artifact = recoveryArtifact(original.path, original.content, original.identity.mode, 'original'); } catch { /* report fallback text */ }
1149
+ rollbackConflict(error, `${original.path} changed after commit; left it untouched. Original content: ${artifact}.`);
1150
+ return false;
1151
+ }
1152
+ }
1153
+
1154
+ function reserveExclusive(filePath, mode, content, testHooks = {}) {
1155
+ let fd;
1156
+ let openedIdentity;
1157
+ let reservation;
1158
+ try {
1159
+ fd = openSync(filePath, 'wx', mode);
1160
+ const opened = fstatSync(fd, { bigint: true });
1161
+ openedIdentity = { dev: opened.dev, ino: opened.ino };
1162
+ const reservationContent = content ?? JSON.stringify({ runlistReservation: true, pid: process.pid, createdAt: new Date().toISOString() }) + '\n';
1163
+ writeFileSync(fd, reservationContent, 'utf8');
1164
+ testHooks.afterReserveWrite?.(filePath);
1165
+ testHooks.beforeReserveFsync?.(filePath);
1166
+ fsyncSync(fd);
1167
+ const finalStat = fstatSync(fd, { bigint: true });
1168
+ reservation = {
1169
+ path: path.resolve(filePath),
1170
+ content: reservationContent,
1171
+ hash: contentHash(reservationContent),
1172
+ identity: statIdentity(finalStat),
1173
+ };
1174
+ closeSync(fd);
1175
+ fd = undefined;
1176
+ return reservation;
1177
+ } catch (err) {
1178
+ if (fd !== undefined) try { closeSync(fd); } catch { /* best effort */ }
1179
+ if (openedIdentity) {
1180
+ try {
1181
+ const current = lstatSync(filePath, { bigint: true });
1182
+ if (current.dev === openedIdentity.dev && current.ino === openedIdentity.ino) unlinkSync(filePath);
1183
+ } catch { /* missing or replaced by another actor */ }
1184
+ }
1185
+ throw err;
1186
+ }
1187
+ }
1188
+
1189
+ export function replaceSnapshot(snapshot, newContent, options = {}) {
1190
+ const { repoRoot, locked = false } = options;
1191
+ const commit = () => {
1192
+ const prepared = writeCompleteTemp(snapshot.path, newContent, snapshot.identity.mode);
1193
+ let committed;
1194
+ try {
1195
+ options.testHooks?.beforeReplacePublish?.({ snapshot, tempPath: prepared.path });
1196
+ assertSnapshotCurrent(snapshot);
1197
+ commitRename(prepared.path, snapshot.path, options.testHooks);
1198
+ committed = committedGeneration(prepared, snapshot.path);
1199
+ options.testHooks?.afterPublicationBeforePathOpen?.('replace', snapshot.path);
1200
+ try { fsyncDirectory(path.dirname(snapshot.path), options, 'replace-publish'); }
1201
+ catch (err) { err.committedSnapshot = committed; throw err; }
1202
+ return committed;
1203
+ } catch (err) {
1204
+ try { unlinkSync(prepared.path); } catch { /* already renamed or best effort */ }
1205
+ if (committed && !err.committedSnapshot) err.committedSnapshot = committed;
1206
+ throw err;
1207
+ }
1208
+ };
1209
+ return locked ? commit() : withPathLocks([snapshot.path], { repoRoot, ...options }, commit);
1210
+ }
1211
+
1212
+ export function mutateFile(filePath, options, render) {
1213
+ return withPathLocks([filePath], options, () => {
1214
+ options.testHooks?.beforeMutationSnapshot?.(filePath);
1215
+ const snapshot = snapshotFile(filePath);
1216
+ const rendered = render(snapshot.content, snapshot);
1217
+ if (rendered === snapshot.content) return { changed: false, snapshot };
1218
+ try {
1219
+ const committed = replaceSnapshot(snapshot, rendered, { ...options, locked: true });
1220
+ return { changed: true, snapshot, committed };
1221
+ } catch (err) {
1222
+ if (err.committedSnapshot) restoreUpdate(err.committedSnapshot, snapshot, err);
1223
+ throw err;
1224
+ }
1225
+ });
1226
+ }
1227
+
1228
+ export function createFileExclusive(filePath, content, options) {
1229
+ const { repoRoot, mode = 0o666 & ~process.umask(), locked = false } = options;
1230
+ const create = () => {
1231
+ if (existsSync(filePath)) throw new MutationConflictError(`Destination already exists: ${path.resolve(filePath)}`);
1232
+ const prepared = writeCompleteTemp(filePath, content, mode);
1233
+ let tempPublished = false;
1234
+ let committed;
1235
+ try {
1236
+ try {
1237
+ if (options.testHooks?.forceLinkUnsupported) {
1238
+ const error = new Error('injected unsupported hard link');
1239
+ error.code = 'ENOTSUP';
1240
+ throw error;
1241
+ }
1242
+ linkSync(prepared.path, filePath);
1243
+ committed = committedGeneration(prepared, filePath);
1244
+ options.testHooks?.afterPublicationBeforePathOpen?.('create-hardlink', filePath);
1245
+ } catch (err) {
1246
+ if (!['ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EXDEV', 'EMLINK'].includes(err?.code)) throw err;
1247
+ // Portable fallback: reserve with O_EXCL, then replace only our own
1248
+ // reservation. This may expose the reservation briefly, but can never
1249
+ // overwrite a competing creator's file.
1250
+ let fallbackReserved = false;
1251
+ let reservation;
1252
+ try {
1253
+ reservation = reserveExclusive(filePath, mode, undefined, options.testHooks);
1254
+ fallbackReserved = true;
1255
+ options.testHooks?.afterReservationAcquired?.(filePath);
1256
+ options.testHooks?.afterCreateReservation?.(filePath);
1257
+ assertSnapshotCurrent(reservation);
1258
+ commitRename(prepared.path, filePath, options.testHooks);
1259
+ tempPublished = true;
1260
+ committed = committedGeneration(prepared, filePath);
1261
+ options.testHooks?.afterPublicationBeforePathOpen?.('create-rename', filePath);
1262
+ } catch (fallbackError) {
1263
+ if (fallbackReserved) {
1264
+ try { assertSnapshotCurrent(reservation); unlinkSync(filePath); } catch { /* replaced by another actor */ }
1265
+ }
1266
+ throw fallbackError;
1267
+ }
1268
+ }
1269
+ if (!tempPublished) unlinkSync(prepared.path);
1270
+ try { fsyncDirectory(path.dirname(filePath), options, 'creation-publish'); }
1271
+ catch (err) { err.committedSnapshot = committed; throw err; }
1272
+ return committed;
1273
+ } catch (err) {
1274
+ try { unlinkSync(prepared.path); } catch { /* best effort */ }
1275
+ if (committed) removeCommitted(committed, err, 'created file after durability failure');
1276
+ if (err?.code === 'EEXIST') throw new MutationConflictError(`Destination already exists: ${path.resolve(filePath)}`);
1277
+ throw err;
1278
+ }
1279
+ };
1280
+ return locked ? create() : withPathLocks([filePath], options, create);
1281
+ }
1282
+
1283
+ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1284
+ const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [], guards = [] } = options;
1285
+ recoverAbandonedTransactions(repoRoot, options);
1286
+ return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path), ...guards.map(item => item.path)], options, () => {
1287
+ testHooks?.afterTransactionPhase?.('lock', { sourcePath, targetPath });
1288
+ testHooks?.beforeMoveSnapshot?.({ sourcePath, targetPath });
1289
+ const source = snapshotFile(sourcePath);
1290
+ if (options.expectedSourceContent !== undefined && source.content !== options.expectedSourceContent) {
1291
+ throw new MutationConflictError(`Source changed while the move transaction was being planned: ${source.path}`);
1292
+ }
1293
+ const targetDir = path.dirname(targetPath);
1294
+ const newContent = typeof render === 'function' ? render(source.content, source) : render;
1295
+ const preparedUpdates = updates.map(item => {
1296
+ const snapshot = snapshotFile(item.path);
1297
+ if (item.expectedContent !== undefined && snapshot.content !== item.expectedContent) {
1298
+ throw new MutationConflictError(`File changed while the move mutation set was being prepared: ${snapshot.path}`);
1299
+ }
1300
+ return { ...item, snapshot, content: item.render ? item.render(snapshot.content, snapshot) : item.content };
1301
+ }).filter(item => item.content !== item.snapshot.content);
1302
+ const preparedDeletions = deletions.map(item => {
1303
+ const snapshot = snapshotFile(item.path);
1304
+ if (item.expectedContent !== undefined && snapshot.content !== item.expectedContent) {
1305
+ throw new MutationConflictError(`File changed while the move deletion set was being prepared: ${snapshot.path}`);
1306
+ }
1307
+ return { ...item, snapshot };
1308
+ });
1309
+ // Read-only participants (see `mutateFileSet`). Checked before the
1310
+ // transaction manifest exists, so a guard conflict can never leave a
1311
+ // transaction to recover from.
1312
+ for (const guard of guards) {
1313
+ if (guard.absent) {
1314
+ if (existsSync(guard.path)) throw new MutationConflictError(`File appeared while the move mutation set was being prepared: ${path.resolve(guard.path)}`);
1315
+ continue;
1316
+ }
1317
+ const snapshot = snapshotFile(guard.path);
1318
+ if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1319
+ throw new MutationConflictError(`File changed while the move mutation set was being prepared: ${snapshot.path}`);
1320
+ }
1321
+ }
1322
+ for (const item of creations) {
1323
+ if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
1324
+ }
1325
+ const transactionId = `${process.pid}-${Date.now()}-${tempSequence++}`;
1326
+ const transaction = beginMoveManifest({
1327
+ repoRoot,
1328
+ transactionId,
1329
+ operation: options.operation,
1330
+ sessionId: options.sessionId,
1331
+ source,
1332
+ targetPath,
1333
+ targetContent: newContent,
1334
+ updates: preparedUpdates,
1335
+ creations,
1336
+ deletions: preparedDeletions,
1337
+ gitIndex: options.gitIndex,
1338
+ tracked: Boolean(options.gitMove || finalize),
1339
+ }, options);
1340
+ const { backup, stagedPath } = transaction;
1341
+ let reserved = false;
1342
+ let reservation;
1343
+ let moved = false;
1344
+ let published = false;
1345
+ let publishedSnapshot;
1346
+ let backupSnapshot;
1347
+ const committedUpdates = [];
1348
+ const committedCreations = [];
1349
+ const committedDeletions = [];
1350
+ let finalizeAttempted = false;
1351
+ let pastRecoveryBoundary = false;
1352
+ try {
1353
+ testHooks?.afterTransactionPhase?.('manifest', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
1354
+ stageMoveManifest(transaction, newContent, source.identity.mode, options);
1355
+ const prepared = snapshotFile(stagedPath);
1356
+ ensureTransactionDirectory(targetDir, transaction, options, targetPath);
1357
+ for (const item of creations) ensureTransactionDirectory(path.dirname(item.path), transaction, options, item.path);
1358
+ try {
1359
+ reservation = reserveExclusive(targetPath, source.identity.mode, JSON.stringify({
1360
+ runlistReservation: true,
1361
+ transactionId,
1362
+ sourcePath,
1363
+ backup,
1364
+ pid: process.pid,
1365
+ createdAt: new Date().toISOString(),
1366
+ }) + '\n', testHooks);
1367
+ } catch (err) {
1368
+ if (err?.code === 'EEXIST') throw new MutationConflictError(`Destination already exists: ${path.resolve(targetPath)}`);
1369
+ throw err;
1370
+ }
1371
+ reserved = true;
1372
+ transaction.manifest.participants[1].transientHashes.push(reservation.hash);
1373
+ setMoveManifestPhase(transaction, 'reservation', options);
1374
+ testHooks?.afterReservationAcquired?.(targetPath);
1375
+ fsyncDirectory(targetDir);
1376
+
1377
+ testHooks?.afterMoveReservation?.({ source, sourcePath, targetPath });
1378
+ assertSnapshotCurrent(source);
1379
+ testHooks?.afterMoveValidation?.({ source, sourcePath, targetPath });
1380
+ // Revalidate immediately before the move, after every hook/preparation
1381
+ // step that can permit an injected or external source edit.
1382
+ assertSnapshotCurrent(source);
1383
+ for (const item of preparedUpdates) assertSnapshotCurrent(item.snapshot);
1384
+ commitRename(sourcePath, backup, testHooks);
1385
+ moved = true;
1386
+ backupSnapshot = { ...source, path: backup, identityKind: 'inode-content' };
1387
+ fsyncDirectory(path.dirname(sourcePath));
1388
+ setMoveManifestPhase(transaction, 'source-move', options);
1389
+
1390
+ testHooks?.afterSourceMove?.({ backup, sourcePath, targetPath });
1391
+ assertSnapshotCurrent(reservation);
1392
+ commitRename(stagedPath, targetPath, testHooks);
1393
+ published = true;
1394
+ reserved = false;
1395
+ publishedSnapshot = committedGeneration(prepared, targetPath);
1396
+ testHooks?.afterPublicationBeforePathOpen?.('move-target', targetPath);
1397
+ fsyncDirectory(targetDir, options, 'move-target-publish');
1398
+ setMoveManifestPhase(transaction, 'target-publication', options);
1399
+ for (const item of preparedUpdates) {
1400
+ try {
1401
+ const committed = replaceSnapshot(item.snapshot, item.content, { ...options, locked: true });
1402
+ committedUpdates.push({ ...item, committed });
1403
+ } catch (err) {
1404
+ if (err.committedSnapshot) committedUpdates.push({ ...item, committed: err.committedSnapshot });
1405
+ throw err;
1406
+ }
1407
+ setMoveManifestPhase(transaction, companionPhase(item, 'referrer-publication'), options, item.path);
1408
+ }
1409
+ for (const item of creations) {
1410
+ const committed = createFileExclusive(item.path, item.content, { ...options, mode: item.mode, locked: true });
1411
+ committedCreations.push({ ...item, committed });
1412
+ setMoveManifestPhase(transaction, companionPhase(item, 'creation-publication'), options, item.path);
1413
+ }
1414
+ for (const item of preparedDeletions) {
1415
+ assertSnapshotCurrent(item.snapshot);
1416
+ unlinkSync(item.path);
1417
+ fsyncDirectory(path.dirname(item.path), options, 'move-companion-delete');
1418
+ committedDeletions.push(item);
1419
+ setMoveManifestPhase(transaction, companionPhase(item, 'deletion-publication'), options, item.path);
1420
+ testHooks?.afterMoveDeletion?.(committedDeletions.length, item.path);
1421
+ }
1422
+ testHooks?.afterMovePublish?.({ backup, sourcePath, targetPath });
1423
+
1424
+ if (options.gitMove && transaction.manifest.gitIndex.before) {
1425
+ const gitHooks = {
1426
+ ...options.testHooks,
1427
+ afterGitIndexArtifact: info => {
1428
+ transaction.manifest.gitIndex.prepared = info.prepared;
1429
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1430
+ options.testHooks?.afterTransactionPhase?.('git-index-artifact', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
1431
+ options.testHooks?.afterGitIndexArtifact?.(info);
1432
+ },
1433
+ afterGitIndexWorkSeed: info => {
1434
+ transaction.manifest.gitIndex.prepared = info.prepared;
1435
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1436
+ options.testHooks?.afterGitIndexWorkSeed?.(info);
1437
+ },
1438
+ afterGitIndexPrepareStep: info => {
1439
+ transaction.manifest.gitIndex.prepared = info.prepared;
1440
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1441
+ options.testHooks?.afterTransactionPhase?.('git-index-prepare-step', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
1442
+ options.testHooks?.afterGitIndexPrepareStep?.(info);
1443
+ },
1444
+ afterGitIndexSubprocessBeforeCheckpoint: info => {
1445
+ options.testHooks?.afterTransactionPhase?.('git-index-post-subprocess', { manifestPath: transaction.manifestPath, manifest: transaction.manifest, ...info });
1446
+ options.testHooks?.afterGitIndexSubprocessBeforeCheckpoint?.(info);
1447
+ },
1448
+ afterGitIndexPrepared: info => {
1449
+ transaction.manifest.gitIndex.prepared = info.prepared;
1450
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1451
+ options.testHooks?.afterTransactionPhase?.('git-index-prepared', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
1452
+ options.testHooks?.afterGitIndexPrepared?.(info);
1453
+ },
1454
+ afterGitIndexLock: info => {
1455
+ options.testHooks?.afterTransactionPhase?.('git-index-lock', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
1456
+ options.testHooks?.afterGitIndexLock?.(info);
1457
+ },
1458
+ afterGitIndexCompare: info => {
1459
+ options.testHooks?.afterTransactionPhase?.('git-index-compare', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
1460
+ options.testHooks?.afterGitIndexCompare?.(info);
1461
+ },
1462
+ afterGitIndexPublication: info => {
1463
+ options.testHooks?.afterTransactionPhase?.('git-index-publication', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
1464
+ options.testHooks?.afterGitIndexPublication?.(info);
1465
+ },
1466
+ };
1467
+ for (let attempt = 0; ; attempt++) {
1468
+ rebaseGitIndexSnapshot(transaction, repoRoot, options);
1469
+ finalizeAttempted = true;
1470
+ try {
1471
+ transaction.manifest.gitIndex.ownedAfter = stageMovePathsCas(
1472
+ transaction.manifest.gitMove.source,
1473
+ transaction.manifest.gitMove.target,
1474
+ repoRoot,
1475
+ transaction.manifest.gitIndex.before,
1476
+ { testHooks: gitHooks, artifactPath: transaction.gitPreparedArtifact },
1477
+ );
1478
+ break;
1479
+ } catch (err) {
1480
+ // A raced attempt published nothing, so re-staging on the
1481
+ // generation that beat us is safe — and it is what the user asked
1482
+ // for. Only contention is retried; a refused `git add` is a verdict
1483
+ // and retrying it just multiplies the failure. The budget is small
1484
+ // on purpose: these attempts run while every participant path is
1485
+ // locked, and peers only wait MUTATION_LOCK_TIMEOUT_MS.
1486
+ if (attempt >= GIT_INDEX_STAGE_ATTEMPTS - 1 || !gitIndexPublicationRaced(err)) throw err;
1487
+ // Give up on the ORIGINAL error if the scratch state cannot be
1488
+ // tidied: it is the one carrying proof that nothing was published,
1489
+ // which is what lets the rollback finish instead of retaining the
1490
+ // transaction for manual repair.
1491
+ let reclaimed;
1492
+ try { reclaimed = reclaimPreparedGitIndex(transaction.manifest.gitIndex, repoRoot, options); }
1493
+ catch { throw err; }
1494
+ if (reclaimed.retainedPaths.length) throw err;
1495
+ transaction.manifest.gitIndex.prepared = null;
1496
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1497
+ options.testHooks?.afterTransactionPhase?.('git-index-stage-retry', { manifestPath: transaction.manifestPath, manifest: transaction.manifest, attempt });
1498
+ Atomics.wait(sleepBuffer, 0, 0, GIT_INDEX_STAGE_BACKOFF_MS * (attempt + 1));
1499
+ }
1500
+ }
1501
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1502
+ } else if (finalize) {
1503
+ finalizeAttempted = true;
1504
+ finalize({ sourcePath, targetPath, backup });
1505
+ }
1506
+ setMoveManifestPhase(transaction, 'canonical-commit', options);
1507
+ testHooks?.afterMoveFinalize?.({ backup, sourcePath, targetPath });
1508
+ unlinkSync(backup);
1509
+ moved = false;
1510
+ pastRecoveryBoundary = true;
1511
+ fsyncDirectory(transaction.directory, options, 'move-backup-delete');
1512
+ setMoveManifestPhase(transaction, 'backup-deletion', options);
1513
+ if (!cleanupCreatedDirectories(transaction, options)) throw new Error('Could not remove transaction-owned directory markers after commit.');
1514
+ transaction.manifest.phase = 'completed';
1515
+ transaction.manifest.status = 'complete';
1516
+ transaction.manifest.result = 'committed';
1517
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1518
+ testHooks?.afterTransactionPhase?.('manifest-completion', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
1519
+ testHooks?.afterTransactionPhase?.('final-commit', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
1520
+ setMoveManifestPhase(transaction, 'cleanup', options);
1521
+ cleanupTransactionDirectory(transaction.directory, transaction.manifest, options);
1522
+ return { source, target: publishedSnapshot, updatedPaths: preparedUpdates.map(item => item.path) };
1523
+ } catch (err) {
1524
+ if (pastRecoveryBoundary) {
1525
+ try {
1526
+ transaction.manifest.phase = 'failed-manual';
1527
+ transaction.manifest.status = 'failed-manual';
1528
+ transaction.manifest.result = 'failed-manual';
1529
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1530
+ } catch { /* retain existing durable evidence */ }
1531
+ err.message += `\n${transactionRepairMessage(transaction.manifestPath, transaction.manifest, 'Move content and Git index are committed, but post-commit durability/cleanup failed; no rollback was attempted.')}`;
1532
+ throw err;
1533
+ }
1534
+ let rollbackFailed = false;
1535
+ for (const item of committedDeletions.reverse()) {
1536
+ try { createFileExclusive(item.path, item.snapshot.content, { ...options, mode: item.snapshot.identity.mode, locked: true }); }
1537
+ catch (rollbackError) { rollbackFailed = true; rollbackConflict(err, `could not restore deleted companion ${item.path}: ${rollbackError.message}`); }
1538
+ }
1539
+ for (const item of committedCreations.reverse()) if (!removeCommitted(item.committed, err, 'created move companion')) rollbackFailed = true;
1540
+ for (const item of committedUpdates.reverse()) if (!restoreUpdate(item.committed, item.snapshot, err)) rollbackFailed = true;
1541
+ if (published) {
1542
+ if (!removeCommitted(publishedSnapshot, err, 'published move target')) rollbackFailed = true;
1543
+ published = false;
1544
+ } else if (reserved) {
1545
+ try { assertSnapshotCurrent(reservation); unlinkSync(targetPath); }
1546
+ catch { rollbackFailed = true; }
1547
+ reserved = false;
1548
+ }
1549
+ if (moved) {
1550
+ try {
1551
+ assertSnapshotCurrent(backupSnapshot);
1552
+ if (existsSync(sourcePath)) {
1553
+ rollbackFailed = true;
1554
+ rollbackConflict(err, `source was recreated at ${sourcePath}; left recoverable original at ${backup}.`);
1555
+ } else {
1556
+ renameSync(backup, sourcePath);
1557
+ moved = false;
1558
+ try { fsyncDirectory(path.dirname(sourcePath), options, 'rollback-source-restore'); }
1559
+ catch (durabilityError) {
1560
+ rollbackFailed = true;
1561
+ rollbackConflict(err, `source was restored but its parent directory could not be durably synced: ${durabilityError.message}`);
1562
+ }
1563
+ }
1564
+ } catch (rollbackError) {
1565
+ rollbackFailed = true;
1566
+ rollbackConflict(err, `could not restore source; recoverable original remains at ${backup}: ${rollbackError.message}`);
1567
+ }
1568
+ }
1569
+ try { if (finalizeAttempted) {
1570
+ if (transaction.manifest.gitIndex.before) {
1571
+ if (transaction.manifest.gitIndex.ownedAfter) {
1572
+ restoreGitIndexCas(transaction.manifest.gitIndex.before, transaction.manifest.gitIndex.ownedAfter, repoRoot, {
1573
+ artifactPath: transaction.gitRestoreArtifact,
1574
+ testHooks: {
1575
+ ...options.testHooks,
1576
+ afterGitRestoreArtifact: info => {
1577
+ transaction.manifest.gitIndex.prepared = info.prepared;
1578
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1579
+ options.testHooks?.afterGitRestoreArtifact?.(info);
1580
+ },
1581
+ afterGitRestorePrepared: info => {
1582
+ transaction.manifest.gitIndex.prepared = info.prepared;
1583
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1584
+ options.testHooks?.afterGitRestorePrepared?.(info);
1585
+ },
1586
+ },
1587
+ });
1588
+ } else if (gitIndexProvablyUnpublished(err)) {
1589
+ // Staging failed before `.git/index` was replaced, so there is
1590
+ // nothing of ours to restore. The index may still differ from our
1591
+ // snapshot — that is another process's staging, and preserving it
1592
+ // IS the correct outcome. Treating that difference as a failed
1593
+ // rollback (as this used to) retained the transaction as
1594
+ // failed-manual, which then blocked EVERY later mutation in the
1595
+ // repo until `runlist doctor --transactions --apply` cleared it, for
1596
+ // a move that had already rolled back completely.
1597
+ } else if (!sameGitIndexGeneration(captureGitIndexGeneration(repoRoot), transaction.manifest.gitIndex.before)) {
1598
+ throw new Error('Git finalize did not publish its prepared generation, but the real index changed; current staging was preserved.');
1599
+ }
1600
+ } else if (rollbackFinalize) rollbackFinalize({ sourcePath, targetPath, backup });
1601
+ } } catch (rollbackError) {
1602
+ rollbackFailed = true;
1603
+ rollbackConflict(err, `could not restore Git index: ${rollbackError.message}`);
1604
+ }
1605
+ try {
1606
+ if (transaction.manifest.gitIndex.prepared) {
1607
+ const reclaimed = reclaimPreparedGitIndex(transaction.manifest.gitIndex, repoRoot, options);
1608
+ if (reclaimed.retainedPaths.length) {
1609
+ transaction.manifest.gitIndex.retainedPaths = [...new Set([
1610
+ ...transaction.manifest.gitIndex.retainedPaths,
1611
+ ...reclaimed.retainedPaths,
1612
+ ])];
1613
+ rollbackFailed = true;
1614
+ rollbackConflict(err, `unverified Git preparation work was preserved for manual recovery: ${reclaimed.retainedPaths.join(', ')}`);
1615
+ }
1616
+ }
1617
+ } catch (cleanupError) {
1618
+ rollbackFailed = true;
1619
+ rollbackConflict(err, `could not safely clean prepared Git index state: ${cleanupError.message}`);
1620
+ }
1621
+ try { fsyncDirectory(targetDir); } catch { rollbackFailed = true; }
1622
+ try {
1623
+ const states = transaction.manifest.participants.map(participantState);
1624
+ if (!rollbackFailed && states.every(state => state === 'old') && cleanupCreatedDirectories(transaction, options)) {
1625
+ transaction.manifest.phase = 'completed';
1626
+ transaction.manifest.status = 'complete';
1627
+ transaction.manifest.result = 'rolled-back';
1628
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1629
+ cleanupTransactionDirectory(transaction.directory, transaction.manifest, options);
1630
+ } else {
1631
+ transaction.manifest.phase = 'failed-manual';
1632
+ transaction.manifest.status = 'failed-manual';
1633
+ transaction.manifest.result = 'failed-manual';
1634
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1635
+ err.message += `\n${transactionRepairMessage(transaction.manifestPath, transaction.manifest, 'Rollback was incomplete; durable evidence was retained.')}`;
1636
+ }
1637
+ } catch (manifestError) {
1638
+ err.message += `\nCould not finalize transaction recovery record: ${manifestError.message}`;
1639
+ }
1640
+ throw err;
1641
+ }
1642
+ });
1643
+ }
1644
+
1645
+ // `guards` are read-only participants: files the mutation's VALIDITY depends on
1646
+ // but that it never writes. They take part in locking and in the same
1647
+ // compare-and-swap as `updates`, so a mutation decided from a file it doesn't
1648
+ // modify cannot land after that file changed underneath it.
1649
+ //
1650
+ // Without this, a decision read and the write it justifies are two separate
1651
+ // steps with nothing holding the gap. A claim that adopts an already-in-session
1652
+ // plan writes only the ownership record — so a concurrent `set` releasing that
1653
+ // plan could win the status write while the claim still took ownership, leaving
1654
+ // a record that owns a plan the file says is `active`. A no-op update on the
1655
+ // plan file would close the gap too, but it would rewrite bytes and report the
1656
+ // plan as changed to anything counting touched files. A guard says what is meant.
1657
+ export function mutateFileSet({ updates = [], creations = [], guards = [] }, options) {
1658
+ const paths = [...updates.map(item => item.path), ...creations.map(item => item.path), ...guards.map(item => item.path)];
1659
+ return withPathLocks(paths, options, () => {
1660
+ // Guards are pure preconditions, so they are checked before anything at all
1661
+ // is created — a guard conflict must leave the tree byte-identical, not even
1662
+ // an empty directory behind.
1663
+ for (const guard of guards) {
1664
+ if (guard.absent) {
1665
+ if (existsSync(guard.path)) throw new MutationConflictError(`File appeared while the mutation set was being prepared: ${path.resolve(guard.path)}`);
1666
+ continue;
1667
+ }
1668
+ const snapshot = snapshotFile(guard.path);
1669
+ if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1670
+ throw new MutationConflictError(`File changed while the mutation set was being prepared: ${snapshot.path}`);
1671
+ }
1672
+ }
1673
+ const createdDirectories = [];
1674
+ for (const item of creations) {
1675
+ const directory = path.dirname(item.path);
1676
+ if (!existsSync(directory)) {
1677
+ const missing = [];
1678
+ let cursor = directory;
1679
+ while (!existsSync(cursor)) { missing.unshift(cursor); cursor = path.dirname(cursor); }
1680
+ for (const candidate of missing) {
1681
+ mkdirSync(candidate);
1682
+ fsyncDirectory(path.dirname(candidate), options, 'set-directory-create');
1683
+ createdDirectories.push(candidate);
1684
+ }
1685
+ }
1686
+ }
1687
+ const preparedUpdates = updates.map(item => {
1688
+ const snapshot = snapshotFile(item.path);
1689
+ if (item.expectedContent !== undefined && snapshot.content !== item.expectedContent) {
1690
+ throw new MutationConflictError(`File changed while the mutation set was being prepared: ${snapshot.path}`);
1691
+ }
1692
+ return { ...item, snapshot, content: item.render ? item.render(snapshot.content, snapshot) : item.content };
1693
+ });
1694
+ for (const item of creations) {
1695
+ if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
1696
+ }
1697
+ options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations, guards });
1698
+
1699
+ const committedUpdates = [];
1700
+ const committedCreations = [];
1701
+ try {
1702
+ for (const item of preparedUpdates) {
1703
+ try {
1704
+ const committed = replaceSnapshot(item.snapshot, item.content, { ...options, locked: true });
1705
+ committedUpdates.push({ ...item, committed });
1706
+ } catch (err) {
1707
+ if (err.committedSnapshot) committedUpdates.push({ ...item, committed: err.committedSnapshot });
1708
+ throw err;
1709
+ }
1710
+ options.testHooks?.afterSetCommit?.(committedUpdates.length + committedCreations.length, item.path);
1711
+ }
1712
+ for (const item of creations) {
1713
+ const committed = createFileExclusive(item.path, item.content, { ...options, mode: item.mode, locked: true });
1714
+ committedCreations.push({ ...item, committed });
1715
+ options.testHooks?.afterSetCommit?.(committedUpdates.length + committedCreations.length, item.path);
1716
+ }
1717
+ return { updates: preparedUpdates, creations };
1718
+ } catch (err) {
1719
+ for (const item of committedCreations.reverse()) {
1720
+ removeCommitted(item.committed, err, 'created file');
1721
+ }
1722
+ for (const item of committedUpdates.reverse()) {
1723
+ restoreUpdate(item.committed, item.snapshot, err);
1724
+ }
1725
+ for (const directory of createdDirectories.reverse()) {
1726
+ try {
1727
+ if (readdirSync(directory).length === 0) {
1728
+ rmdirSync(directory);
1729
+ fsyncDirectory(path.dirname(directory), options, 'set-directory-rollback');
1730
+ }
1731
+ } catch (rollbackError) { rollbackConflict(err, `could not remove newly created directory ${directory}: ${rollbackError.message}`); }
1732
+ }
1733
+ throw err;
1734
+ }
1735
+ });
1736
+ }