dotmd-cli 0.69.0 → 0.70.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -964
- package/bin/dotmd.mjs +251 -202
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -114
- package/src/bulk-tag.mjs +7 -7
- package/src/check-collapse.mjs +2 -2
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +25 -15
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +9 -7
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +722 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +21 -13
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +596 -294
- package/src/lint.mjs +117 -56
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +134 -75
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +24 -11
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +53 -17
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
|
@@ -0,0 +1,1505 @@
|
|
|
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, reclaimPreparedGitIndex, restoreGitIndexCas, sameGitIndexGeneration, stageMovePathsCas } from './git.mjs';
|
|
25
|
+
import { authorizeManagedDestination, authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
26
|
+
|
|
27
|
+
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
28
|
+
let tempSequence = 0;
|
|
29
|
+
|
|
30
|
+
export function processStartIdentity(pid) {
|
|
31
|
+
try {
|
|
32
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
33
|
+
const afterName = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/);
|
|
34
|
+
return `linux:${afterName[19]}`;
|
|
35
|
+
} catch { /* non-Linux or proc unavailable */ }
|
|
36
|
+
if (process.platform === 'win32') return null;
|
|
37
|
+
try {
|
|
38
|
+
const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 1000 }).trim();
|
|
39
|
+
return started ? `ps:${started}` : null;
|
|
40
|
+
} catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const PROCESS_STARTED_AT = new Date(Date.now() - Math.floor(process.uptime() * 1000)).toISOString();
|
|
44
|
+
export const PROCESS_START_IDENTITY = processStartIdentity(process.pid);
|
|
45
|
+
|
|
46
|
+
export function currentProcessOwner() {
|
|
47
|
+
return {
|
|
48
|
+
pid: process.pid,
|
|
49
|
+
hostname: os.hostname(),
|
|
50
|
+
processStartedAt: PROCESS_STARTED_AT,
|
|
51
|
+
processStartIdentity: PROCESS_START_IDENTITY,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function processOwnerLiveness(owner) {
|
|
56
|
+
if (!owner || owner.hostname !== os.hostname() || !Number.isInteger(owner.pid)) return 'unverifiable';
|
|
57
|
+
try {
|
|
58
|
+
process.kill(owner.pid, 0);
|
|
59
|
+
const currentStart = processStartIdentity(owner.pid);
|
|
60
|
+
// If the host cannot expose process start times, an existing PID is still
|
|
61
|
+
// conservatively live. This may delay stale-lock recovery after PID reuse,
|
|
62
|
+
// but can never steal a live process's lock.
|
|
63
|
+
if (!owner.processStartIdentity || !currentStart) return 'live';
|
|
64
|
+
return owner.processStartIdentity === currentStart ? 'live' : 'dead';
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err?.code === 'ESRCH') return 'dead';
|
|
67
|
+
if (err?.code === 'EPERM') return 'live';
|
|
68
|
+
return 'unverifiable';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class MutationConflictError extends Error {
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = 'MutationConflictError';
|
|
76
|
+
this.code = 'DOTMD_MUTATION_CONFLICT';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class MutationLockError extends Error {
|
|
81
|
+
constructor(message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'MutationLockError';
|
|
84
|
+
this.code = 'DOTMD_MUTATION_LOCK_TIMEOUT';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function contentHash(content) {
|
|
89
|
+
return createHash('sha256').update(content).digest('hex');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const TRANSACTION_SCHEMA = 2;
|
|
93
|
+
const TRANSACTION_OPERATIONS = new Set(['move', 'rename', 'lifecycle-move']);
|
|
94
|
+
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']);
|
|
95
|
+
const TRANSACTION_RESULTS = new Set([null, 'committed', 'rolled-back', 'rolled-forward', 'failed-manual']);
|
|
96
|
+
const TRANSACTION_STATUSES = new Set(['active', 'complete', 'failed-manual']);
|
|
97
|
+
|
|
98
|
+
function contained(parent, child) {
|
|
99
|
+
const relative = path.relative(parent, child);
|
|
100
|
+
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function safeGeneratedPath(filePath, repoRoot, options, kind) {
|
|
104
|
+
return authorizeRepoGeneratedPath(filePath, options.config ?? { repoRoot }, { kind }).path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function transactionRoot(repoRoot, options = {}) {
|
|
108
|
+
return safeGeneratedPath(path.join(path.resolve(repoRoot), '.dotmd', 'transactions'), repoRoot, options, 'Transaction root');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function durableJson(filePath, value, options = {}) {
|
|
112
|
+
const content = JSON.stringify(value, null, 2) + '\n';
|
|
113
|
+
const temp = writeCompleteTemp(filePath, content, 0o600);
|
|
114
|
+
renameSync(temp.path, filePath);
|
|
115
|
+
fsyncDirectory(path.dirname(filePath), options, 'transaction-manifest');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeTransactionArtifact(directory, name, content, mode, options) {
|
|
119
|
+
const artifact = path.join(directory, name);
|
|
120
|
+
const temp = writeCompleteTemp(artifact, content, mode);
|
|
121
|
+
renameSync(temp.path, artifact);
|
|
122
|
+
fsyncDirectory(directory, options, 'transaction-artifact');
|
|
123
|
+
return artifact;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function participantState(participant) {
|
|
127
|
+
if (!existsSync(participant.path)) {
|
|
128
|
+
if (!participant.old.exists) return 'old';
|
|
129
|
+
if (!participant.new.exists) return 'new';
|
|
130
|
+
return 'unknown';
|
|
131
|
+
}
|
|
132
|
+
let hash;
|
|
133
|
+
try { hash = contentHash(readFileSync(participant.path)); }
|
|
134
|
+
catch { return 'unknown'; }
|
|
135
|
+
if (participant.old.exists && hash === participant.old.hash) return 'old';
|
|
136
|
+
if (participant.new.exists && hash === participant.new.hash) return 'new';
|
|
137
|
+
if (participant.transientHashes?.includes(hash)) return 'transient';
|
|
138
|
+
return 'unknown';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function publishParticipantGeneration(participant, generation, repoRoot) {
|
|
142
|
+
const current = participantState(participant);
|
|
143
|
+
if (current === 'unknown') throw new MutationConflictError(`Transaction recovery found an unrecognized generation at ${participant.path}.`);
|
|
144
|
+
const alreadyDesired = generation.exists
|
|
145
|
+
? existsSync(participant.path) && contentHash(readFileSync(participant.path)) === generation.hash
|
|
146
|
+
: !existsSync(participant.path);
|
|
147
|
+
if (alreadyDesired) return;
|
|
148
|
+
if (!generation.exists) {
|
|
149
|
+
if (existsSync(participant.path)) unlinkSync(participant.path);
|
|
150
|
+
} else {
|
|
151
|
+
const content = readFileSync(generation.artifact, 'utf8');
|
|
152
|
+
if (contentHash(content) !== generation.hash) throw new MutationConflictError(`Transaction recovery artifact hash mismatch: ${generation.artifact}.`);
|
|
153
|
+
if (existsSync(participant.path)) {
|
|
154
|
+
const snapshot = snapshotFile(participant.path);
|
|
155
|
+
replaceSnapshot(snapshot, content, { repoRoot, locked: true });
|
|
156
|
+
} else {
|
|
157
|
+
createFileExclusive(participant.path, content, { repoRoot, mode: generation.mode, locked: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
fsyncDirectory(path.dirname(participant.path), {}, 'transaction-recovery');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cleanupTransactionDirectory(directory, manifest, options) {
|
|
164
|
+
const artifacts = new Set([
|
|
165
|
+
...manifest.participants.flatMap(item => [item.old.artifact, item.new.artifact]),
|
|
166
|
+
...manifest.recoveryArtifacts,
|
|
167
|
+
].filter(Boolean));
|
|
168
|
+
for (const artifact of artifacts) {
|
|
169
|
+
if (!existsSync(artifact)) continue;
|
|
170
|
+
validateTransactionArtifact(artifact, directory, 'cleanup artifact');
|
|
171
|
+
unlinkSync(artifact);
|
|
172
|
+
fsyncDirectory(directory, options, 'transaction-cleanup-artifact-delete');
|
|
173
|
+
}
|
|
174
|
+
const manifestPath = path.join(directory, 'manifest.json');
|
|
175
|
+
if (existsSync(manifestPath)) {
|
|
176
|
+
unlinkSync(manifestPath);
|
|
177
|
+
fsyncDirectory(directory, options, 'transaction-cleanup-manifest-delete');
|
|
178
|
+
}
|
|
179
|
+
const leftovers = readdirSync(directory);
|
|
180
|
+
if (leftovers.length) throw new Error(`Unexpected transaction evidence remains: ${leftovers.join(', ')}`);
|
|
181
|
+
rmdirSync(directory);
|
|
182
|
+
fsyncDirectory(path.dirname(directory), options, 'transaction-cleanup-directory-delete');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function transactionRepairMessage(manifestPath, manifest, reason) {
|
|
186
|
+
const artifacts = [
|
|
187
|
+
...manifest.participants.flatMap(item => [item.old.artifact, item.new.artifact]),
|
|
188
|
+
manifest.gitIndex?.prepared?.path,
|
|
189
|
+
manifest.gitIndex?.prepared?.tempPath,
|
|
190
|
+
...(manifest.gitIndex?.retainedPaths ?? []),
|
|
191
|
+
].filter(Boolean);
|
|
192
|
+
return `${reason}\nTransaction recovery refused to guess. Manifest: ${manifestPath}\nInspect the canonical files and recovery artifacts, then restore one complete generation and remove the manifest:\n${artifacts.map(item => ` ${item}`).join('\n') || ' (no content artifacts)'}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertString(value, label, { nullable = false } = {}) {
|
|
196
|
+
if ((nullable && value === null) || typeof value === 'string') return;
|
|
197
|
+
throw new MutationConflictError(`Invalid transaction manifest ${label}.`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function validateGitSnapshot(snapshot, label) {
|
|
201
|
+
if (snapshot === null) return;
|
|
202
|
+
if (!snapshot || typeof snapshot.exists !== 'boolean' || !Number.isInteger(snapshot.size) || typeof snapshot.indexPath !== 'string' || !path.isAbsolute(snapshot.indexPath)) {
|
|
203
|
+
throw new MutationConflictError(`Invalid transaction manifest ${label}.`);
|
|
204
|
+
}
|
|
205
|
+
if (!snapshot.exists) {
|
|
206
|
+
if (snapshot.hash !== null || snapshot.content !== null || snapshot.size !== 0 || snapshot.mode !== null) throw new MutationConflictError(`Invalid absent Git index generation ${label}.`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (typeof snapshot.hash !== 'string' || !/^[a-f\d]{64}$/.test(snapshot.hash) || typeof snapshot.content !== 'string'
|
|
210
|
+
|| !Number.isInteger(snapshot.mode) || snapshot.mode < 0 || snapshot.mode > 0o7777) throw new MutationConflictError(`Invalid Git index generation ${label}.`);
|
|
211
|
+
const bytes = Buffer.from(snapshot.content, 'base64');
|
|
212
|
+
if (bytes.length !== snapshot.size || contentHash(bytes) !== snapshot.hash) throw new MutationConflictError(`Git index generation ${label} hash mismatch.`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validatePreparedGitIndex(prepared, label, directory) {
|
|
216
|
+
if (prepared === null) return;
|
|
217
|
+
if (!prepared || typeof prepared.path !== 'string' || !path.isAbsolute(prepared.path)
|
|
218
|
+
|| !Number.isInteger(prepared.dev) || !Number.isInteger(prepared.ino) || !Number.isInteger(prepared.mode) || prepared.mode < 0 || prepared.mode > 0o7777
|
|
219
|
+
|| !Number.isInteger(prepared.size) || typeof prepared.hash !== 'string' || !/^[a-f\d]{64}$/.test(prepared.hash)
|
|
220
|
+
|| !['preparing', 'prepared'].includes(prepared.state) || typeof prepared.tempPath !== 'string' || !path.isAbsolute(prepared.tempPath)) {
|
|
221
|
+
throw new MutationConflictError(`Invalid transaction manifest ${label}.`);
|
|
222
|
+
}
|
|
223
|
+
validateGitSnapshot(prepared.generation, `${label}.generation`);
|
|
224
|
+
validateTransactionArtifact(prepared.path, directory, label);
|
|
225
|
+
if (path.dirname(prepared.tempPath) !== path.dirname(prepared.generation.indexPath) || !path.basename(prepared.tempPath).startsWith('.dotmd-index-')) {
|
|
226
|
+
throw new MutationConflictError(`Unsafe prepared Git index path in ${label}.`);
|
|
227
|
+
}
|
|
228
|
+
if ((prepared.generation.exists && (prepared.hash !== prepared.generation.hash || prepared.size !== prepared.generation.size))
|
|
229
|
+
|| (!prepared.generation.exists && prepared.size !== 0)) throw new MutationConflictError(`Prepared Git artifact does not match ${label}.generation.`);
|
|
230
|
+
if (prepared.work !== null) {
|
|
231
|
+
if (!prepared.work || prepared.work.path !== prepared.tempPath || !Number.isInteger(prepared.work.dev) || !Number.isInteger(prepared.work.ino)
|
|
232
|
+
|| !Number.isInteger(prepared.work.mode) || !Number.isInteger(prepared.work.size) || typeof prepared.work.hash !== 'string') {
|
|
233
|
+
throw new MutationConflictError(`Invalid prepared Git working inode in ${label}.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function validateGeneration(generation, directory, label) {
|
|
239
|
+
if (!generation || typeof generation.exists !== 'boolean') throw new MutationConflictError(`Invalid transaction manifest ${label}.`);
|
|
240
|
+
if (!generation.exists) {
|
|
241
|
+
if (generation.artifact !== null) throw new MutationConflictError(`Invalid absent generation artifact for ${label}.`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (typeof generation.hash !== 'string' || !/^[a-f\d]{64}$/.test(generation.hash)
|
|
245
|
+
|| !Number.isInteger(generation.mode) || typeof generation.artifact !== 'string') {
|
|
246
|
+
throw new MutationConflictError(`Invalid transaction generation for ${label}.`);
|
|
247
|
+
}
|
|
248
|
+
validateTransactionArtifact(generation.artifact, directory, label);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function validateTransactionArtifact(candidate, directory, label) {
|
|
252
|
+
const absolute = path.resolve(candidate);
|
|
253
|
+
if (!contained(directory, absolute) || absolute === directory) throw new MutationConflictError(`Transaction ${label} escapes its transaction directory: ${candidate}`);
|
|
254
|
+
let canonicalDirectory;
|
|
255
|
+
try {
|
|
256
|
+
canonicalDirectory = realpathSync(directory);
|
|
257
|
+
if (existsSync(absolute)) {
|
|
258
|
+
if (lstatSync(absolute).isSymbolicLink()) throw new Error('symlink artifact');
|
|
259
|
+
if (!contained(canonicalDirectory, realpathSync(absolute))) throw new Error('artifact resolves outside transaction directory');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
let ancestor = path.dirname(absolute);
|
|
263
|
+
while (!existsSync(ancestor)) ancestor = path.dirname(ancestor);
|
|
264
|
+
if (!contained(canonicalDirectory, realpathSync(ancestor))) throw new Error('artifact parent resolves outside transaction directory');
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw new MutationConflictError(`Transaction ${label} is missing or unsafe: ${candidate} (${err.message})`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function validateParticipantPath(participant, repoRoot, options, label) {
|
|
271
|
+
if (!participant || typeof participant.path !== 'string' || !path.isAbsolute(participant.path)
|
|
272
|
+
|| !['managed', 'generated'].includes(participant.policy)
|
|
273
|
+
|| !['source', 'target', 'referrer', 'ownership', 'creation', 'deletion'].includes(participant.label)) {
|
|
274
|
+
throw new MutationConflictError(`Invalid transaction participant ${label}.`);
|
|
275
|
+
}
|
|
276
|
+
const txRoot = transactionRoot(repoRoot, options);
|
|
277
|
+
const lockRoot = safeGeneratedPath(path.join(repoRoot, '.dotmd', 'locks'), repoRoot, options, 'Lock root');
|
|
278
|
+
if (contained(txRoot, participant.path) || contained(lockRoot, participant.path)) {
|
|
279
|
+
throw new MutationConflictError(`Transaction participant targets transaction/lock state: ${participant.path}`);
|
|
280
|
+
}
|
|
281
|
+
if (participant.policy === 'managed' && options.config) {
|
|
282
|
+
if (existsSync(participant.path)) authorizeManagedSource(participant.path, options.config, { kind: 'Transaction participant' });
|
|
283
|
+
else authorizeManagedDestination(participant.path, options.config, { kind: 'Transaction participant' });
|
|
284
|
+
} else {
|
|
285
|
+
const authorized = safeGeneratedPath(participant.path, repoRoot, options, 'Transaction generated participant');
|
|
286
|
+
if (options.config) {
|
|
287
|
+
const ownershipRoot = path.join(path.resolve(repoRoot), '.dotmd', 'ownership');
|
|
288
|
+
if (!contained(ownershipRoot, authorized)) throw new MutationConflictError(`Generated transaction participant is outside session ownership state: ${participant.path}`);
|
|
289
|
+
if (participant.label !== 'ownership') throw new MutationConflictError(`Session-generated participant must be classified as ownership: ${participant.path}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateManifest(manifest, manifestPath, directory, repoRoot, options) {
|
|
295
|
+
if (!manifest || manifest.schema !== TRANSACTION_SCHEMA || typeof manifest.id !== 'string'
|
|
296
|
+
|| !TRANSACTION_OPERATIONS.has(manifest.operation) || !TRANSACTION_PHASES.has(manifest.phase)
|
|
297
|
+
|| !TRANSACTION_RESULTS.has(manifest.result) || !TRANSACTION_STATUSES.has(manifest.status) || !Array.isArray(manifest.participants)
|
|
298
|
+
|| !Array.isArray(manifest.recoveryArtifacts) || !Array.isArray(manifest.createdDirectories) || typeof manifest.createdAt !== 'string'
|
|
299
|
+
|| typeof manifest.directoryToken !== 'string') {
|
|
300
|
+
throw new MutationConflictError(`Invalid transaction manifest schema at ${manifestPath}. Preserve it for manual repair.`);
|
|
301
|
+
}
|
|
302
|
+
assertString(manifest.sessionId, 'sessionId', { nullable: true });
|
|
303
|
+
if (!manifest.owner || !Number.isInteger(manifest.owner.pid) || typeof manifest.owner.hostname !== 'string'
|
|
304
|
+
|| typeof manifest.owner.processStartedAt !== 'string' || (manifest.owner.processStartIdentity !== null && typeof manifest.owner.processStartIdentity !== 'string')) {
|
|
305
|
+
throw new MutationConflictError(`Invalid transaction manifest owner at ${manifestPath}.`);
|
|
306
|
+
}
|
|
307
|
+
if (manifest.directory !== directory) throw new MutationConflictError(`Transaction manifest directory binding mismatch at ${manifestPath}.`);
|
|
308
|
+
for (let index = 0; index < manifest.participants.length; index++) {
|
|
309
|
+
const participant = manifest.participants[index];
|
|
310
|
+
validateParticipantPath(participant, repoRoot, options, `participants[${index}]`);
|
|
311
|
+
validateGeneration(participant.old, directory, `participants[${index}].old`);
|
|
312
|
+
validateGeneration(participant.new, directory, `participants[${index}].new`);
|
|
313
|
+
const shape = `${participant.old.exists ? 1 : 0}${participant.new.exists ? 1 : 0}`;
|
|
314
|
+
const expectedShapes = {
|
|
315
|
+
source: ['10'], target: ['01'], referrer: ['11'], creation: ['01'], deletion: ['10'], ownership: ['01', '10'],
|
|
316
|
+
}[participant.label];
|
|
317
|
+
if (!expectedShapes.includes(shape)) throw new MutationConflictError(`Invalid generation shape for transaction ${participant.label} participant.`);
|
|
318
|
+
if (!Array.isArray(participant.transientHashes) || !participant.transientHashes.every(hash => typeof hash === 'string' && /^[a-f\d]{64}$/.test(hash))) {
|
|
319
|
+
throw new MutationConflictError(`Invalid transaction transient generations at ${manifestPath}.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (manifest.participants.filter(item => item.label === 'source').length !== 1
|
|
323
|
+
|| manifest.participants.filter(item => item.label === 'target').length !== 1) {
|
|
324
|
+
throw new MutationConflictError(`Transaction manifest must contain exactly one source and target at ${manifestPath}.`);
|
|
325
|
+
}
|
|
326
|
+
for (const artifact of manifest.recoveryArtifacts) validateTransactionArtifact(artifact, directory, 'recovery artifact');
|
|
327
|
+
for (const createdDirectory of manifest.createdDirectories) {
|
|
328
|
+
if (!createdDirectory || typeof createdDirectory.path !== 'string' || typeof createdDirectory.marker !== 'string'
|
|
329
|
+
|| typeof createdDirectory.participantPath !== 'string' || typeof createdDirectory.token !== 'string'
|
|
330
|
+
|| !path.isAbsolute(createdDirectory.path) || !path.isAbsolute(createdDirectory.marker) || !path.isAbsolute(createdDirectory.participantPath)) {
|
|
331
|
+
throw new MutationConflictError(`Invalid created directory in ${manifestPath}.`);
|
|
332
|
+
}
|
|
333
|
+
const participant = manifest.participants.find(item => item.path === createdDirectory.participantPath && !item.old.exists && item.new.exists);
|
|
334
|
+
if (!participant || !contained(createdDirectory.path, participant.path) || createdDirectory.path === participant.path) {
|
|
335
|
+
throw new MutationConflictError(`Transaction-created directory is not bound to a destination participant: ${createdDirectory.path}`);
|
|
336
|
+
}
|
|
337
|
+
const expectedMarker = path.join(createdDirectory.path, `.dotmd-transaction-${manifest.id}`);
|
|
338
|
+
if (createdDirectory.marker !== expectedMarker || createdDirectory.token !== manifest.directoryToken) {
|
|
339
|
+
throw new MutationConflictError(`Transaction-created directory marker binding mismatch: ${createdDirectory.path}`);
|
|
340
|
+
}
|
|
341
|
+
if (!['intended', 'created', 'removing', 'removed'].includes(createdDirectory.markerState)) throw new MutationConflictError(`Invalid transaction directory marker state: ${createdDirectory.path}`);
|
|
342
|
+
if (createdDirectory.identity !== null && (!createdDirectory.identity || !Number.isInteger(createdDirectory.identity.dev) || !Number.isInteger(createdDirectory.identity.ino))) {
|
|
343
|
+
throw new MutationConflictError(`Invalid transaction-created directory identity: ${createdDirectory.path}`);
|
|
344
|
+
}
|
|
345
|
+
if (createdDirectory.markerState !== 'intended' && createdDirectory.identity === null) throw new MutationConflictError(`Transaction-created directory state lacks an identity: ${createdDirectory.path}`);
|
|
346
|
+
if (participant.policy === 'managed' && options.config) authorizeManagedDestination(path.join(createdDirectory.path, '.dotmd-directory-check.md'), options.config, { kind: 'Transaction-created directory' });
|
|
347
|
+
else safeGeneratedPath(createdDirectory.path, repoRoot, options, 'Transaction-created directory');
|
|
348
|
+
}
|
|
349
|
+
const createdPaths = new Set(manifest.createdDirectories.map(item => item.path));
|
|
350
|
+
if (createdPaths.size !== manifest.createdDirectories.length) throw new MutationConflictError(`Duplicate transaction-created directory in ${manifestPath}.`);
|
|
351
|
+
for (const participantPath of new Set(manifest.createdDirectories.map(item => item.participantPath))) {
|
|
352
|
+
const chain = manifest.createdDirectories
|
|
353
|
+
.filter(item => item.participantPath === participantPath)
|
|
354
|
+
.sort((left, right) => left.path.split(path.sep).length - right.path.split(path.sep).length);
|
|
355
|
+
if (chain.at(-1)?.path !== path.dirname(participantPath)) throw new MutationConflictError(`Transaction-created directory chain does not end at destination parent: ${participantPath}`);
|
|
356
|
+
for (let index = 1; index < chain.length; index++) {
|
|
357
|
+
if (path.dirname(chain[index].path) !== chain[index - 1].path) throw new MutationConflictError(`Transaction-created directory chain is not contiguous: ${participantPath}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (manifest.gitMove !== null) {
|
|
361
|
+
if (!manifest.gitMove || typeof manifest.gitMove.source !== 'string' || typeof manifest.gitMove.target !== 'string') throw new MutationConflictError(`Invalid transaction Git move at ${manifestPath}.`);
|
|
362
|
+
for (const gitPath of [manifest.gitMove.source, manifest.gitMove.target]) {
|
|
363
|
+
if (path.isAbsolute(gitPath) || gitPath.split(/[\\/]/).includes('..')) throw new MutationConflictError(`Unsafe Git move path in transaction manifest: ${gitPath}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
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}.`);
|
|
367
|
+
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}.`);
|
|
368
|
+
validateGitSnapshot(manifest.gitIndex.before, 'gitIndex.before');
|
|
369
|
+
validatePreparedGitIndex(manifest.gitIndex.prepared, 'gitIndex.prepared', directory);
|
|
370
|
+
validateGitSnapshot(manifest.gitIndex.ownedAfter, 'gitIndex.ownedAfter');
|
|
371
|
+
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.`);
|
|
372
|
+
return manifest;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function recoverAbandonedTransactions(repoRoot, options = {}) {
|
|
376
|
+
const root = transactionRoot(repoRoot, options);
|
|
377
|
+
if (!existsSync(root)) return [];
|
|
378
|
+
const recovered = [];
|
|
379
|
+
for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
380
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new MutationConflictError(`Unsafe entry in transaction root: ${path.join(root, entry.name)}`);
|
|
381
|
+
const directory = path.join(root, entry.name);
|
|
382
|
+
const manifestPath = path.join(directory, 'manifest.json');
|
|
383
|
+
let manifest;
|
|
384
|
+
try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); }
|
|
385
|
+
catch (err) {
|
|
386
|
+
if (err?.code === 'ENOENT' && readdirSync(directory).length === 0) {
|
|
387
|
+
rmdirSync(directory);
|
|
388
|
+
fsyncDirectory(root, options, 'transaction-resume-empty-cleanup');
|
|
389
|
+
recovered.push({ id: entry.name, result: 'cleanup-completed' });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
throw new MutationConflictError(`Unreadable transaction manifest: ${manifestPath}. Preserve its artifacts and repair it manually.`);
|
|
393
|
+
}
|
|
394
|
+
validateManifest(manifest, manifestPath, directory, repoRoot, options);
|
|
395
|
+
if (manifest.status === 'complete') {
|
|
396
|
+
const retainedDirectories = manifest.createdDirectories.filter(item => existsSync(item.path)).map(item => item.path);
|
|
397
|
+
const retainedGitPaths = manifest.gitIndex.retainedPaths.filter(item => existsSync(item));
|
|
398
|
+
const completedResult = manifest.result === 'committed' ? 'rolled-forward' : manifest.result;
|
|
399
|
+
if (retainedDirectories.length === 0 && retainedGitPaths.length === 0) {
|
|
400
|
+
cleanupTransactionDirectory(directory, manifest, options);
|
|
401
|
+
recovered.push({ id: manifest.id, result: completedResult, retainedDirectories: [], retainedGitPaths: [] });
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
recovered.push({
|
|
405
|
+
id: manifest.id,
|
|
406
|
+
result: completedResult,
|
|
407
|
+
retainedDirectories,
|
|
408
|
+
retainedGitPaths,
|
|
409
|
+
});
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (manifest.status === 'failed-manual') throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Transaction is marked failed/manual and will not be retried automatically.'));
|
|
413
|
+
const liveness = processOwnerLiveness(manifest.owner);
|
|
414
|
+
if (liveness === 'live') continue;
|
|
415
|
+
if (liveness !== 'dead') throw new MutationConflictError(`Cannot prove transaction owner is dead for ${manifestPath}; refusing recovery.`);
|
|
416
|
+
if (manifest.gitIndex.before) {
|
|
417
|
+
const selected = captureGitIndexGeneration(repoRoot);
|
|
418
|
+
if (selected.indexPath !== manifest.gitIndex.before.indexPath) throw new MutationConflictError(`Recovery environment selects a different Git index than ${manifestPath}; refusing recovery.`);
|
|
419
|
+
try {
|
|
420
|
+
const reclaimed = reclaimPreparedGitIndex(manifest.gitIndex, repoRoot, options);
|
|
421
|
+
manifest.gitIndex.retainedPaths = [...new Set([...manifest.gitIndex.retainedPaths, ...reclaimed.retainedPaths])];
|
|
422
|
+
if (reclaimed.retainedPaths.length) {
|
|
423
|
+
manifest.phase = 'failed-manual';
|
|
424
|
+
manifest.result = 'failed-manual';
|
|
425
|
+
manifest.status = 'failed-manual';
|
|
426
|
+
durableJson(manifestPath, manifest, options);
|
|
427
|
+
const retainedError = new MutationConflictError(transactionRepairMessage(manifestPath, manifest,
|
|
428
|
+
`Git preparation left unverified work that was preserved and requires manual recovery: ${reclaimed.retainedPaths.join(', ')}`));
|
|
429
|
+
retainedError.retainedGitPaths = reclaimed.retainedPaths;
|
|
430
|
+
throw retainedError;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
if (err?.retainedGitPaths) throw err;
|
|
435
|
+
throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, `Prepared Git index recovery failed: ${err.message}`));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const states = manifest.participants.map(participantState);
|
|
439
|
+
if (states.includes('unknown')) {
|
|
440
|
+
throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations are ambiguous.'));
|
|
441
|
+
}
|
|
442
|
+
const rollForward = states.every(state => state === 'new');
|
|
443
|
+
try {
|
|
444
|
+
withPathLocks(manifest.participants.map(item => item.path), { repoRoot, ...options }, () => {
|
|
445
|
+
const lockedStates = manifest.participants.map(participantState);
|
|
446
|
+
if (lockedStates.includes('unknown')) {
|
|
447
|
+
throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations changed while recovery acquired locks.'));
|
|
448
|
+
}
|
|
449
|
+
const lockedRollForward = lockedStates.every(state => state === 'new');
|
|
450
|
+
if (lockedRollForward !== rollForward) throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations changed during recovery classification.'));
|
|
451
|
+
if (!lockedRollForward) {
|
|
452
|
+
for (let i = manifest.participants.length - 1; i >= 0; i--) {
|
|
453
|
+
publishParticipantGeneration(manifest.participants[i], manifest.participants[i].old, repoRoot);
|
|
454
|
+
}
|
|
455
|
+
if (manifest.gitIndex.before) {
|
|
456
|
+
if (manifest.gitIndex.ownedAfter) {
|
|
457
|
+
const currentIndex = captureGitIndexGeneration(repoRoot);
|
|
458
|
+
if (!sameGitIndexGeneration(currentIndex, manifest.gitIndex.before)) restoreGitIndexCas(manifest.gitIndex.before, manifest.gitIndex.ownedAfter, repoRoot, {
|
|
459
|
+
artifactPath: path.join(directory, 'git-index-restore'),
|
|
460
|
+
testHooks: {
|
|
461
|
+
...options.testHooks,
|
|
462
|
+
afterGitRestoreArtifact: info => {
|
|
463
|
+
manifest.gitIndex.prepared = info.prepared;
|
|
464
|
+
durableJson(manifestPath, manifest, options);
|
|
465
|
+
options.testHooks?.afterGitRestoreArtifact?.(info);
|
|
466
|
+
},
|
|
467
|
+
afterGitRestorePrepared: info => {
|
|
468
|
+
manifest.gitIndex.prepared = info.prepared;
|
|
469
|
+
durableJson(manifestPath, manifest, options);
|
|
470
|
+
options.testHooks?.afterGitRestorePrepared?.(info);
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
} else if (manifest.gitMove) {
|
|
477
|
+
const before = manifest.gitIndex.before;
|
|
478
|
+
const current = before ? captureGitIndexGeneration(repoRoot) : null;
|
|
479
|
+
if (manifest.gitIndex.ownedAfter) {
|
|
480
|
+
if (!sameGitIndexGeneration(current, manifest.gitIndex.ownedAfter)) throw new Error('Git index no longer matches the transaction-owned committed generation; current staging was preserved.');
|
|
481
|
+
} else if (manifest.gitIndex.prepared?.state === 'prepared' && sameGitIndexGeneration(current, manifest.gitIndex.prepared.generation)) {
|
|
482
|
+
manifest.gitIndex.ownedAfter = manifest.gitIndex.prepared.generation;
|
|
483
|
+
} else {
|
|
484
|
+
if (before && !sameGitIndexGeneration(current, before)) throw new Error('Git index changed before recovery could finalize the move; current staging was preserved.');
|
|
485
|
+
manifest.gitIndex.ownedAfter = stageMovePathsCas(manifest.gitMove.source, manifest.gitMove.target, repoRoot, before, {
|
|
486
|
+
artifactPath: path.join(directory, 'git-index-prepared'),
|
|
487
|
+
testHooks: {
|
|
488
|
+
...options.testHooks,
|
|
489
|
+
afterGitIndexArtifact: info => {
|
|
490
|
+
manifest.gitIndex.prepared = info.prepared;
|
|
491
|
+
durableJson(manifestPath, manifest, options);
|
|
492
|
+
options.testHooks?.afterGitIndexArtifact?.(info);
|
|
493
|
+
},
|
|
494
|
+
afterGitIndexWorkSeed: info => {
|
|
495
|
+
manifest.gitIndex.prepared = info.prepared;
|
|
496
|
+
durableJson(manifestPath, manifest, options);
|
|
497
|
+
options.testHooks?.afterGitIndexWorkSeed?.(info);
|
|
498
|
+
},
|
|
499
|
+
afterGitIndexPrepareStep: info => {
|
|
500
|
+
manifest.gitIndex.prepared = info.prepared;
|
|
501
|
+
durableJson(manifestPath, manifest, options);
|
|
502
|
+
options.testHooks?.afterGitIndexPrepareStep?.(info);
|
|
503
|
+
},
|
|
504
|
+
afterGitIndexSubprocessBeforeCheckpoint: info => {
|
|
505
|
+
options.testHooks?.afterTransactionPhase?.('git-index-post-subprocess', { manifestPath, manifest, ...info });
|
|
506
|
+
options.testHooks?.afterGitIndexSubprocessBeforeCheckpoint?.(info);
|
|
507
|
+
},
|
|
508
|
+
afterGitIndexPrepared: info => {
|
|
509
|
+
manifest.gitIndex.prepared = info.prepared;
|
|
510
|
+
durableJson(manifestPath, manifest, options);
|
|
511
|
+
options.testHooks?.afterGitIndexPrepared?.(info);
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
manifest.phase = 'recovered';
|
|
519
|
+
manifest.result = rollForward ? 'rolled-forward' : 'rolled-back';
|
|
520
|
+
manifest.status = 'complete';
|
|
521
|
+
durableJson(manifestPath, manifest, options);
|
|
522
|
+
options.testHooks?.afterTransactionPhase?.('recovery', { manifestPath, manifest });
|
|
523
|
+
const retainedDirectories = manifest.createdDirectories.filter(item => existsSync(item.path)).map(item => item.path);
|
|
524
|
+
const retainedGitPaths = manifest.gitIndex.retainedPaths.filter(item => existsSync(item));
|
|
525
|
+
if (retainedDirectories.length === 0 && retainedGitPaths.length === 0) {
|
|
526
|
+
setMoveManifestPhase({ manifest, manifestPath }, 'cleanup', options);
|
|
527
|
+
cleanupTransactionDirectory(directory, manifest, options);
|
|
528
|
+
}
|
|
529
|
+
recovered.push({
|
|
530
|
+
id: manifest.id,
|
|
531
|
+
result: manifest.result,
|
|
532
|
+
retainedDirectories,
|
|
533
|
+
retainedGitPaths,
|
|
534
|
+
});
|
|
535
|
+
} catch (err) {
|
|
536
|
+
try {
|
|
537
|
+
manifest.phase = 'failed-manual';
|
|
538
|
+
manifest.result = 'failed-manual';
|
|
539
|
+
manifest.status = 'failed-manual';
|
|
540
|
+
durableJson(manifestPath, manifest, options);
|
|
541
|
+
} catch { /* retain original manifest and every artifact */ }
|
|
542
|
+
throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, `Recovery failed and evidence was retained: ${err.message}`));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return recovered;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function ensureDirectoryDurable(directory, options, phase) {
|
|
549
|
+
if (existsSync(directory)) return false;
|
|
550
|
+
const parent = path.dirname(directory);
|
|
551
|
+
if (!existsSync(parent)) ensureDirectoryDurable(parent, options, phase);
|
|
552
|
+
try { mkdirSync(directory); }
|
|
553
|
+
catch (err) { if (err?.code === 'EEXIST') return false; throw err; }
|
|
554
|
+
fsyncDirectory(parent, options, phase);
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function beginMoveManifest({ repoRoot, transactionId, operation, sessionId, source, targetPath, targetContent, updates, creations, deletions, gitIndex, tracked }, options) {
|
|
559
|
+
const root = transactionRoot(repoRoot, options);
|
|
560
|
+
ensureDirectoryDurable(root, options, 'transaction-root-create');
|
|
561
|
+
const directory = path.join(root, transactionId);
|
|
562
|
+
mkdirSync(directory);
|
|
563
|
+
try { fsyncDirectory(root, options, 'transaction-directory-create'); }
|
|
564
|
+
catch (err) {
|
|
565
|
+
try { rmdirSync(directory); fsyncDirectory(root, options, 'transaction-directory-create-rollback'); } catch { /* preserve durability error */ }
|
|
566
|
+
throw err;
|
|
567
|
+
}
|
|
568
|
+
const participants = [];
|
|
569
|
+
const add = (filePath, oldGeneration, newGeneration, label) => {
|
|
570
|
+
const index = participants.length;
|
|
571
|
+
const old = oldGeneration.exists ? {
|
|
572
|
+
exists: true, hash: oldGeneration.hash, mode: oldGeneration.mode,
|
|
573
|
+
artifact: path.join(directory, `${index}-old`),
|
|
574
|
+
} : { exists: false, artifact: null };
|
|
575
|
+
const next = newGeneration.exists ? {
|
|
576
|
+
exists: true, hash: contentHash(newGeneration.content), mode: newGeneration.mode,
|
|
577
|
+
artifact: path.join(directory, `${index}-new`),
|
|
578
|
+
} : { exists: false, artifact: null };
|
|
579
|
+
const policy = String(filePath).endsWith('.md') ? 'managed' : 'generated';
|
|
580
|
+
participants.push({ path: path.resolve(filePath), policy, label, old, new: next, transientHashes: [], _oldContent: oldGeneration.content, _newContent: newGeneration.content });
|
|
581
|
+
};
|
|
582
|
+
add(source.path, { exists: true, content: source.content, hash: source.hash, mode: source.identity.mode }, { exists: false }, 'source');
|
|
583
|
+
add(targetPath, { exists: false }, { exists: true, content: targetContent, mode: source.identity.mode }, 'target');
|
|
584
|
+
for (const item of updates) add(item.path,
|
|
585
|
+
{ exists: true, content: item.snapshot.content, hash: item.snapshot.hash, mode: item.snapshot.identity.mode },
|
|
586
|
+
{ exists: true, content: item.content, mode: item.snapshot.identity.mode }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'referrer');
|
|
587
|
+
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');
|
|
588
|
+
for (const item of deletions) add(item.path,
|
|
589
|
+
{ 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');
|
|
590
|
+
const backup = path.join(directory, 'source-backup');
|
|
591
|
+
const stagedPath = path.join(directory, 'target-staged');
|
|
592
|
+
const gitPreparedArtifact = path.join(directory, 'git-index-prepared');
|
|
593
|
+
const gitRestoreArtifact = path.join(directory, 'git-index-restore');
|
|
594
|
+
const manifest = {
|
|
595
|
+
schema: TRANSACTION_SCHEMA,
|
|
596
|
+
id: transactionId,
|
|
597
|
+
operation: operation ?? 'move',
|
|
598
|
+
sessionId: sessionId ?? null,
|
|
599
|
+
owner: currentProcessOwner(),
|
|
600
|
+
createdAt: new Date().toISOString(),
|
|
601
|
+
phase: 'manifest',
|
|
602
|
+
status: 'active',
|
|
603
|
+
result: null,
|
|
604
|
+
directory,
|
|
605
|
+
directoryToken: randomUUID(),
|
|
606
|
+
participants: participants.map(({ _oldContent, _newContent, ...participant }) => participant),
|
|
607
|
+
gitIndex: { before: gitIndex ?? null, prepared: null, ownedAfter: null, retainedPaths: [] },
|
|
608
|
+
gitMove: tracked ? { source: path.relative(repoRoot, source.path).split(path.sep).join('/'), target: path.relative(repoRoot, targetPath).split(path.sep).join('/') } : null,
|
|
609
|
+
recoveryArtifacts: [backup, stagedPath, ...(tracked ? [gitPreparedArtifact, gitRestoreArtifact] : [])],
|
|
610
|
+
createdDirectories: [],
|
|
611
|
+
};
|
|
612
|
+
const manifestPath = path.join(directory, 'manifest.json');
|
|
613
|
+
try { durableJson(manifestPath, manifest, options); }
|
|
614
|
+
catch (err) {
|
|
615
|
+
try { cleanupTransactionDirectory(directory, manifest, options); }
|
|
616
|
+
catch (cleanupError) { err.message += `\nCould not clean failed manifest publication: ${cleanupError.message}`; }
|
|
617
|
+
throw err;
|
|
618
|
+
}
|
|
619
|
+
return { directory, manifestPath, manifest, participants, backup, stagedPath, gitPreparedArtifact, gitRestoreArtifact, createdDirectories: [], createdDirectoryOwnership: new Set() };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function stageMoveManifest(transaction, targetContent, sourceMode, options) {
|
|
623
|
+
for (let index = 0; index < transaction.participants.length; index++) {
|
|
624
|
+
const participant = transaction.participants[index];
|
|
625
|
+
if (participant.old.exists) writeTransactionArtifact(transaction.directory, `${index}-old`, participant._oldContent, participant.old.mode, options);
|
|
626
|
+
if (participant.new.exists) writeTransactionArtifact(transaction.directory, `${index}-new`, participant._newContent, participant.new.mode, options);
|
|
627
|
+
}
|
|
628
|
+
writeTransactionArtifact(transaction.directory, 'target-staged', targetContent, sourceMode, options);
|
|
629
|
+
setMoveManifestPhase(transaction, 'staging', options);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function ensureTransactionDirectory(directory, transaction, options, participantPath) {
|
|
633
|
+
if (existsSync(directory)) return;
|
|
634
|
+
const missing = [];
|
|
635
|
+
let cursor = directory;
|
|
636
|
+
while (!existsSync(cursor)) { missing.unshift(cursor); cursor = path.dirname(cursor); }
|
|
637
|
+
for (const item of missing) {
|
|
638
|
+
const intent = {
|
|
639
|
+
path: item,
|
|
640
|
+
participantPath: path.resolve(participantPath),
|
|
641
|
+
marker: path.join(item, `.dotmd-transaction-${transaction.manifest.id}`),
|
|
642
|
+
token: transaction.manifest.directoryToken,
|
|
643
|
+
markerState: 'intended',
|
|
644
|
+
identity: null,
|
|
645
|
+
};
|
|
646
|
+
transaction.manifest.createdDirectories.push(intent);
|
|
647
|
+
transaction.createdDirectories.push(intent);
|
|
648
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
649
|
+
options.testHooks?.afterTransactionPhase?.('directory-intent', { directory: item, intent, manifestPath: transaction.manifestPath });
|
|
650
|
+
mkdirSync(item);
|
|
651
|
+
transaction.createdDirectoryOwnership.add(item);
|
|
652
|
+
fsyncDirectory(path.dirname(item), options, 'canonical-directory-create');
|
|
653
|
+
options.testHooks?.afterTransactionPhase?.('directory-create', { directory: item, intent, manifestPath: transaction.manifestPath });
|
|
654
|
+
const createdIdentity = lstatSync(item);
|
|
655
|
+
intent.identity = { dev: createdIdentity.dev, ino: createdIdentity.ino };
|
|
656
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
657
|
+
const fd = openSync(intent.marker, 'wx', 0o600);
|
|
658
|
+
try { writeFileSync(fd, `${intent.token}\n`); fsyncSync(fd); } finally { closeSync(fd); }
|
|
659
|
+
fsyncDirectory(item, options, 'canonical-directory-marker');
|
|
660
|
+
intent.markerState = 'created';
|
|
661
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
662
|
+
options.testHooks?.afterTransactionPhase?.('directory-marker', { directory: item, intent, manifestPath: transaction.manifestPath });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function cleanupCreatedDirectories(transaction, options) {
|
|
667
|
+
let complete = true;
|
|
668
|
+
const manifest = transaction.manifest ?? transaction;
|
|
669
|
+
for (const intent of [...transaction.createdDirectories].reverse()) {
|
|
670
|
+
try {
|
|
671
|
+
const directory = intent.path;
|
|
672
|
+
if (!(transaction.createdDirectoryOwnership instanceof Set) || !transaction.createdDirectoryOwnership.has(directory)) continue;
|
|
673
|
+
if (!existsSync(directory)) continue;
|
|
674
|
+
const currentIdentity = lstatSync(directory);
|
|
675
|
+
if (!currentIdentity.isDirectory() || !intent.identity || currentIdentity.dev !== intent.identity.dev || currentIdentity.ino !== intent.identity.ino) {
|
|
676
|
+
complete = false;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (!existsSync(intent.marker)) {
|
|
680
|
+
if (['removing', 'removed'].includes(intent.markerState)) {
|
|
681
|
+
if (readdirSync(directory).length === 0) {
|
|
682
|
+
rmdirSync(directory);
|
|
683
|
+
fsyncDirectory(path.dirname(directory), options, 'canonical-directory-rollback');
|
|
684
|
+
}
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
complete = false;
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (lstatSync(intent.marker).isSymbolicLink() || readFileSync(intent.marker, 'utf8') !== `${intent.token}\n`) {
|
|
691
|
+
complete = false;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
const entries = readdirSync(directory).filter(entry => path.join(directory, entry) !== intent.marker);
|
|
695
|
+
intent.markerState = 'removing';
|
|
696
|
+
if (transaction.manifestPath) durableJson(transaction.manifestPath, manifest, options);
|
|
697
|
+
unlinkSync(intent.marker);
|
|
698
|
+
fsyncDirectory(directory, options, 'canonical-directory-marker-delete');
|
|
699
|
+
if (entries.length === 0) {
|
|
700
|
+
rmdirSync(directory);
|
|
701
|
+
fsyncDirectory(path.dirname(directory), options, 'canonical-directory-rollback');
|
|
702
|
+
} else {
|
|
703
|
+
intent.markerState = 'removed';
|
|
704
|
+
if (transaction.manifestPath) durableJson(transaction.manifestPath, manifest, options);
|
|
705
|
+
}
|
|
706
|
+
} catch { complete = false; }
|
|
707
|
+
}
|
|
708
|
+
return complete;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function setMoveManifestPhase(transaction, phase, options, detail = null) {
|
|
712
|
+
transaction.manifest.phase = phase;
|
|
713
|
+
if (detail !== null) transaction.manifest.detail = detail;
|
|
714
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
715
|
+
options.testHooks?.afterTransactionPhase?.(phase, { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function companionPhase(item, fallback) {
|
|
719
|
+
return item.label === 'ownership' || item.path.includes(`${path.sep}.dotmd${path.sep}ownership${path.sep}`)
|
|
720
|
+
? 'ownership-publication'
|
|
721
|
+
: fallback;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function statIdentity(stat) {
|
|
725
|
+
return {
|
|
726
|
+
dev: stat.dev.toString(),
|
|
727
|
+
ino: stat.ino.toString(),
|
|
728
|
+
size: stat.size.toString(),
|
|
729
|
+
mtimeNs: stat.mtimeNs.toString(),
|
|
730
|
+
ctimeNs: stat.ctimeNs.toString(),
|
|
731
|
+
mode: Number(stat.mode & 0o7777n),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function sameIdentity(a, b) {
|
|
736
|
+
return a.dev === b.dev && a.ino === b.ino && a.size === b.size
|
|
737
|
+
&& a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function sameGenerationIdentity(a, b) {
|
|
741
|
+
return a.dev === b.dev && a.ino === b.ino && a.size === b.size;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function snapshotFile(filePath) {
|
|
745
|
+
const resolved = path.resolve(filePath);
|
|
746
|
+
const before = statSync(resolved, { bigint: true });
|
|
747
|
+
if (!before.isFile()) throw new Error(`Mutation source is not a file: ${resolved}`);
|
|
748
|
+
const content = readFileSync(resolved, 'utf8');
|
|
749
|
+
const after = statSync(resolved, { bigint: true });
|
|
750
|
+
const identity = statIdentity(after);
|
|
751
|
+
if (!sameIdentity(statIdentity(before), identity) || BigInt(Buffer.byteLength(content)) !== after.size) {
|
|
752
|
+
throw new MutationConflictError(`File changed while it was being read: ${resolved}`);
|
|
753
|
+
}
|
|
754
|
+
return { path: resolved, content, hash: contentHash(content), identity };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export function assertSnapshotCurrent(snapshot) {
|
|
758
|
+
let current;
|
|
759
|
+
try { current = snapshotFile(snapshot.path); }
|
|
760
|
+
catch (err) {
|
|
761
|
+
if (err?.code === 'ENOENT') throw new MutationConflictError(`File disappeared after it was read: ${snapshot.path}`);
|
|
762
|
+
throw err;
|
|
763
|
+
}
|
|
764
|
+
const identityMatches = snapshot.identityKind === 'inode-content'
|
|
765
|
+
? sameGenerationIdentity(snapshot.identity, current.identity)
|
|
766
|
+
: sameIdentity(snapshot.identity, current.identity);
|
|
767
|
+
if (!identityMatches || snapshot.hash !== current.hash) {
|
|
768
|
+
throw new MutationConflictError(`File changed after it was read; refusing to overwrite newer content: ${snapshot.path}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function canonicalPath(filePath) {
|
|
773
|
+
const resolved = path.resolve(filePath);
|
|
774
|
+
if (existsSync(resolved)) return realpathSync(resolved);
|
|
775
|
+
const suffix = [];
|
|
776
|
+
let cursor = resolved;
|
|
777
|
+
while (!existsSync(cursor)) {
|
|
778
|
+
suffix.unshift(path.basename(cursor));
|
|
779
|
+
const parent = path.dirname(cursor);
|
|
780
|
+
if (parent === cursor) break;
|
|
781
|
+
cursor = parent;
|
|
782
|
+
}
|
|
783
|
+
return path.join(realpathSync(cursor), ...suffix);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function lockPathFor(canonical, repoRoot) {
|
|
787
|
+
const key = createHash('sha256').update(canonical).digest('hex');
|
|
788
|
+
return path.join(path.resolve(repoRoot), '.dotmd', 'locks', `${key}.lock`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function ownerDescription(lockPath) {
|
|
792
|
+
try {
|
|
793
|
+
const owner = JSON.parse(readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
|
|
794
|
+
const age = Math.max(0, Date.now() - Date.parse(owner.createdAt));
|
|
795
|
+
return `pid ${owner.pid} on ${owner.hostname} (process started ${owner.processStartedAt ?? 'unknown'}, lock age ${age}ms)`;
|
|
796
|
+
} catch {
|
|
797
|
+
return 'another process';
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function lockOwner(lockPath) {
|
|
802
|
+
try { return JSON.parse(readFileSync(path.join(lockPath, 'owner.json'), 'utf8')); }
|
|
803
|
+
catch { return null; }
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function ownerIsDemonstrablyDead(owner) {
|
|
807
|
+
return processOwnerLiveness(owner) === 'dead';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function removeLockDirectory(lockPath, lockRoot) {
|
|
811
|
+
if (!contained(lockRoot, lockPath) || lstatSync(lockPath).isSymbolicLink()) return false;
|
|
812
|
+
for (const entry of readdirSync(lockPath)) {
|
|
813
|
+
if (!/^owner\.json$|^reclaiming$|^\.owner-[\w-]+\.tmp$/.test(entry)) return false;
|
|
814
|
+
unlinkSync(path.join(lockPath, entry));
|
|
815
|
+
}
|
|
816
|
+
rmdirSync(lockPath);
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function reclaimLock(lockPath, lockRoot) {
|
|
821
|
+
try { statSync(lockPath); } catch { return true; }
|
|
822
|
+
const owner = lockOwner(lockPath);
|
|
823
|
+
if (!ownerIsDemonstrablyDead(owner)) return false;
|
|
824
|
+
|
|
825
|
+
// A marker inside the old directory serializes reclaimers. A live owner is
|
|
826
|
+
// never reclaimed. An ownerless directory is deliberately left for bounded
|
|
827
|
+
// timeout/manual recovery: reclaiming it could steal from a live process
|
|
828
|
+
// paused between mkdir and atomic owner publication.
|
|
829
|
+
let marker;
|
|
830
|
+
try {
|
|
831
|
+
marker = openSync(path.join(lockPath, 'reclaiming'), 'wx');
|
|
832
|
+
closeSync(marker);
|
|
833
|
+
} catch { return false; }
|
|
834
|
+
const tombstone = `${lockPath}.reclaimed-${process.pid}-${Date.now()}-${tempSequence++}`;
|
|
835
|
+
try {
|
|
836
|
+
renameSync(lockPath, tombstone);
|
|
837
|
+
return removeLockDirectory(tombstone, path.dirname(tombstone));
|
|
838
|
+
} catch {
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function withPathLocks(filePaths, options, callback) {
|
|
844
|
+
const { repoRoot, timeoutMs = 2000, retryMs = 20 } = options;
|
|
845
|
+
if (!repoRoot) throw new Error('withPathLocks requires repoRoot.');
|
|
846
|
+
const canonicals = [...new Set(filePaths.map(canonicalPath))].sort();
|
|
847
|
+
const lockRoot = safeGeneratedPath(path.join(path.resolve(repoRoot), '.dotmd', 'locks'), repoRoot, options, 'Lock root');
|
|
848
|
+
ensureDirectoryDurable(lockRoot, options, 'lock-root-create');
|
|
849
|
+
const acquired = [];
|
|
850
|
+
const deadline = Date.now() + timeoutMs;
|
|
851
|
+
try {
|
|
852
|
+
for (const canonical of canonicals) {
|
|
853
|
+
const lockPath = lockPathFor(canonical, repoRoot);
|
|
854
|
+
while (true) {
|
|
855
|
+
const token = randomUUID();
|
|
856
|
+
let madeLock = false;
|
|
857
|
+
try {
|
|
858
|
+
mkdirSync(lockPath);
|
|
859
|
+
fsyncDirectory(lockRoot, options, 'lock-directory-create');
|
|
860
|
+
madeLock = true;
|
|
861
|
+
const ownerTemp = path.join(lockPath, `.owner-${token}.tmp`);
|
|
862
|
+
writeFileSync(ownerTemp, JSON.stringify({
|
|
863
|
+
token,
|
|
864
|
+
...currentProcessOwner(),
|
|
865
|
+
createdAt: new Date().toISOString(),
|
|
866
|
+
path: canonical,
|
|
867
|
+
}) + '\n', { flag: 'wx' });
|
|
868
|
+
renameSync(ownerTemp, path.join(lockPath, 'owner.json'));
|
|
869
|
+
fsyncDirectory(lockPath, options, 'lock-owner-publish');
|
|
870
|
+
if (lockOwner(lockPath)?.token !== token) {
|
|
871
|
+
throw new MutationConflictError(`Mutation lock ownership changed while claiming ${canonical}.`);
|
|
872
|
+
}
|
|
873
|
+
acquired.push({ lockPath, token });
|
|
874
|
+
break;
|
|
875
|
+
} catch (err) {
|
|
876
|
+
if (madeLock) {
|
|
877
|
+
try {
|
|
878
|
+
const owner = lockOwner(lockPath);
|
|
879
|
+
if (!owner || owner.token === token) removeLockDirectory(lockPath, lockRoot);
|
|
880
|
+
} catch { /* best effort */ }
|
|
881
|
+
}
|
|
882
|
+
if (err?.code !== 'EEXIST') throw err;
|
|
883
|
+
if (reclaimLock(lockPath, lockRoot)) continue;
|
|
884
|
+
if (Date.now() >= deadline) {
|
|
885
|
+
throw new MutationLockError(`Timed out after ${timeoutMs}ms waiting for mutation lock on ${canonical} (held by ${ownerDescription(lockPath)}).`);
|
|
886
|
+
}
|
|
887
|
+
Atomics.wait(sleepBuffer, 0, 0, Math.min(retryMs, Math.max(1, deadline - Date.now())));
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return callback();
|
|
892
|
+
} finally {
|
|
893
|
+
for (const { lockPath, token } of acquired.reverse()) {
|
|
894
|
+
try {
|
|
895
|
+
if (lockOwner(lockPath)?.token === token) {
|
|
896
|
+
removeLockDirectory(lockPath, lockRoot);
|
|
897
|
+
fsyncDirectory(lockRoot, options, 'lock-directory-delete');
|
|
898
|
+
}
|
|
899
|
+
} catch { /* preserve original error */ }
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function tempPathFor(filePath) {
|
|
905
|
+
const base = path.basename(filePath);
|
|
906
|
+
return path.join(path.dirname(filePath), `.${base}.dotmd-tmp-${process.pid}-${Date.now()}-${tempSequence++}`);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function fsyncDirectory(dirPath, options = {}, phase = 'directory') {
|
|
910
|
+
let fd;
|
|
911
|
+
try {
|
|
912
|
+
fd = openSync(dirPath, 'r');
|
|
913
|
+
options.testHooks?.beforeDirectoryFsync?.(phase, dirPath);
|
|
914
|
+
fsyncSync(fd);
|
|
915
|
+
} catch (err) {
|
|
916
|
+
if (!['EINVAL', 'ENOTSUP', 'EBADF', 'EISDIR', 'EPERM'].includes(err?.code)) throw err;
|
|
917
|
+
} finally {
|
|
918
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* best effort */ }
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function writeCompleteTemp(filePath, content, mode) {
|
|
923
|
+
const tempPath = tempPathFor(filePath);
|
|
924
|
+
let fd;
|
|
925
|
+
try {
|
|
926
|
+
fd = openSync(tempPath, 'wx', mode);
|
|
927
|
+
writeFileSync(fd, content, 'utf8');
|
|
928
|
+
fchmodSync(fd, mode);
|
|
929
|
+
fsyncSync(fd);
|
|
930
|
+
const finalStat = fstatSync(fd, { bigint: true });
|
|
931
|
+
closeSync(fd);
|
|
932
|
+
fd = undefined;
|
|
933
|
+
return {
|
|
934
|
+
path: tempPath,
|
|
935
|
+
content,
|
|
936
|
+
hash: contentHash(content),
|
|
937
|
+
identity: statIdentity(finalStat),
|
|
938
|
+
};
|
|
939
|
+
} catch (err) {
|
|
940
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* best effort */ }
|
|
941
|
+
try { unlinkSync(tempPath); } catch { /* best effort */ }
|
|
942
|
+
throw err;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function committedGeneration(prepared, finalPath) {
|
|
947
|
+
return {
|
|
948
|
+
path: path.resolve(finalPath),
|
|
949
|
+
content: prepared.content,
|
|
950
|
+
hash: prepared.hash,
|
|
951
|
+
identity: prepared.identity,
|
|
952
|
+
identityKind: 'inode-content',
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function recoveryArtifact(filePath, content, mode, label) {
|
|
957
|
+
const artifact = path.join(path.dirname(filePath), `.${path.basename(filePath)}.dotmd-recovery-${label}-${randomUUID()}`);
|
|
958
|
+
const temp = writeCompleteTemp(artifact, content, mode);
|
|
959
|
+
renameSync(temp.path, artifact);
|
|
960
|
+
fsyncDirectory(path.dirname(artifact));
|
|
961
|
+
return artifact;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function rollbackConflict(error, message) {
|
|
965
|
+
error.message += `\nRollback conflict: ${message}`;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function removeCommitted(snapshot, error, label) {
|
|
969
|
+
try {
|
|
970
|
+
assertSnapshotCurrent(snapshot);
|
|
971
|
+
unlinkSync(snapshot.path);
|
|
972
|
+
try { fsyncDirectory(path.dirname(snapshot.path)); }
|
|
973
|
+
catch (durabilityError) {
|
|
974
|
+
rollbackConflict(error, `${label} was removed, but its directory could not be durably synced: ${durabilityError.message}`);
|
|
975
|
+
}
|
|
976
|
+
return true;
|
|
977
|
+
} catch (conflict) {
|
|
978
|
+
rollbackConflict(error, `${label} was replaced; left current file untouched at ${snapshot.path}.`);
|
|
979
|
+
return false;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function restoreUpdate(committed, original, error) {
|
|
984
|
+
try {
|
|
985
|
+
assertSnapshotCurrent(committed);
|
|
986
|
+
replaceSnapshot(committed, original.content, { repoRoot: path.dirname(original.path), locked: true });
|
|
987
|
+
return true;
|
|
988
|
+
} catch (conflict) {
|
|
989
|
+
let artifact = 'could not write recovery artifact';
|
|
990
|
+
try { artifact = recoveryArtifact(original.path, original.content, original.identity.mode, 'original'); } catch { /* report fallback text */ }
|
|
991
|
+
rollbackConflict(error, `${original.path} changed after commit; left it untouched. Original content: ${artifact}.`);
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function reserveExclusive(filePath, mode, content, testHooks = {}) {
|
|
997
|
+
let fd;
|
|
998
|
+
let openedIdentity;
|
|
999
|
+
let reservation;
|
|
1000
|
+
try {
|
|
1001
|
+
fd = openSync(filePath, 'wx', mode);
|
|
1002
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
1003
|
+
openedIdentity = { dev: opened.dev, ino: opened.ino };
|
|
1004
|
+
const reservationContent = content ?? JSON.stringify({ dotmdReservation: true, pid: process.pid, createdAt: new Date().toISOString() }) + '\n';
|
|
1005
|
+
writeFileSync(fd, reservationContent, 'utf8');
|
|
1006
|
+
testHooks.afterReserveWrite?.(filePath);
|
|
1007
|
+
testHooks.beforeReserveFsync?.(filePath);
|
|
1008
|
+
fsyncSync(fd);
|
|
1009
|
+
const finalStat = fstatSync(fd, { bigint: true });
|
|
1010
|
+
reservation = {
|
|
1011
|
+
path: path.resolve(filePath),
|
|
1012
|
+
content: reservationContent,
|
|
1013
|
+
hash: contentHash(reservationContent),
|
|
1014
|
+
identity: statIdentity(finalStat),
|
|
1015
|
+
};
|
|
1016
|
+
closeSync(fd);
|
|
1017
|
+
fd = undefined;
|
|
1018
|
+
return reservation;
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* best effort */ }
|
|
1021
|
+
if (openedIdentity) {
|
|
1022
|
+
try {
|
|
1023
|
+
const current = lstatSync(filePath, { bigint: true });
|
|
1024
|
+
if (current.dev === openedIdentity.dev && current.ino === openedIdentity.ino) unlinkSync(filePath);
|
|
1025
|
+
} catch { /* missing or replaced by another actor */ }
|
|
1026
|
+
}
|
|
1027
|
+
throw err;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export function replaceSnapshot(snapshot, newContent, options = {}) {
|
|
1032
|
+
const { repoRoot, locked = false } = options;
|
|
1033
|
+
const commit = () => {
|
|
1034
|
+
const prepared = writeCompleteTemp(snapshot.path, newContent, snapshot.identity.mode);
|
|
1035
|
+
let committed;
|
|
1036
|
+
try {
|
|
1037
|
+
options.testHooks?.beforeReplacePublish?.({ snapshot, tempPath: prepared.path });
|
|
1038
|
+
assertSnapshotCurrent(snapshot);
|
|
1039
|
+
renameSync(prepared.path, snapshot.path);
|
|
1040
|
+
committed = committedGeneration(prepared, snapshot.path);
|
|
1041
|
+
options.testHooks?.afterPublicationBeforePathOpen?.('replace', snapshot.path);
|
|
1042
|
+
try { fsyncDirectory(path.dirname(snapshot.path), options, 'replace-publish'); }
|
|
1043
|
+
catch (err) { err.committedSnapshot = committed; throw err; }
|
|
1044
|
+
return committed;
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
try { unlinkSync(prepared.path); } catch { /* already renamed or best effort */ }
|
|
1047
|
+
if (committed && !err.committedSnapshot) err.committedSnapshot = committed;
|
|
1048
|
+
throw err;
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
return locked ? commit() : withPathLocks([snapshot.path], { repoRoot, ...options }, commit);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export function mutateFile(filePath, options, render) {
|
|
1055
|
+
return withPathLocks([filePath], options, () => {
|
|
1056
|
+
options.testHooks?.beforeMutationSnapshot?.(filePath);
|
|
1057
|
+
const snapshot = snapshotFile(filePath);
|
|
1058
|
+
const rendered = render(snapshot.content, snapshot);
|
|
1059
|
+
if (rendered === snapshot.content) return { changed: false, snapshot };
|
|
1060
|
+
try {
|
|
1061
|
+
const committed = replaceSnapshot(snapshot, rendered, { ...options, locked: true });
|
|
1062
|
+
return { changed: true, snapshot, committed };
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
if (err.committedSnapshot) restoreUpdate(err.committedSnapshot, snapshot, err);
|
|
1065
|
+
throw err;
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
export function createFileExclusive(filePath, content, options) {
|
|
1071
|
+
const { repoRoot, mode = 0o666 & ~process.umask(), locked = false } = options;
|
|
1072
|
+
const create = () => {
|
|
1073
|
+
if (existsSync(filePath)) throw new MutationConflictError(`Destination already exists: ${path.resolve(filePath)}`);
|
|
1074
|
+
const prepared = writeCompleteTemp(filePath, content, mode);
|
|
1075
|
+
let tempPublished = false;
|
|
1076
|
+
let committed;
|
|
1077
|
+
try {
|
|
1078
|
+
try {
|
|
1079
|
+
if (options.testHooks?.forceLinkUnsupported) {
|
|
1080
|
+
const error = new Error('injected unsupported hard link');
|
|
1081
|
+
error.code = 'ENOTSUP';
|
|
1082
|
+
throw error;
|
|
1083
|
+
}
|
|
1084
|
+
linkSync(prepared.path, filePath);
|
|
1085
|
+
committed = committedGeneration(prepared, filePath);
|
|
1086
|
+
options.testHooks?.afterPublicationBeforePathOpen?.('create-hardlink', filePath);
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
if (!['ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EXDEV', 'EMLINK'].includes(err?.code)) throw err;
|
|
1089
|
+
// Portable fallback: reserve with O_EXCL, then replace only our own
|
|
1090
|
+
// reservation. This may expose the reservation briefly, but can never
|
|
1091
|
+
// overwrite a competing creator's file.
|
|
1092
|
+
let fallbackReserved = false;
|
|
1093
|
+
let reservation;
|
|
1094
|
+
try {
|
|
1095
|
+
reservation = reserveExclusive(filePath, mode, undefined, options.testHooks);
|
|
1096
|
+
fallbackReserved = true;
|
|
1097
|
+
options.testHooks?.afterReservationAcquired?.(filePath);
|
|
1098
|
+
options.testHooks?.afterCreateReservation?.(filePath);
|
|
1099
|
+
assertSnapshotCurrent(reservation);
|
|
1100
|
+
renameSync(prepared.path, filePath);
|
|
1101
|
+
tempPublished = true;
|
|
1102
|
+
committed = committedGeneration(prepared, filePath);
|
|
1103
|
+
options.testHooks?.afterPublicationBeforePathOpen?.('create-rename', filePath);
|
|
1104
|
+
} catch (fallbackError) {
|
|
1105
|
+
if (fallbackReserved) {
|
|
1106
|
+
try { assertSnapshotCurrent(reservation); unlinkSync(filePath); } catch { /* replaced by another actor */ }
|
|
1107
|
+
}
|
|
1108
|
+
throw fallbackError;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
if (!tempPublished) unlinkSync(prepared.path);
|
|
1112
|
+
try { fsyncDirectory(path.dirname(filePath), options, 'creation-publish'); }
|
|
1113
|
+
catch (err) { err.committedSnapshot = committed; throw err; }
|
|
1114
|
+
return committed;
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
try { unlinkSync(prepared.path); } catch { /* best effort */ }
|
|
1117
|
+
if (committed) removeCommitted(committed, err, 'created file after durability failure');
|
|
1118
|
+
if (err?.code === 'EEXIST') throw new MutationConflictError(`Destination already exists: ${path.resolve(filePath)}`);
|
|
1119
|
+
throw err;
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
return locked ? create() : withPathLocks([filePath], options, create);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
1126
|
+
const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [] } = options;
|
|
1127
|
+
recoverAbandonedTransactions(repoRoot, options);
|
|
1128
|
+
return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path)], options, () => {
|
|
1129
|
+
testHooks?.afterTransactionPhase?.('lock', { sourcePath, targetPath });
|
|
1130
|
+
testHooks?.beforeMoveSnapshot?.({ sourcePath, targetPath });
|
|
1131
|
+
const source = snapshotFile(sourcePath);
|
|
1132
|
+
if (options.expectedSourceContent !== undefined && source.content !== options.expectedSourceContent) {
|
|
1133
|
+
throw new MutationConflictError(`Source changed while the move transaction was being planned: ${source.path}`);
|
|
1134
|
+
}
|
|
1135
|
+
const targetDir = path.dirname(targetPath);
|
|
1136
|
+
const newContent = typeof render === 'function' ? render(source.content, source) : render;
|
|
1137
|
+
const preparedUpdates = updates.map(item => {
|
|
1138
|
+
const snapshot = snapshotFile(item.path);
|
|
1139
|
+
if (item.expectedContent !== undefined && snapshot.content !== item.expectedContent) {
|
|
1140
|
+
throw new MutationConflictError(`File changed while the move mutation set was being prepared: ${snapshot.path}`);
|
|
1141
|
+
}
|
|
1142
|
+
return { ...item, snapshot, content: item.render ? item.render(snapshot.content, snapshot) : item.content };
|
|
1143
|
+
}).filter(item => item.content !== item.snapshot.content);
|
|
1144
|
+
const preparedDeletions = deletions.map(item => {
|
|
1145
|
+
const snapshot = snapshotFile(item.path);
|
|
1146
|
+
if (item.expectedContent !== undefined && snapshot.content !== item.expectedContent) {
|
|
1147
|
+
throw new MutationConflictError(`File changed while the move deletion set was being prepared: ${snapshot.path}`);
|
|
1148
|
+
}
|
|
1149
|
+
return { ...item, snapshot };
|
|
1150
|
+
});
|
|
1151
|
+
for (const item of creations) {
|
|
1152
|
+
if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
|
|
1153
|
+
}
|
|
1154
|
+
const transactionId = `${process.pid}-${Date.now()}-${tempSequence++}`;
|
|
1155
|
+
const transaction = beginMoveManifest({
|
|
1156
|
+
repoRoot,
|
|
1157
|
+
transactionId,
|
|
1158
|
+
operation: options.operation,
|
|
1159
|
+
sessionId: options.sessionId,
|
|
1160
|
+
source,
|
|
1161
|
+
targetPath,
|
|
1162
|
+
targetContent: newContent,
|
|
1163
|
+
updates: preparedUpdates,
|
|
1164
|
+
creations,
|
|
1165
|
+
deletions: preparedDeletions,
|
|
1166
|
+
gitIndex: options.gitIndex,
|
|
1167
|
+
tracked: Boolean(options.gitMove || finalize),
|
|
1168
|
+
}, options);
|
|
1169
|
+
const { backup, stagedPath } = transaction;
|
|
1170
|
+
let reserved = false;
|
|
1171
|
+
let reservation;
|
|
1172
|
+
let moved = false;
|
|
1173
|
+
let published = false;
|
|
1174
|
+
let publishedSnapshot;
|
|
1175
|
+
let backupSnapshot;
|
|
1176
|
+
const committedUpdates = [];
|
|
1177
|
+
const committedCreations = [];
|
|
1178
|
+
const committedDeletions = [];
|
|
1179
|
+
let finalizeAttempted = false;
|
|
1180
|
+
let pastRecoveryBoundary = false;
|
|
1181
|
+
try {
|
|
1182
|
+
testHooks?.afterTransactionPhase?.('manifest', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
1183
|
+
stageMoveManifest(transaction, newContent, source.identity.mode, options);
|
|
1184
|
+
const prepared = snapshotFile(stagedPath);
|
|
1185
|
+
ensureTransactionDirectory(targetDir, transaction, options, targetPath);
|
|
1186
|
+
for (const item of creations) ensureTransactionDirectory(path.dirname(item.path), transaction, options, item.path);
|
|
1187
|
+
try {
|
|
1188
|
+
reservation = reserveExclusive(targetPath, source.identity.mode, JSON.stringify({
|
|
1189
|
+
dotmdReservation: true,
|
|
1190
|
+
transactionId,
|
|
1191
|
+
sourcePath,
|
|
1192
|
+
backup,
|
|
1193
|
+
pid: process.pid,
|
|
1194
|
+
createdAt: new Date().toISOString(),
|
|
1195
|
+
}) + '\n', testHooks);
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
if (err?.code === 'EEXIST') throw new MutationConflictError(`Destination already exists: ${path.resolve(targetPath)}`);
|
|
1198
|
+
throw err;
|
|
1199
|
+
}
|
|
1200
|
+
reserved = true;
|
|
1201
|
+
transaction.manifest.participants[1].transientHashes.push(reservation.hash);
|
|
1202
|
+
setMoveManifestPhase(transaction, 'reservation', options);
|
|
1203
|
+
testHooks?.afterReservationAcquired?.(targetPath);
|
|
1204
|
+
fsyncDirectory(targetDir);
|
|
1205
|
+
|
|
1206
|
+
testHooks?.afterMoveReservation?.({ source, sourcePath, targetPath });
|
|
1207
|
+
assertSnapshotCurrent(source);
|
|
1208
|
+
testHooks?.afterMoveValidation?.({ source, sourcePath, targetPath });
|
|
1209
|
+
// Revalidate immediately before the move, after every hook/preparation
|
|
1210
|
+
// step that can permit an injected or external source edit.
|
|
1211
|
+
assertSnapshotCurrent(source);
|
|
1212
|
+
for (const item of preparedUpdates) assertSnapshotCurrent(item.snapshot);
|
|
1213
|
+
renameSync(sourcePath, backup);
|
|
1214
|
+
moved = true;
|
|
1215
|
+
backupSnapshot = { ...source, path: backup, identityKind: 'inode-content' };
|
|
1216
|
+
fsyncDirectory(path.dirname(sourcePath));
|
|
1217
|
+
setMoveManifestPhase(transaction, 'source-move', options);
|
|
1218
|
+
|
|
1219
|
+
testHooks?.afterSourceMove?.({ backup, sourcePath, targetPath });
|
|
1220
|
+
assertSnapshotCurrent(reservation);
|
|
1221
|
+
renameSync(stagedPath, targetPath);
|
|
1222
|
+
published = true;
|
|
1223
|
+
reserved = false;
|
|
1224
|
+
publishedSnapshot = committedGeneration(prepared, targetPath);
|
|
1225
|
+
testHooks?.afterPublicationBeforePathOpen?.('move-target', targetPath);
|
|
1226
|
+
fsyncDirectory(targetDir, options, 'move-target-publish');
|
|
1227
|
+
setMoveManifestPhase(transaction, 'target-publication', options);
|
|
1228
|
+
for (const item of preparedUpdates) {
|
|
1229
|
+
try {
|
|
1230
|
+
const committed = replaceSnapshot(item.snapshot, item.content, { ...options, locked: true });
|
|
1231
|
+
committedUpdates.push({ ...item, committed });
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
if (err.committedSnapshot) committedUpdates.push({ ...item, committed: err.committedSnapshot });
|
|
1234
|
+
throw err;
|
|
1235
|
+
}
|
|
1236
|
+
setMoveManifestPhase(transaction, companionPhase(item, 'referrer-publication'), options, item.path);
|
|
1237
|
+
}
|
|
1238
|
+
for (const item of creations) {
|
|
1239
|
+
const committed = createFileExclusive(item.path, item.content, { ...options, mode: item.mode, locked: true });
|
|
1240
|
+
committedCreations.push({ ...item, committed });
|
|
1241
|
+
setMoveManifestPhase(transaction, companionPhase(item, 'creation-publication'), options, item.path);
|
|
1242
|
+
}
|
|
1243
|
+
for (const item of preparedDeletions) {
|
|
1244
|
+
assertSnapshotCurrent(item.snapshot);
|
|
1245
|
+
unlinkSync(item.path);
|
|
1246
|
+
fsyncDirectory(path.dirname(item.path), options, 'move-companion-delete');
|
|
1247
|
+
committedDeletions.push(item);
|
|
1248
|
+
setMoveManifestPhase(transaction, companionPhase(item, 'deletion-publication'), options, item.path);
|
|
1249
|
+
testHooks?.afterMoveDeletion?.(committedDeletions.length, item.path);
|
|
1250
|
+
}
|
|
1251
|
+
testHooks?.afterMovePublish?.({ backup, sourcePath, targetPath });
|
|
1252
|
+
|
|
1253
|
+
if (options.gitMove && transaction.manifest.gitIndex.before) {
|
|
1254
|
+
finalizeAttempted = true;
|
|
1255
|
+
const gitHooks = {
|
|
1256
|
+
...options.testHooks,
|
|
1257
|
+
afterGitIndexArtifact: info => {
|
|
1258
|
+
transaction.manifest.gitIndex.prepared = info.prepared;
|
|
1259
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1260
|
+
options.testHooks?.afterTransactionPhase?.('git-index-artifact', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
1261
|
+
options.testHooks?.afterGitIndexArtifact?.(info);
|
|
1262
|
+
},
|
|
1263
|
+
afterGitIndexWorkSeed: info => {
|
|
1264
|
+
transaction.manifest.gitIndex.prepared = info.prepared;
|
|
1265
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1266
|
+
options.testHooks?.afterGitIndexWorkSeed?.(info);
|
|
1267
|
+
},
|
|
1268
|
+
afterGitIndexPrepareStep: info => {
|
|
1269
|
+
transaction.manifest.gitIndex.prepared = info.prepared;
|
|
1270
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1271
|
+
options.testHooks?.afterTransactionPhase?.('git-index-prepare-step', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
1272
|
+
options.testHooks?.afterGitIndexPrepareStep?.(info);
|
|
1273
|
+
},
|
|
1274
|
+
afterGitIndexSubprocessBeforeCheckpoint: info => {
|
|
1275
|
+
options.testHooks?.afterTransactionPhase?.('git-index-post-subprocess', { manifestPath: transaction.manifestPath, manifest: transaction.manifest, ...info });
|
|
1276
|
+
options.testHooks?.afterGitIndexSubprocessBeforeCheckpoint?.(info);
|
|
1277
|
+
},
|
|
1278
|
+
afterGitIndexPrepared: info => {
|
|
1279
|
+
transaction.manifest.gitIndex.prepared = info.prepared;
|
|
1280
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1281
|
+
options.testHooks?.afterTransactionPhase?.('git-index-prepared', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
1282
|
+
options.testHooks?.afterGitIndexPrepared?.(info);
|
|
1283
|
+
},
|
|
1284
|
+
afterGitIndexLock: info => {
|
|
1285
|
+
options.testHooks?.afterTransactionPhase?.('git-index-lock', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
1286
|
+
options.testHooks?.afterGitIndexLock?.(info);
|
|
1287
|
+
},
|
|
1288
|
+
afterGitIndexCompare: info => {
|
|
1289
|
+
options.testHooks?.afterTransactionPhase?.('git-index-compare', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
1290
|
+
options.testHooks?.afterGitIndexCompare?.(info);
|
|
1291
|
+
},
|
|
1292
|
+
afterGitIndexPublication: info => {
|
|
1293
|
+
options.testHooks?.afterTransactionPhase?.('git-index-publication', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
1294
|
+
options.testHooks?.afterGitIndexPublication?.(info);
|
|
1295
|
+
},
|
|
1296
|
+
};
|
|
1297
|
+
transaction.manifest.gitIndex.ownedAfter = stageMovePathsCas(
|
|
1298
|
+
transaction.manifest.gitMove.source,
|
|
1299
|
+
transaction.manifest.gitMove.target,
|
|
1300
|
+
repoRoot,
|
|
1301
|
+
transaction.manifest.gitIndex.before,
|
|
1302
|
+
{ testHooks: gitHooks, artifactPath: transaction.gitPreparedArtifact },
|
|
1303
|
+
);
|
|
1304
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1305
|
+
} else if (finalize) {
|
|
1306
|
+
finalizeAttempted = true;
|
|
1307
|
+
finalize({ sourcePath, targetPath, backup });
|
|
1308
|
+
}
|
|
1309
|
+
setMoveManifestPhase(transaction, 'canonical-commit', options);
|
|
1310
|
+
testHooks?.afterMoveFinalize?.({ backup, sourcePath, targetPath });
|
|
1311
|
+
unlinkSync(backup);
|
|
1312
|
+
moved = false;
|
|
1313
|
+
pastRecoveryBoundary = true;
|
|
1314
|
+
fsyncDirectory(transaction.directory, options, 'move-backup-delete');
|
|
1315
|
+
setMoveManifestPhase(transaction, 'backup-deletion', options);
|
|
1316
|
+
if (!cleanupCreatedDirectories(transaction, options)) throw new Error('Could not remove transaction-owned directory markers after commit.');
|
|
1317
|
+
transaction.manifest.phase = 'completed';
|
|
1318
|
+
transaction.manifest.status = 'complete';
|
|
1319
|
+
transaction.manifest.result = 'committed';
|
|
1320
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1321
|
+
testHooks?.afterTransactionPhase?.('manifest-completion', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
1322
|
+
testHooks?.afterTransactionPhase?.('final-commit', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
|
|
1323
|
+
setMoveManifestPhase(transaction, 'cleanup', options);
|
|
1324
|
+
cleanupTransactionDirectory(transaction.directory, transaction.manifest, options);
|
|
1325
|
+
return { source, target: publishedSnapshot, updatedPaths: preparedUpdates.map(item => item.path) };
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
if (pastRecoveryBoundary) {
|
|
1328
|
+
try {
|
|
1329
|
+
transaction.manifest.phase = 'failed-manual';
|
|
1330
|
+
transaction.manifest.status = 'failed-manual';
|
|
1331
|
+
transaction.manifest.result = 'failed-manual';
|
|
1332
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1333
|
+
} catch { /* retain existing durable evidence */ }
|
|
1334
|
+
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.')}`;
|
|
1335
|
+
throw err;
|
|
1336
|
+
}
|
|
1337
|
+
let rollbackFailed = false;
|
|
1338
|
+
for (const item of committedDeletions.reverse()) {
|
|
1339
|
+
try { createFileExclusive(item.path, item.snapshot.content, { ...options, mode: item.snapshot.identity.mode, locked: true }); }
|
|
1340
|
+
catch (rollbackError) { rollbackFailed = true; rollbackConflict(err, `could not restore deleted companion ${item.path}: ${rollbackError.message}`); }
|
|
1341
|
+
}
|
|
1342
|
+
for (const item of committedCreations.reverse()) if (!removeCommitted(item.committed, err, 'created move companion')) rollbackFailed = true;
|
|
1343
|
+
for (const item of committedUpdates.reverse()) if (!restoreUpdate(item.committed, item.snapshot, err)) rollbackFailed = true;
|
|
1344
|
+
if (published) {
|
|
1345
|
+
if (!removeCommitted(publishedSnapshot, err, 'published move target')) rollbackFailed = true;
|
|
1346
|
+
published = false;
|
|
1347
|
+
} else if (reserved) {
|
|
1348
|
+
try { assertSnapshotCurrent(reservation); unlinkSync(targetPath); }
|
|
1349
|
+
catch { rollbackFailed = true; }
|
|
1350
|
+
reserved = false;
|
|
1351
|
+
}
|
|
1352
|
+
if (moved) {
|
|
1353
|
+
try {
|
|
1354
|
+
assertSnapshotCurrent(backupSnapshot);
|
|
1355
|
+
if (existsSync(sourcePath)) {
|
|
1356
|
+
rollbackFailed = true;
|
|
1357
|
+
rollbackConflict(err, `source was recreated at ${sourcePath}; left recoverable original at ${backup}.`);
|
|
1358
|
+
} else {
|
|
1359
|
+
renameSync(backup, sourcePath);
|
|
1360
|
+
moved = false;
|
|
1361
|
+
try { fsyncDirectory(path.dirname(sourcePath), options, 'rollback-source-restore'); }
|
|
1362
|
+
catch (durabilityError) {
|
|
1363
|
+
rollbackFailed = true;
|
|
1364
|
+
rollbackConflict(err, `source was restored but its parent directory could not be durably synced: ${durabilityError.message}`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
} catch (rollbackError) {
|
|
1368
|
+
rollbackFailed = true;
|
|
1369
|
+
rollbackConflict(err, `could not restore source; recoverable original remains at ${backup}: ${rollbackError.message}`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
try { if (finalizeAttempted) {
|
|
1373
|
+
if (transaction.manifest.gitIndex.before) {
|
|
1374
|
+
if (transaction.manifest.gitIndex.ownedAfter) {
|
|
1375
|
+
restoreGitIndexCas(transaction.manifest.gitIndex.before, transaction.manifest.gitIndex.ownedAfter, repoRoot, {
|
|
1376
|
+
artifactPath: transaction.gitRestoreArtifact,
|
|
1377
|
+
testHooks: {
|
|
1378
|
+
...options.testHooks,
|
|
1379
|
+
afterGitRestoreArtifact: info => {
|
|
1380
|
+
transaction.manifest.gitIndex.prepared = info.prepared;
|
|
1381
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1382
|
+
options.testHooks?.afterGitRestoreArtifact?.(info);
|
|
1383
|
+
},
|
|
1384
|
+
afterGitRestorePrepared: info => {
|
|
1385
|
+
transaction.manifest.gitIndex.prepared = info.prepared;
|
|
1386
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1387
|
+
options.testHooks?.afterGitRestorePrepared?.(info);
|
|
1388
|
+
},
|
|
1389
|
+
},
|
|
1390
|
+
});
|
|
1391
|
+
} else if (!sameGitIndexGeneration(captureGitIndexGeneration(repoRoot), transaction.manifest.gitIndex.before)) {
|
|
1392
|
+
throw new Error('Git finalize did not publish its prepared generation, but the real index changed; current staging was preserved.');
|
|
1393
|
+
}
|
|
1394
|
+
} else if (rollbackFinalize) rollbackFinalize({ sourcePath, targetPath, backup });
|
|
1395
|
+
} } catch (rollbackError) {
|
|
1396
|
+
rollbackFailed = true;
|
|
1397
|
+
rollbackConflict(err, `could not restore Git index: ${rollbackError.message}`);
|
|
1398
|
+
}
|
|
1399
|
+
try {
|
|
1400
|
+
if (transaction.manifest.gitIndex.prepared) {
|
|
1401
|
+
const reclaimed = reclaimPreparedGitIndex(transaction.manifest.gitIndex, repoRoot, options);
|
|
1402
|
+
if (reclaimed.retainedPaths.length) {
|
|
1403
|
+
transaction.manifest.gitIndex.retainedPaths = [...new Set([
|
|
1404
|
+
...transaction.manifest.gitIndex.retainedPaths,
|
|
1405
|
+
...reclaimed.retainedPaths,
|
|
1406
|
+
])];
|
|
1407
|
+
rollbackFailed = true;
|
|
1408
|
+
rollbackConflict(err, `unverified Git preparation work was preserved for manual recovery: ${reclaimed.retainedPaths.join(', ')}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
} catch (cleanupError) {
|
|
1412
|
+
rollbackFailed = true;
|
|
1413
|
+
rollbackConflict(err, `could not safely clean prepared Git index state: ${cleanupError.message}`);
|
|
1414
|
+
}
|
|
1415
|
+
try { fsyncDirectory(targetDir); } catch { rollbackFailed = true; }
|
|
1416
|
+
try {
|
|
1417
|
+
const states = transaction.manifest.participants.map(participantState);
|
|
1418
|
+
if (!rollbackFailed && states.every(state => state === 'old') && cleanupCreatedDirectories(transaction, options)) {
|
|
1419
|
+
transaction.manifest.phase = 'completed';
|
|
1420
|
+
transaction.manifest.status = 'complete';
|
|
1421
|
+
transaction.manifest.result = 'rolled-back';
|
|
1422
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1423
|
+
cleanupTransactionDirectory(transaction.directory, transaction.manifest, options);
|
|
1424
|
+
} else {
|
|
1425
|
+
transaction.manifest.phase = 'failed-manual';
|
|
1426
|
+
transaction.manifest.status = 'failed-manual';
|
|
1427
|
+
transaction.manifest.result = 'failed-manual';
|
|
1428
|
+
durableJson(transaction.manifestPath, transaction.manifest, options);
|
|
1429
|
+
err.message += `\n${transactionRepairMessage(transaction.manifestPath, transaction.manifest, 'Rollback was incomplete; durable evidence was retained.')}`;
|
|
1430
|
+
}
|
|
1431
|
+
} catch (manifestError) {
|
|
1432
|
+
err.message += `\nCould not finalize transaction recovery record: ${manifestError.message}`;
|
|
1433
|
+
}
|
|
1434
|
+
throw err;
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
export function mutateFileSet({ updates = [], creations = [] }, options) {
|
|
1440
|
+
const paths = [...updates.map(item => item.path), ...creations.map(item => item.path)];
|
|
1441
|
+
return withPathLocks(paths, options, () => {
|
|
1442
|
+
const createdDirectories = [];
|
|
1443
|
+
for (const item of creations) {
|
|
1444
|
+
const directory = path.dirname(item.path);
|
|
1445
|
+
if (!existsSync(directory)) {
|
|
1446
|
+
const missing = [];
|
|
1447
|
+
let cursor = directory;
|
|
1448
|
+
while (!existsSync(cursor)) { missing.unshift(cursor); cursor = path.dirname(cursor); }
|
|
1449
|
+
for (const candidate of missing) {
|
|
1450
|
+
mkdirSync(candidate);
|
|
1451
|
+
fsyncDirectory(path.dirname(candidate), options, 'set-directory-create');
|
|
1452
|
+
createdDirectories.push(candidate);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
const preparedUpdates = updates.map(item => {
|
|
1457
|
+
const snapshot = snapshotFile(item.path);
|
|
1458
|
+
if (item.expectedContent !== undefined && snapshot.content !== item.expectedContent) {
|
|
1459
|
+
throw new MutationConflictError(`File changed while the mutation set was being prepared: ${snapshot.path}`);
|
|
1460
|
+
}
|
|
1461
|
+
return { ...item, snapshot, content: item.render ? item.render(snapshot.content, snapshot) : item.content };
|
|
1462
|
+
});
|
|
1463
|
+
for (const item of creations) {
|
|
1464
|
+
if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
|
|
1465
|
+
}
|
|
1466
|
+
options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations });
|
|
1467
|
+
|
|
1468
|
+
const committedUpdates = [];
|
|
1469
|
+
const committedCreations = [];
|
|
1470
|
+
try {
|
|
1471
|
+
for (const item of preparedUpdates) {
|
|
1472
|
+
try {
|
|
1473
|
+
const committed = replaceSnapshot(item.snapshot, item.content, { ...options, locked: true });
|
|
1474
|
+
committedUpdates.push({ ...item, committed });
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
if (err.committedSnapshot) committedUpdates.push({ ...item, committed: err.committedSnapshot });
|
|
1477
|
+
throw err;
|
|
1478
|
+
}
|
|
1479
|
+
options.testHooks?.afterSetCommit?.(committedUpdates.length + committedCreations.length, item.path);
|
|
1480
|
+
}
|
|
1481
|
+
for (const item of creations) {
|
|
1482
|
+
const committed = createFileExclusive(item.path, item.content, { ...options, mode: item.mode, locked: true });
|
|
1483
|
+
committedCreations.push({ ...item, committed });
|
|
1484
|
+
options.testHooks?.afterSetCommit?.(committedUpdates.length + committedCreations.length, item.path);
|
|
1485
|
+
}
|
|
1486
|
+
return { updates: preparedUpdates, creations };
|
|
1487
|
+
} catch (err) {
|
|
1488
|
+
for (const item of committedCreations.reverse()) {
|
|
1489
|
+
removeCommitted(item.committed, err, 'created file');
|
|
1490
|
+
}
|
|
1491
|
+
for (const item of committedUpdates.reverse()) {
|
|
1492
|
+
restoreUpdate(item.committed, item.snapshot, err);
|
|
1493
|
+
}
|
|
1494
|
+
for (const directory of createdDirectories.reverse()) {
|
|
1495
|
+
try {
|
|
1496
|
+
if (readdirSync(directory).length === 0) {
|
|
1497
|
+
rmdirSync(directory);
|
|
1498
|
+
fsyncDirectory(path.dirname(directory), options, 'set-directory-rollback');
|
|
1499
|
+
}
|
|
1500
|
+
} catch (rollbackError) { rollbackConflict(err, `could not remove newly created directory ${directory}: ${rollbackError.message}`); }
|
|
1501
|
+
}
|
|
1502
|
+
throw err;
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
}
|