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.
- package/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- 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
|
+
}
|