dotmd-cli 0.70.2 → 0.70.4
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/package.json +1 -1
- package/src/atomic-mutation.mjs +11 -6
- package/src/config-edit.mjs +6 -3
- package/src/durable-rename.mjs +55 -0
- package/src/git.mjs +4 -1
- package/src/lifecycle.mjs +20 -11
- package/src/prompts.mjs +4 -1
package/package.json
CHANGED
package/src/atomic-mutation.mjs
CHANGED
|
@@ -23,10 +23,15 @@ import os from 'node:os';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { captureGitIndexGeneration, reclaimPreparedGitIndex, restoreGitIndexCas, sameGitIndexGeneration, stageMovePathsCas } from './git.mjs';
|
|
25
25
|
import { authorizeManagedDestination, authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
26
|
+
import { commitRename } from './durable-rename.mjs';
|
|
26
27
|
|
|
27
28
|
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
28
29
|
let tempSequence = 0;
|
|
29
30
|
|
|
31
|
+
// How long a peer waits to acquire a path lock before MutationLockError. Any
|
|
32
|
+
// retry budget spent while the lock is held has to fit well inside this.
|
|
33
|
+
export const MUTATION_LOCK_TIMEOUT_MS = 2000;
|
|
34
|
+
|
|
30
35
|
export function processStartIdentity(pid) {
|
|
31
36
|
try {
|
|
32
37
|
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
@@ -111,7 +116,7 @@ function transactionRoot(repoRoot, options = {}) {
|
|
|
111
116
|
function durableJson(filePath, value, options = {}) {
|
|
112
117
|
const content = JSON.stringify(value, null, 2) + '\n';
|
|
113
118
|
const temp = writeCompleteTemp(filePath, content, 0o600);
|
|
114
|
-
|
|
119
|
+
commitRename(temp.path, filePath, options.testHooks);
|
|
115
120
|
fsyncDirectory(path.dirname(filePath), options, 'transaction-manifest');
|
|
116
121
|
}
|
|
117
122
|
|
|
@@ -841,7 +846,7 @@ function reclaimLock(lockPath, lockRoot) {
|
|
|
841
846
|
}
|
|
842
847
|
|
|
843
848
|
export function withPathLocks(filePaths, options, callback) {
|
|
844
|
-
const { repoRoot, timeoutMs =
|
|
849
|
+
const { repoRoot, timeoutMs = MUTATION_LOCK_TIMEOUT_MS, retryMs = 20 } = options;
|
|
845
850
|
if (!repoRoot) throw new Error('withPathLocks requires repoRoot.');
|
|
846
851
|
const canonicals = [...new Set(filePaths.map(canonicalPath))].sort();
|
|
847
852
|
const lockRoot = safeGeneratedPath(path.join(path.resolve(repoRoot), '.dotmd', 'locks'), repoRoot, options, 'Lock root');
|
|
@@ -1036,7 +1041,7 @@ export function replaceSnapshot(snapshot, newContent, options = {}) {
|
|
|
1036
1041
|
try {
|
|
1037
1042
|
options.testHooks?.beforeReplacePublish?.({ snapshot, tempPath: prepared.path });
|
|
1038
1043
|
assertSnapshotCurrent(snapshot);
|
|
1039
|
-
|
|
1044
|
+
commitRename(prepared.path, snapshot.path, options.testHooks);
|
|
1040
1045
|
committed = committedGeneration(prepared, snapshot.path);
|
|
1041
1046
|
options.testHooks?.afterPublicationBeforePathOpen?.('replace', snapshot.path);
|
|
1042
1047
|
try { fsyncDirectory(path.dirname(snapshot.path), options, 'replace-publish'); }
|
|
@@ -1097,7 +1102,7 @@ export function createFileExclusive(filePath, content, options) {
|
|
|
1097
1102
|
options.testHooks?.afterReservationAcquired?.(filePath);
|
|
1098
1103
|
options.testHooks?.afterCreateReservation?.(filePath);
|
|
1099
1104
|
assertSnapshotCurrent(reservation);
|
|
1100
|
-
|
|
1105
|
+
commitRename(prepared.path, filePath, options.testHooks);
|
|
1101
1106
|
tempPublished = true;
|
|
1102
1107
|
committed = committedGeneration(prepared, filePath);
|
|
1103
1108
|
options.testHooks?.afterPublicationBeforePathOpen?.('create-rename', filePath);
|
|
@@ -1210,7 +1215,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
|
1210
1215
|
// step that can permit an injected or external source edit.
|
|
1211
1216
|
assertSnapshotCurrent(source);
|
|
1212
1217
|
for (const item of preparedUpdates) assertSnapshotCurrent(item.snapshot);
|
|
1213
|
-
|
|
1218
|
+
commitRename(sourcePath, backup, testHooks);
|
|
1214
1219
|
moved = true;
|
|
1215
1220
|
backupSnapshot = { ...source, path: backup, identityKind: 'inode-content' };
|
|
1216
1221
|
fsyncDirectory(path.dirname(sourcePath));
|
|
@@ -1218,7 +1223,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
|
1218
1223
|
|
|
1219
1224
|
testHooks?.afterSourceMove?.({ backup, sourcePath, targetPath });
|
|
1220
1225
|
assertSnapshotCurrent(reservation);
|
|
1221
|
-
|
|
1226
|
+
commitRename(stagedPath, targetPath, testHooks);
|
|
1222
1227
|
published = true;
|
|
1223
1228
|
reserved = false;
|
|
1224
1229
|
publishedSnapshot = committedGeneration(prepared, targetPath);
|
package/src/config-edit.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// 2. fs.writeFileSync(<path>.tmp, new)
|
|
8
8
|
// 3. import the tmp via file:// + cache-bust query — must parse
|
|
9
9
|
// 4. resolveConfig(tmp) — must not surface new warnings
|
|
10
|
-
// 5.
|
|
10
|
+
// 5. commitRename(tmp, real) only on full success
|
|
11
11
|
// 6. on any failure: unlink tmp, surface error, real file untouched
|
|
12
12
|
//
|
|
13
13
|
// Pulling in @babel/parser would force a heavy dep on a project with two
|
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
// `templates` section is 264 lines of arrow functions with multi-line
|
|
16
16
|
// template literals). Line surgery is the conservative choice.
|
|
17
17
|
|
|
18
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
19
19
|
import { pathToFileURL } from 'node:url';
|
|
20
20
|
import { resolveConfig } from './config.mjs';
|
|
21
|
+
import { commitRename } from './durable-rename.mjs';
|
|
21
22
|
|
|
22
23
|
const STATUS_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
23
24
|
const RESERVED_NAMES = new Set([
|
|
@@ -595,7 +596,9 @@ export async function writeConfigAtomic(configPath, newContent, cwd) {
|
|
|
595
596
|
throw new ConfigEditError(`Generated config surfaces new warnings:\n - ${novel.join('\n - ')}`);
|
|
596
597
|
}
|
|
597
598
|
|
|
598
|
-
|
|
599
|
+
// The live config is very likely open in the user's editor; on Windows that
|
|
600
|
+
// makes the rename fail transiently, so publish through the retrying helper.
|
|
601
|
+
commitRename(tmpPath, configPath);
|
|
599
602
|
} catch (err) {
|
|
600
603
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
601
604
|
throw err;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Retrying rename for publishing a prepared temp over a live path.
|
|
2
|
+
//
|
|
3
|
+
// Lives in its own leaf module rather than in atomic-mutation.mjs because
|
|
4
|
+
// atomic-mutation.mjs already imports git.mjs, and git.mjs needs this helper for
|
|
5
|
+
// its .git/index publish — exporting it from atomic-mutation.mjs would close an
|
|
6
|
+
// import cycle.
|
|
7
|
+
import { renameSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
10
|
+
|
|
11
|
+
const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES'];
|
|
12
|
+
const RENAME_RETRY_ATTEMPTS = 10;
|
|
13
|
+
const RENAME_RETRY_BACKOFF_MS = 10;
|
|
14
|
+
|
|
15
|
+
// Total the backoff can sleep across a fully exhausted retry — the sum of
|
|
16
|
+
// RENAME_RETRY_BACKOFF_MS × (1 … attempts-1). Exported so the budget can be
|
|
17
|
+
// checked against MUTATION_LOCK_TIMEOUT_MS deterministically, rather than by
|
|
18
|
+
// timing a run on a shared CI runner.
|
|
19
|
+
export const RENAME_RETRY_SLEEP_BUDGET_MS =
|
|
20
|
+
(RENAME_RETRY_BACKOFF_MS * (RENAME_RETRY_ATTEMPTS - 1) * RENAME_RETRY_ATTEMPTS) / 2;
|
|
21
|
+
|
|
22
|
+
// Windows refuses to rename onto — or away from — a path another process holds
|
|
23
|
+
// open, surfacing EPERM/EBUSY/EACCES. That holder is by construction a
|
|
24
|
+
// non-cooperating one (editor, AV, Search Indexer, `git`): dotmd's path lock is
|
|
25
|
+
// advisory and only excludes other dotmd processes, so a retry here is never
|
|
26
|
+
// waiting on a peer we would have serialized against anyway. POSIX never fails a
|
|
27
|
+
// rename this way, so retrying there would mask a genuinely different fault.
|
|
28
|
+
//
|
|
29
|
+
// The budget is bounded by withPathLocks' timeoutMs (2000ms default): these
|
|
30
|
+
// renames run while the lock is held, so a longer budget would trade a rare
|
|
31
|
+
// transient EPERM for common peer MutationLockErrors. 10 attempts with a 10ms
|
|
32
|
+
// linear backoff is ~450ms of sleep — comfortable headroom under 2s. Do not
|
|
33
|
+
// raise it past ~1s without also raising timeoutMs.
|
|
34
|
+
export function commitRename(from, to, testHooks) {
|
|
35
|
+
// Single seam for the platform check so the retry is reachable in tests on the
|
|
36
|
+
// POSIX machines that actually run them.
|
|
37
|
+
const windowsRenameSemantics = testHooks?.forceWindowsRenameSemantics ?? process.platform === 'win32';
|
|
38
|
+
for (let attempt = 0; ; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
const injected = testHooks?.forceRenameError?.(attempt);
|
|
41
|
+
if (injected) {
|
|
42
|
+
const error = new Error(`injected rename ${injected}`);
|
|
43
|
+
error.code = injected;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
renameSync(from, to);
|
|
47
|
+
return;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (!windowsRenameSemantics) throw err;
|
|
50
|
+
if (!RENAME_RETRY_CODES.includes(err?.code)) throw err;
|
|
51
|
+
if (attempt >= RENAME_RETRY_ATTEMPTS - 1) throw err;
|
|
52
|
+
Atomics.wait(sleepBuffer, 0, 0, RENAME_RETRY_BACKOFF_MS * (attempt + 1));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/git.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { commitRename } from './durable-rename.mjs';
|
|
5
6
|
|
|
6
7
|
// Best-effort `git check-ignore` for a path. Returns true only when git
|
|
7
8
|
// definitively reports the path is ignored; any failure (not a repo, git
|
|
@@ -675,7 +676,9 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
|
|
|
675
676
|
if (!sameGitIndexGeneration(current, expected)) throw new Error('Git index changed before transaction publication; current staging was preserved.');
|
|
676
677
|
testHooks.afterGitIndexCompare?.({ lockPath, current, desired });
|
|
677
678
|
if (desired.exists) {
|
|
678
|
-
|
|
679
|
+
// .git/index is routinely held open by concurrent git processes and IDE git
|
|
680
|
+
// extensions, so on Windows this publish needs the retry.
|
|
681
|
+
commitRename(lockPath, indexPath, testHooks);
|
|
679
682
|
lockOwned = false;
|
|
680
683
|
} else {
|
|
681
684
|
if (existsSync(indexPath)) unlinkSync(indexPath);
|
package/src/lifecycle.mjs
CHANGED
|
@@ -80,10 +80,20 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
|
|
|
80
80
|
const tracked = isTracked(filePath, config.repoRoot);
|
|
81
81
|
const gitIndex = tracked ? captureGitIndexGeneration(config.repoRoot) : null;
|
|
82
82
|
const companionPaths = new Set((options.additionalUpdates ?? []).map(item => path.resolve(item.path)));
|
|
83
|
-
const allFiles =
|
|
83
|
+
const allFiles = options.skipInboundRefs
|
|
84
|
+
? []
|
|
85
|
+
: collectDocFiles(config).filter(candidate => candidate !== filePath && candidate !== targetPath && !companionPaths.has(path.resolve(candidate)));
|
|
84
86
|
authorizeManagedSweep(allFiles, config, { kind: 'Reference rewrite source' });
|
|
85
87
|
const identities = createReferenceIdentitySet([filePath, ...allFiles]);
|
|
86
88
|
const referenceFields = configuredReferenceFields(config);
|
|
89
|
+
const additionalUpdates = options.skipInboundRefs
|
|
90
|
+
? (options.additionalUpdates ?? [])
|
|
91
|
+
: (options.additionalUpdates ?? []).map(item => ({
|
|
92
|
+
...item,
|
|
93
|
+
content: item.content === undefined ? undefined : rewriteDocumentReferences(item.content, {
|
|
94
|
+
sourcePath: item.path, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
|
|
95
|
+
}),
|
|
96
|
+
}));
|
|
87
97
|
const moveResult = moveFileAtomic(filePath, targetPath, sourceContent => {
|
|
88
98
|
result = render(sourceContent);
|
|
89
99
|
return result.content;
|
|
@@ -95,12 +105,7 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
|
|
|
95
105
|
render: raw => rewriteDocumentReferences(raw, {
|
|
96
106
|
sourcePath: docFile, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
|
|
97
107
|
}),
|
|
98
|
-
})), ...
|
|
99
|
-
...item,
|
|
100
|
-
content: item.content === undefined ? undefined : rewriteDocumentReferences(item.content, {
|
|
101
|
-
sourcePath: item.path, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
|
|
102
|
-
}),
|
|
103
|
-
}))],
|
|
108
|
+
})), ...additionalUpdates],
|
|
104
109
|
creations: options.creations ?? [],
|
|
105
110
|
gitMove: tracked,
|
|
106
111
|
gitIndex,
|
|
@@ -806,10 +811,13 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
806
811
|
if (config.indexPath && !noIndex && !opts.deferIndex) out.write(`${prefix} Would regenerate index\n`);
|
|
807
812
|
if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
|
|
808
813
|
|
|
809
|
-
// Preview reference updates
|
|
810
|
-
|
|
811
|
-
if (
|
|
812
|
-
|
|
814
|
+
// Preview reference updates unless the caller owns a non-referenceable,
|
|
815
|
+
// session-local document such as a saved prompt.
|
|
816
|
+
if (!opts.skipInboundRefs) {
|
|
817
|
+
const refCount = countRefsToUpdate(filePath, targetPath, config);
|
|
818
|
+
if (refCount > 0) {
|
|
819
|
+
out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
|
|
820
|
+
}
|
|
813
821
|
}
|
|
814
822
|
|
|
815
823
|
// Preview onArchive hook fire
|
|
@@ -825,6 +833,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
825
833
|
testHooks: opts.testHooks,
|
|
826
834
|
additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
|
|
827
835
|
creations: opts.creations,
|
|
836
|
+
skipInboundRefs: opts.skipInboundRefs,
|
|
828
837
|
bodyTransform: closeoutTemplate ? currentBody => {
|
|
829
838
|
committedCloseoutAction = planCloseoutInjection(currentBody);
|
|
830
839
|
return committedCloseoutAction.action === 'inject' ? committedCloseoutAction.newBody : currentBody;
|
package/src/prompts.mjs
CHANGED
|
@@ -270,7 +270,7 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
270
270
|
process.stderr.write(body);
|
|
271
271
|
if (!body.endsWith('\n')) process.stderr.write('\n');
|
|
272
272
|
process.stderr.write(`${dim('--->8---')}\n`);
|
|
273
|
-
runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr });
|
|
273
|
+
runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr, skipInboundRefs: true });
|
|
274
274
|
return;
|
|
275
275
|
}
|
|
276
276
|
|
|
@@ -284,6 +284,9 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
284
284
|
out: process.stderr,
|
|
285
285
|
testHooks: opts.testHooks,
|
|
286
286
|
deferIndex: Boolean(linkedClaim),
|
|
287
|
+
// Saved prompts are gitignored session state, never durable reference
|
|
288
|
+
// targets. Avoid locking and replanning the entire corpus on consumption.
|
|
289
|
+
skipInboundRefs: true,
|
|
287
290
|
additionalUpdates: linkedClaim?.prepared?.updates,
|
|
288
291
|
creations: linkedClaim?.prepared?.creations,
|
|
289
292
|
});
|