@williamthorsen/nmr 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.meta/changelog.json +22 -0
- package/.readyup/kits/default.js +1 -1
- package/.readyup/manifest.json +3 -3
- package/CHANGELOG.md +7 -0
- package/README.md +3 -6
- package/dist/esm/delegation/delegate-host.js +8 -1
- package/dist/esm/delegation/host-filesystem.d.ts +24 -0
- package/dist/esm/delegation/host-filesystem.js +45 -0
- package/dist/esm/delegation/remote-run.d.ts +12 -6
- package/dist/esm/delegation/remote-run.js +30 -8
- package/dist/esm/delegation/removeStaleCheckouts.d.ts +33 -0
- package/dist/esm/delegation/removeStaleCheckouts.js +98 -0
- package/dist/esm/delegation/user-config.d.ts +4 -0
- package/dist/esm/delegation/user-config.js +13 -3
- package/docs/delegate.md +22 -5
- package/package.json +2 -2
package/.meta/changelog.json
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"date": "2026-11-12",
|
|
5
|
+
"sections": [
|
|
6
|
+
{
|
|
7
|
+
"title": "🎉 Features",
|
|
8
|
+
"audience": "all",
|
|
9
|
+
"items": [
|
|
10
|
+
{
|
|
11
|
+
"description": "Makes each `nmr` delegation remove, before it pushes, the host's other checkouts that have gone unused for longer than the new user-config key `delegate.removeCheckoutsAfterDays` allows (7 days by default, `false` to keep them), while keeping any checkout whose lock has a live holder. (#1317)",
|
|
12
|
+
"hash": "529687fecf5111851cb369c5ee9554d9711dad62",
|
|
13
|
+
"entry": 1
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"description": "Adds to the failure message of a delegation step that fails with `No space left on device` whether the host's filesystem ran out of inodes or of space. (#1317)",
|
|
17
|
+
"hash": "529687fecf5111851cb369c5ee9554d9711dad62",
|
|
18
|
+
"entry": 2
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
2
24
|
{
|
|
3
25
|
"version": "2.1.0",
|
|
4
26
|
"date": "2026-11-12",
|
package/.readyup/kits/default.js
CHANGED
|
@@ -9662,7 +9662,7 @@ function hasSupportedStrictLintVersion() {
|
|
|
9662
9662
|
});
|
|
9663
9663
|
}
|
|
9664
9664
|
function getMinVersion() {
|
|
9665
|
-
const pickedFields = { "version": "2.
|
|
9665
|
+
const pickedFields = { "version": "2.2.0" };
|
|
9666
9666
|
if (typeof pickedFields["version"] !== "string") {
|
|
9667
9667
|
throw new TypeError("nmr/package.json: 'version' must be a string");
|
|
9668
9668
|
}
|
package/.readyup/manifest.json
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"checklist": "nmr",
|
|
32
32
|
"checks": [
|
|
33
33
|
"@williamthorsen/nmr in devDependencies",
|
|
34
|
-
"@williamthorsen/nmr >= 2.
|
|
34
|
+
"@williamthorsen/nmr >= 2.2.0",
|
|
35
35
|
"pnpm-workspace.yaml exists",
|
|
36
36
|
"catalogued dependencies use catalog:",
|
|
37
37
|
"package.json has packageManager field",
|
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
"path": "lib/workspace-build.ts"
|
|
236
236
|
},
|
|
237
237
|
{
|
|
238
|
-
"hash": "
|
|
238
|
+
"hash": "7015d006",
|
|
239
239
|
"kind": "inline",
|
|
240
240
|
"path": "../package.json",
|
|
241
241
|
"paths": [
|
|
@@ -363,7 +363,7 @@
|
|
|
363
363
|
"readyupVersion": "0.46.1",
|
|
364
364
|
"source": "kits/default.ts",
|
|
365
365
|
"sourceHash": "9fd61bf4",
|
|
366
|
-
"targetHash": "
|
|
366
|
+
"targetHash": "177bd0ca"
|
|
367
367
|
}
|
|
368
368
|
]
|
|
369
369
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 2.2.0 — 2026-11-12
|
|
6
|
+
|
|
7
|
+
### 🎉 Features
|
|
8
|
+
|
|
9
|
+
- Makes each `nmr` delegation remove, before it pushes, the host's other checkouts that have gone unused for longer than the new user-config key `delegate.removeCheckoutsAfterDays` allows (7 days by default, `false` to keep them), while keeping any checkout whose lock has a live holder. (#1317)
|
|
10
|
+
- Adds to the failure message of a delegation step that fails with `No space left on device` whether the host's filesystem ran out of inodes or of space. (#1317)
|
|
11
|
+
|
|
5
12
|
## 2.1.0 — 2026-11-12
|
|
6
13
|
|
|
7
14
|
### 🎉 Features
|
package/README.md
CHANGED
|
@@ -5,15 +5,12 @@
|
|
|
5
5
|
Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
|
|
6
6
|
|
|
7
7
|
<!-- section:release-notes -->
|
|
8
|
-
## Release notes — v2.
|
|
8
|
+
## Release notes — v2.2.0 (2026-11-12)
|
|
9
9
|
|
|
10
10
|
### 🎉 Features
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
### ⚡ Performance
|
|
15
|
-
|
|
16
|
-
- Drops pnpm's topological sort, which runs each package only after its workspace dependencies finish, from every `-R` and `-F` fan-out except one of `build`, so that `nmr test`, the `test:*` commands, and `nmr typecheck` start each package without waiting for its workspace dependencies. (#1306)
|
|
12
|
+
- Makes each `nmr` delegation remove, before it pushes, the host's other checkouts that have gone unused for longer than the new user-config key `delegate.removeCheckoutsAfterDays` allows (7 days by default, `false` to keep them), while keeping any checkout whose lock has a live holder. (#1317)
|
|
13
|
+
- Adds to the failure message of a delegation step that fails with `No space left on device` whether the host's filesystem ran out of inodes or of space. (#1317)
|
|
17
14
|
<!-- /section:release-notes -->
|
|
18
15
|
|
|
19
16
|
## Installation
|
|
@@ -13,7 +13,7 @@ import { createSnapshot } from "./createSnapshot.js";
|
|
|
13
13
|
import { chooseHost, probeHost } from "./remote-probe.js";
|
|
14
14
|
import { DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATE_TO_FLAG, DELEGATION_FAILURE_EXIT_CODE, deriveCheckoutPath, formatMisplacedDelegationWarning, readLastLine, runRemotely, } from "./remote-run.js";
|
|
15
15
|
import { resolveDelegationTarget } from "./resolveDelegationTarget.js";
|
|
16
|
-
import { formatDisplayPath, loadUserConfig } from "./user-config.js";
|
|
16
|
+
import { DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS, formatDisplayPath, loadUserConfig } from "./user-config.js";
|
|
17
17
|
export async function resolveExecutionTarget(invocation) {
|
|
18
18
|
const { command, context, env, parsedArgs, stderr, styles } = invocation;
|
|
19
19
|
if (parsedArgs.passthrough.some(isDelegationFlag)) {
|
|
@@ -69,6 +69,7 @@ export async function runPlanOnHost(plan, target) {
|
|
|
69
69
|
target,
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
72
73
|
const PROBE_RESULT_PHRASES = {
|
|
73
74
|
busy: 'is busy',
|
|
74
75
|
free: 'is free',
|
|
@@ -133,6 +134,7 @@ async function delegate(options) {
|
|
|
133
134
|
}
|
|
134
135
|
const { host } = choice;
|
|
135
136
|
const retained = { stderr: createBoundedBuffer(), stdout: createBoundedBuffer() };
|
|
137
|
+
const removeCheckoutsAfterMs = snapshot.ok ? await readRemoveCheckoutsAfterMs(env) : null;
|
|
136
138
|
const result = snapshot.ok
|
|
137
139
|
? await runRemotely({
|
|
138
140
|
command,
|
|
@@ -142,6 +144,7 @@ async function delegate(options) {
|
|
|
142
144
|
monorepoRoot: context.monorepoRoot,
|
|
143
145
|
nmrArgs: composeForwardedArgs(command, parsedArgs),
|
|
144
146
|
remoteEnv,
|
|
147
|
+
removeCheckoutsAfterMs,
|
|
145
148
|
snapshot,
|
|
146
149
|
stderr: createTee(stderr, retained.stderr),
|
|
147
150
|
stdout: createTee(stdout, retained.stdout),
|
|
@@ -178,6 +181,10 @@ async function delegate(options) {
|
|
|
178
181
|
function formatRunHereLine(command, listedHosts, reason) {
|
|
179
182
|
return `nmr: running ${command} here rather than on ${listedHosts}: ${reason}\n`;
|
|
180
183
|
}
|
|
184
|
+
async function readRemoveCheckoutsAfterMs(env) {
|
|
185
|
+
const days = (await loadUserConfig(env))?.config.delegate?.removeCheckoutsAfterDays ?? DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS;
|
|
186
|
+
return days === false ? null : days * MS_PER_DAY;
|
|
187
|
+
}
|
|
181
188
|
async function resolveHost(hosts, toplevel, env, stderr) {
|
|
182
189
|
const [first, ...rest] = hosts;
|
|
183
190
|
if (rest.length === 0) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appends to a failed step's message the resource that the host's filesystem has run out of, when the step's output
|
|
3
|
+
* reports a full filesystem and the counts that `runOnHost` reads say which. A write fails alike for lack of space and
|
|
4
|
+
* of inodes.
|
|
5
|
+
*/
|
|
6
|
+
export declare function appendFilesystemReason(message: string, output: string, runOnHost: (script: string) => Promise<{
|
|
7
|
+
exitCode: number;
|
|
8
|
+
output: string;
|
|
9
|
+
}>): Promise<string>;
|
|
10
|
+
/** Composes the shell command that prints, on the host, the free counts of the filesystem that holds its home. */
|
|
11
|
+
export declare function composeFilesystemCheckScript(): string;
|
|
12
|
+
/**
|
|
13
|
+
* Names the resource that the host's filesystem has run out of, from what `composeFilesystemCheckScript` printed, or
|
|
14
|
+
* returns `undefined` when neither is exhausted or the output does not include the counts. A filesystem that reports
|
|
15
|
+
* a total of zero inodes, as btrfs does, allocates them as it goes, so it cannot run out of them.
|
|
16
|
+
*/
|
|
17
|
+
export declare function describeExhaustedResource(output: string): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Runs on the host and prints, as one JSON line, the total and free inodes and the blocks available to this user on
|
|
20
|
+
* the filesystem that holds the home directory. It is sent as source, so it refers to nothing outside its own body.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export declare function readHostFilesystem(): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { quoteForShell } from "../helpers/quoteForShell.js";
|
|
2
|
+
export async function appendFilesystemReason(message, output, runOnHost) {
|
|
3
|
+
if (!FULL_FILESYSTEM_PATTERN.test(output))
|
|
4
|
+
return message;
|
|
5
|
+
const check = await runOnHost(composeFilesystemCheckScript());
|
|
6
|
+
const reason = check.exitCode === 0 ? describeExhaustedResource(check.output) : undefined;
|
|
7
|
+
return reason === undefined ? message : `${message}: ${reason}`;
|
|
8
|
+
}
|
|
9
|
+
export function composeFilesystemCheckScript() {
|
|
10
|
+
return `node -e ${quoteForShell(`(${readHostFilesystem.toString()})();`)}`;
|
|
11
|
+
}
|
|
12
|
+
export function describeExhaustedResource(output) {
|
|
13
|
+
const counts = output
|
|
14
|
+
.split('\n')
|
|
15
|
+
.map(parseCounts)
|
|
16
|
+
.findLast((value) => value !== undefined);
|
|
17
|
+
if (counts !== undefined && counts.files > 0 && counts.ffree === 0)
|
|
18
|
+
return "the host's filesystem has run out of inodes";
|
|
19
|
+
if (counts?.bavail === 0)
|
|
20
|
+
return "the host's filesystem has run out of space";
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
export function readHostFilesystem() {
|
|
24
|
+
const fs = process.getBuiltinModule('node:fs');
|
|
25
|
+
const os = process.getBuiltinModule('node:os');
|
|
26
|
+
const { bavail, ffree, files } = fs.statfsSync(os.homedir());
|
|
27
|
+
process.stdout.write(`${JSON.stringify({ bavail, ffree, files })}\n`);
|
|
28
|
+
}
|
|
29
|
+
const FULL_FILESYSTEM_PATTERN = /no space left on device|ENOSPC/i;
|
|
30
|
+
function parseCounts(line) {
|
|
31
|
+
try {
|
|
32
|
+
const value = JSON.parse(line);
|
|
33
|
+
if (typeof value !== 'object' || value === null)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (!('bavail' in value) || !('ffree' in value) || !('files' in value))
|
|
36
|
+
return undefined;
|
|
37
|
+
const { bavail, ffree, files } = value;
|
|
38
|
+
return typeof bavail === 'number' && typeof ffree === 'number' && typeof files === 'number'
|
|
39
|
+
? { bavail, ffree, files }
|
|
40
|
+
: undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Writable } from 'node:stream';
|
|
2
2
|
import { type OutputStyle } from '@williamthorsen/nmr-core';
|
|
3
|
+
/** The directory on the host, relative to its home directory, that contains one directory per checkout key. */
|
|
4
|
+
export declare const CHECKOUTS_DIR = ".cache/nmr/delegate";
|
|
3
5
|
/** Names the hosts to which nmr delegates a command when the invocation does not say otherwise. */
|
|
4
6
|
export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
|
|
5
7
|
/** The flag that runs the command on a host that `NMR_DELEGATE` or the user config names. */
|
|
@@ -56,9 +58,11 @@ export declare function composeGitSshCommand(env: NodeJS.ProcessEnv): string;
|
|
|
56
58
|
* @internal
|
|
57
59
|
*/
|
|
58
60
|
export declare function composeSshArgv(env: NodeJS.ProcessEnv, host: string, remoteCommand: string): string[];
|
|
61
|
+
/** Returns the key of the checkout on the host that serves the worktree at `toplevel` on the machine `hostname`. */
|
|
62
|
+
export declare function deriveCheckoutKey(hostname: string, toplevel: string): string;
|
|
59
63
|
/**
|
|
60
|
-
* Returns the checkout's path on the host, relative to its home directory:
|
|
61
|
-
* repository
|
|
64
|
+
* Returns the checkout's path on the host, relative to its home directory: the checkout's key, followed by the
|
|
65
|
+
* repository's own directory name, which nmr on the host reports as the root's scope.
|
|
62
66
|
*
|
|
63
67
|
* @internal
|
|
64
68
|
*/
|
|
@@ -74,11 +78,12 @@ export declare function readLastLine(output: string): string;
|
|
|
74
78
|
* Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
|
|
75
79
|
* `stdout` and `stderr` and returning its exit status.
|
|
76
80
|
*
|
|
77
|
-
* The host keeps one checkout per
|
|
81
|
+
* The host keeps one checkout per worktree on each delegating machine, under `~/.cache/nmr/delegate/`, so concurrent
|
|
78
82
|
* delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
|
|
79
|
-
* between runs.
|
|
80
|
-
*
|
|
81
|
-
*
|
|
83
|
+
* between runs. Before the push, the delegation removes the host's other checkouts that have not been used for
|
|
84
|
+
* longer than `removeCheckoutsAfterMs`, unless it is `null`. Check out, install, and run take one connection, whose
|
|
85
|
+
* session holds the checkout's lock, so that two delegations from one worktree run one after the other; what a phase
|
|
86
|
+
* other than `run` prints is returned rather than streamed, because it matters only when the phase fails.
|
|
82
87
|
*/
|
|
83
88
|
export declare function runRemotely(options: {
|
|
84
89
|
command: string;
|
|
@@ -88,6 +93,7 @@ export declare function runRemotely(options: {
|
|
|
88
93
|
monorepoRoot: string;
|
|
89
94
|
nmrArgs: readonly string[];
|
|
90
95
|
remoteEnv: Readonly<Record<string, string>>;
|
|
96
|
+
removeCheckoutsAfterMs: number | null;
|
|
91
97
|
snapshot: {
|
|
92
98
|
sha: string;
|
|
93
99
|
toplevel: string;
|
|
@@ -9,8 +9,11 @@ import { createBoundedBuffer } from "../helpers/createBoundedBuffer.js";
|
|
|
9
9
|
import { quoteForShell } from "../helpers/quoteForShell.js";
|
|
10
10
|
import { STALE_AFTER_MS } from "../machine-lock.js";
|
|
11
11
|
import { formatFlagSpelling } from "../nmr-flags.js";
|
|
12
|
+
import { appendFilesystemReason } from "./host-filesystem.js";
|
|
12
13
|
import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
|
|
13
14
|
import { composeSessionScript, createSessionDemultiplexer, formatDelegationWaitingLine } from "./remote-session.js";
|
|
15
|
+
import { CHECKOUT_STAMP_PATH, composeCheckoutRemovalScript } from "./removeStaleCheckouts.js";
|
|
16
|
+
export const CHECKOUTS_DIR = '.cache/nmr/delegate';
|
|
14
17
|
export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
|
|
15
18
|
export const DELEGATE_FLAG = formatFlagSpelling('delegate');
|
|
16
19
|
export const DELEGATE_TO_FLAG = formatFlagSpelling('delegateTo');
|
|
@@ -28,9 +31,11 @@ export function composeSshArgv(env, host, remoteCommand) {
|
|
|
28
31
|
? ['ssh', ...sshArgs]
|
|
29
32
|
: ['/bin/sh', '-c', `${sshCommand} "$@"`, sshCommand, ...sshArgs];
|
|
30
33
|
}
|
|
34
|
+
export function deriveCheckoutKey(hostname, toplevel) {
|
|
35
|
+
return createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
|
|
36
|
+
}
|
|
31
37
|
export function deriveCheckoutPath(hostname, toplevel) {
|
|
32
|
-
|
|
33
|
-
return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
|
|
38
|
+
return path.posix.join(CHECKOUTS_DIR, deriveCheckoutKey(hostname, toplevel), path.basename(toplevel));
|
|
34
39
|
}
|
|
35
40
|
export function formatMisplacedDelegationWarning(command, style) {
|
|
36
41
|
return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG}, ${DELEGATE_TO_FLAG}, and ${NO_DELEGATE_FLAG} after the ` +
|
|
@@ -44,15 +49,30 @@ export function readLastLine(output) {
|
|
|
44
49
|
.findLast((line) => line !== '') ?? '');
|
|
45
50
|
}
|
|
46
51
|
export async function runRemotely(options) {
|
|
47
|
-
const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
|
|
52
|
+
const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, removeCheckoutsAfterMs, snapshot, stderr, stdout, } = options;
|
|
48
53
|
const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
|
|
49
54
|
const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
|
|
50
|
-
|
|
55
|
+
function runOnHost(script) {
|
|
56
|
+
return runSsh(host, script, env);
|
|
57
|
+
}
|
|
58
|
+
const removal = composeCheckoutRemovalScript({
|
|
59
|
+
checkoutsDir: CHECKOUTS_DIR,
|
|
60
|
+
lockPath: CHECKOUT_LOCK_PATH,
|
|
61
|
+
ownKey: deriveCheckoutKey(os.hostname(), snapshot.toplevel),
|
|
62
|
+
removeAfterMs: removeCheckoutsAfterMs,
|
|
63
|
+
staleAfterMs: STALE_AFTER_MS,
|
|
64
|
+
stampPath: CHECKOUT_STAMP_PATH,
|
|
65
|
+
});
|
|
66
|
+
const prepare = await runSsh(host, `{ ${removal} || :; }; mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; } && ` +
|
|
67
|
+
`{ touch ${quoteForShell(CHECKOUT_STAMP_PATH)} || :; }`, env);
|
|
51
68
|
if (prepare.exitCode !== 0) {
|
|
69
|
+
const isUnreachable = prepare.exitCode === DELEGATION_FAILURE_EXIT_CODE;
|
|
52
70
|
return {
|
|
53
71
|
ok: false,
|
|
54
|
-
isUnreachable
|
|
55
|
-
message:
|
|
72
|
+
isUnreachable,
|
|
73
|
+
message: isUnreachable
|
|
74
|
+
? `could not prepare ${host}`
|
|
75
|
+
: await appendFilesystemReason(`could not prepare ${host}`, prepare.output, runOnHost),
|
|
56
76
|
output: prepare.output,
|
|
57
77
|
phase: 'prepare',
|
|
58
78
|
waitedMs: 0,
|
|
@@ -69,7 +89,8 @@ export async function runRemotely(options) {
|
|
|
69
89
|
`${snapshot.sha}:${SNAPSHOT_REF}`,
|
|
70
90
|
], { cwd: snapshot.toplevel, env: { ...env, [SSH_COMMAND_ENV_VAR]: composeGitSshCommand(env) } });
|
|
71
91
|
if (push.exitCode !== 0) {
|
|
72
|
-
|
|
92
|
+
const message = await appendFilesystemReason(`could not push to ${host}`, push.output, runOnHost);
|
|
93
|
+
return { ok: false, message, output: push.output, phase: 'push', waitedMs: 0 };
|
|
73
94
|
}
|
|
74
95
|
const nonce = randomUUID();
|
|
75
96
|
const marker = `__NMR_DELEGATE_${nonce}__`;
|
|
@@ -127,7 +148,8 @@ export async function runRemotely(options) {
|
|
|
127
148
|
demultiplexer.flush();
|
|
128
149
|
if (failure !== undefined) {
|
|
129
150
|
const output = [demultiplexer.readOutput(), failure.output].filter((text) => text !== '').join('\n');
|
|
130
|
-
|
|
151
|
+
const message = await appendFilesystemReason(describeFailedPhase(failure, host), output, runOnHost);
|
|
152
|
+
return { ok: false, message, output, phase: failure.phase, waitedMs };
|
|
131
153
|
}
|
|
132
154
|
if (!demultiplexer.hasStarted()) {
|
|
133
155
|
return {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The file whose mtime records a checkout's last use, relative to the checkout. */
|
|
2
|
+
export declare const CHECKOUT_STAMP_PATH = ".git/nmr-delegate-used";
|
|
3
|
+
/** What the removal needs, sent to it as JSON. */
|
|
4
|
+
export interface CheckoutRemovalParams {
|
|
5
|
+
/** The directory that contains one directory per checkout key, relative to the host's home directory. */
|
|
6
|
+
checkoutsDir: string;
|
|
7
|
+
/** The checkout's lock file, relative to the checkout. */
|
|
8
|
+
lockPath: string;
|
|
9
|
+
/** The key of the delegation's own checkout, which is never removed. */
|
|
10
|
+
ownKey: string;
|
|
11
|
+
/** Age past which an unused checkout is removed, or `null` to keep every checkout. */
|
|
12
|
+
removeAfterMs: number | null;
|
|
13
|
+
/** Age at which an unrefreshed lock file is abandoned. */
|
|
14
|
+
staleAfterMs: number;
|
|
15
|
+
/** The file whose mtime records the checkout's last use, relative to the checkout. */
|
|
16
|
+
stampPath: string;
|
|
17
|
+
}
|
|
18
|
+
/** Composes the shell command that runs `removeStaleCheckouts` on the host. */
|
|
19
|
+
export declare function composeCheckoutRemovalScript(params: CheckoutRemovalParams): string;
|
|
20
|
+
/**
|
|
21
|
+
* Runs on the host and removes every checkout other than the delegation's own that has not been used for longer than
|
|
22
|
+
* `removeAfterMs` and whose lock does not have a live holder. A checkout's last use is the newest mtime of its stamp,
|
|
23
|
+
* `.git/index`, and `.git/HEAD`, so that a checkout made by an nmr that does not write the stamp still has one.
|
|
24
|
+
*
|
|
25
|
+
* Each checkout is renamed at once to a `.removing-` sibling, which needs no new inode, so that removal works on a
|
|
26
|
+
* filesystem without any free inodes and a delegation that starts later creates a fresh checkout. A detached `rm`
|
|
27
|
+
* then deletes every `.removing-` sibling, including those that an interrupted removal left, so that the delegation
|
|
28
|
+
* does not wait for it. An entry that cannot be read or moved is skipped. It is sent as source, so it refers to
|
|
29
|
+
* nothing outside its own body.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export declare function removeStaleCheckouts(params: CheckoutRemovalParams): void;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { quoteForShell } from "../helpers/quoteForShell.js";
|
|
2
|
+
export const CHECKOUT_STAMP_PATH = '.git/nmr-delegate-used';
|
|
3
|
+
export function composeCheckoutRemovalScript(params) {
|
|
4
|
+
const source = `(${removeStaleCheckouts.toString()})(JSON.parse(process.argv[1]));`;
|
|
5
|
+
return `node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`;
|
|
6
|
+
}
|
|
7
|
+
export function removeStaleCheckouts(params) {
|
|
8
|
+
const fs = process.getBuiltinModule('node:fs');
|
|
9
|
+
const childProcess = process.getBuiltinModule('node:child_process');
|
|
10
|
+
const crypto = process.getBuiltinModule('node:crypto');
|
|
11
|
+
const os = process.getBuiltinModule('node:os');
|
|
12
|
+
const path = process.getBuiltinModule('node:path');
|
|
13
|
+
const removeAfterMs = params.removeAfterMs;
|
|
14
|
+
if (removeAfterMs === null)
|
|
15
|
+
return;
|
|
16
|
+
const rootDir = path.join(os.homedir(), params.checkoutsDir);
|
|
17
|
+
const REMOVING_PREFIX = '.removing-';
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
for (const key of readNames(rootDir)) {
|
|
20
|
+
if (key.startsWith('.') || key === params.ownKey)
|
|
21
|
+
continue;
|
|
22
|
+
const keyDir = path.join(rootDir, key);
|
|
23
|
+
const checkouts = readNames(keyDir);
|
|
24
|
+
if (checkouts.length !== 1)
|
|
25
|
+
continue;
|
|
26
|
+
const checkoutDir = path.join(keyDir, checkouts[0] ?? '');
|
|
27
|
+
const lastUsedMs = readLastUsedMs(checkoutDir);
|
|
28
|
+
if (lastUsedMs === undefined || now - lastUsedMs <= removeAfterMs)
|
|
29
|
+
continue;
|
|
30
|
+
if (isLockHeld(path.join(checkoutDir, params.lockPath)))
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
fs.renameSync(keyDir, path.join(rootDir, `${REMOVING_PREFIX}${crypto.randomUUID()}`));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const moved = readNames(rootDir)
|
|
39
|
+
.filter((name) => name.startsWith(REMOVING_PREFIX))
|
|
40
|
+
.map((name) => path.join(rootDir, name));
|
|
41
|
+
if (moved.length > 0) {
|
|
42
|
+
childProcess.spawn('rm', ['-rf', ...moved], { detached: true, stdio: 'ignore' }).unref();
|
|
43
|
+
}
|
|
44
|
+
function isLockHeld(lockPath) {
|
|
45
|
+
let content;
|
|
46
|
+
let mtimeMs;
|
|
47
|
+
try {
|
|
48
|
+
content = fs.readFileSync(lockPath, 'utf8');
|
|
49
|
+
({ mtimeMs } = fs.statSync(lockPath));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (Date.now() - mtimeMs > params.staleAfterMs)
|
|
55
|
+
return false;
|
|
56
|
+
const pid = readHolderPid(content);
|
|
57
|
+
return pid === undefined || !isProcessGone(pid);
|
|
58
|
+
}
|
|
59
|
+
function isProcessGone(pid) {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function readHolderPid(content) {
|
|
69
|
+
try {
|
|
70
|
+
const value = JSON.parse(content);
|
|
71
|
+
if (typeof value !== 'object' || value === null || !('pid' in value))
|
|
72
|
+
return undefined;
|
|
73
|
+
return typeof value.pid === 'number' ? value.pid : undefined;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function readLastUsedMs(checkoutDir) {
|
|
80
|
+
let newestMs;
|
|
81
|
+
for (const file of [params.stampPath, '.git/index', '.git/HEAD']) {
|
|
82
|
+
try {
|
|
83
|
+
newestMs = Math.max(newestMs ?? 0, fs.statSync(path.join(checkoutDir, file)).mtimeMs);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return newestMs;
|
|
89
|
+
}
|
|
90
|
+
function readNames(dir) {
|
|
91
|
+
try {
|
|
92
|
+
return fs.readdirSync(dir);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -2,6 +2,8 @@ import type { HostList } from './remote-run.js';
|
|
|
2
2
|
/** The values that `delegate.when` accepts, each naming the runs that delegate without being asked. */
|
|
3
3
|
declare const DELEGATE_WHEN_VALUES: readonly ["agent", "always"];
|
|
4
4
|
export type DelegateWhen = (typeof DELEGATE_WHEN_VALUES)[number];
|
|
5
|
+
/** The age, in days, past which a delegation removes a host's unused checkouts when the user config does not set one. */
|
|
6
|
+
export declare const DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS = 7;
|
|
5
7
|
/**
|
|
6
8
|
* Renders a path for a message, with `~` standing for the home directory that `env` names when the path lies under
|
|
7
9
|
* it.
|
|
@@ -30,6 +32,8 @@ export interface UserConfig {
|
|
|
30
32
|
/** This machine's delegation hosts, and which runs delegate to them without being asked. */
|
|
31
33
|
export interface UserDelegateConfig {
|
|
32
34
|
hosts: HostList;
|
|
35
|
+
/** The age, in days, past which a delegation removes the host's unused checkouts; `false` keeps them. */
|
|
36
|
+
removeCheckoutsAfterDays?: number | false;
|
|
33
37
|
when?: DelegateWhen;
|
|
34
38
|
}
|
|
35
39
|
export {};
|
|
@@ -5,6 +5,7 @@ import { assertRecognizedKeys } from "../config-validation.js";
|
|
|
5
5
|
import { importConfigDefault } from "../helpers/importConfigDefault.js";
|
|
6
6
|
import { resolveXdgBaseDir } from "../helpers/resolveXdgBaseDir.js";
|
|
7
7
|
const DELEGATE_WHEN_VALUES = ['agent', 'always'];
|
|
8
|
+
export const DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS = 7;
|
|
8
9
|
export function formatDisplayPath(filePath, env) {
|
|
9
10
|
const home = env['HOME'];
|
|
10
11
|
if (home === undefined || !path.isAbsolute(home)) {
|
|
@@ -30,7 +31,7 @@ export function resolveUserConfigPath(env) {
|
|
|
30
31
|
}
|
|
31
32
|
const USER_CONFIG_RELATIVE_PATH = path.join('nmr', 'config.ts');
|
|
32
33
|
const RECOGNIZED_KEYS = ['delegate'];
|
|
33
|
-
const RECOGNIZED_DELEGATE_KEYS = ['hosts', 'when'];
|
|
34
|
+
const RECOGNIZED_DELEGATE_KEYS = ['hosts', 'removeCheckoutsAfterDays', 'when'];
|
|
34
35
|
function isDelegateWhen(value) {
|
|
35
36
|
const values = DELEGATE_WHEN_VALUES;
|
|
36
37
|
return values.includes(value);
|
|
@@ -45,16 +46,18 @@ function validateDelegateField(value, configPath) {
|
|
|
45
46
|
}
|
|
46
47
|
assertRecognizedKeys(delegate, RECOGNIZED_DELEGATE_KEYS, configPath, 'delegate.');
|
|
47
48
|
const hosts = validateHosts(delegate['hosts'], configPath);
|
|
49
|
+
const removeCheckoutsAfterDays = validateRemoveCheckoutsAfterDays(delegate['removeCheckoutsAfterDays'], configPath);
|
|
50
|
+
const removal = removeCheckoutsAfterDays === undefined ? {} : { removeCheckoutsAfterDays };
|
|
48
51
|
const when = delegate['when'];
|
|
49
52
|
if (when === undefined) {
|
|
50
|
-
return { hosts };
|
|
53
|
+
return { hosts, ...removal };
|
|
51
54
|
}
|
|
52
55
|
if (!isDelegateWhen(when)) {
|
|
53
56
|
const renderedValue = typeof when === 'string' ? when : JSON.stringify(when);
|
|
54
57
|
throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.when\` is \`${renderedValue}\`, which is not one of: ` +
|
|
55
58
|
DELEGATE_WHEN_VALUES.join(', '));
|
|
56
59
|
}
|
|
57
|
-
return { hosts, when };
|
|
60
|
+
return { hosts, ...removal, when };
|
|
58
61
|
}
|
|
59
62
|
function validateHosts(value, configPath) {
|
|
60
63
|
const names = Array.isArray(value) && value.every((host) => typeof host === 'string')
|
|
@@ -67,6 +70,13 @@ function validateHosts(value, configPath) {
|
|
|
67
70
|
}
|
|
68
71
|
return [first, ...rest];
|
|
69
72
|
}
|
|
73
|
+
function validateRemoveCheckoutsAfterDays(value, configPath) {
|
|
74
|
+
if (value === undefined || value === false || (typeof value === 'number' && Number.isFinite(value) && value > 0)) {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.removeCheckoutsAfterDays\` must be a positive number of days, ` +
|
|
78
|
+
'or `false` to keep every checkout');
|
|
79
|
+
}
|
|
70
80
|
function validateUserConfig(value, configPath) {
|
|
71
81
|
if (!isRecord(value)) {
|
|
72
82
|
throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
|
package/docs/delegate.md
CHANGED
|
@@ -12,18 +12,19 @@ A machine that runs several worktrees' checks at once runs out of memory, and th
|
|
|
12
12
|
|
|
13
13
|
## The user config
|
|
14
14
|
|
|
15
|
-
`~/.config/nmr/config.ts`, or `$XDG_CONFIG_HOME/nmr/config.ts` when `XDG_CONFIG_HOME` is set, declares this machine's hosts
|
|
15
|
+
`~/.config/nmr/config.ts`, or `$XDG_CONFIG_HOME/nmr/config.ts` when `XDG_CONFIG_HOME` is set, declares this machine's hosts, when to delegate to them without being asked, and how long a host keeps an unused checkout. It honors one key, `delegate`:
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
18
|
export default {
|
|
19
|
-
delegate: { hosts: ['imac2', 'imac3'], when: 'agent' },
|
|
19
|
+
delegate: { hosts: ['imac2', 'imac3'], removeCheckoutsAfterDays: 14, when: 'agent' },
|
|
20
20
|
};
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
- **`hosts`** lists the hosts in order of preference. It is required whenever `delegate` is present.
|
|
24
|
+
- **`removeCheckoutsAfterDays`** sets how long a checkout on a host may go unused before a delegation from this machine removes it, in days; `false` keeps every checkout. It defaults to 7. See [unused checkouts](#unused-checkouts).
|
|
24
25
|
- **`when`** turns on delegation by default: `'agent'` for every run whose environment contains an agent-harness marker, the variables that also select [quiet output](reporting.md#configuring), extended by the repo's `output.extraAgentEnvVars`; `'always'` for every run. Without `when`, nmr delegates only on request, and `hosts` serves `--delegate`.
|
|
25
26
|
|
|
26
|
-
nmr reads the file
|
|
27
|
+
nmr reads the file when a run reaches rule 7 of [choosing where a command runs](#choosing-where-a-command-runs), and for every delegation, however it was requested, to learn `removeCheckoutsAfterDays`; a run that an earlier rule keeps here does not read it. An unrecognized key, a malformed `hosts`, or an unknown `when` fails that run with an error naming the file and the key. The file cannot import anything from a repo, so it is a plain object rather than a `defineConfig` call. When a directory above it contains a `package.json` without `"type"`, such as a stray `~/package.json`, Node warns on every load that the file's module type is not specified; a `~/.config/nmr/package.json` containing `{ "type": "module" }` silences it.
|
|
27
28
|
|
|
28
29
|
A pass that a host earns satisfies a later delegation to that host, but not a run here: Under `when`, a run that `--no-delegate` keeps here re-runs a check that the host already passed. See [consulting the cache first](#consulting-the-cache-first).
|
|
29
30
|
|
|
@@ -100,11 +101,21 @@ $ nmr --delegate-to imac2 ci
|
|
|
100
101
|
|
|
101
102
|
nmr records the working tree as it is on disk as a commit: tracked changes, staged or not, and untracked files that git does not ignore. This is the tree that a local run checks. The commit is built in a temporary index, so the repository's index, refs, and stash do not change.
|
|
102
103
|
|
|
103
|
-
nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each
|
|
104
|
+
nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each worktree on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. When the workspace packages' `bin` entries differ from those of the checkout's last successful install, nmr first removes pnpm's workspace state, so that the install links the new bins, which an unchanged lockfile would otherwise let pnpm skip; that install reruns the workspace's `prepare` scripts.
|
|
104
105
|
|
|
105
106
|
- **Ignored files stay here.** A command that needs a gitignored file, such as a local `.env`, does not find it on the host.
|
|
106
107
|
- **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. A delegation flag refuses such a tree; under `NMR_DELEGATE` or the user config, the command runs here, and nmr prints one line saying why.
|
|
107
108
|
|
|
109
|
+
### Unused checkouts
|
|
110
|
+
|
|
111
|
+
A worktree adds a checkout on each host to which it delegates, with its own `node_modules`, so the checkouts accumulate as worktrees come and go. Before it pushes, each delegation removes the host's other checkouts that have not been used for longer than the delegating machine's `removeCheckoutsAfterDays`, whichever machine created them:
|
|
112
|
+
|
|
113
|
+
- **A checkout's last use** is the last delegation to it. A checkout made by an nmr that does not record its use is dated by its last check out.
|
|
114
|
+
- **A checkout in use is kept**: one whose lock, described below, has a live holder, whatever its age. The delegation's own checkout is never removed.
|
|
115
|
+
- **The delegation does not wait for the deletion.** It moves each checkout aside at once and deletes it in the background, so a delegation that starts later creates a fresh checkout and installs it from scratch.
|
|
116
|
+
- **The removal runs before the push**, so a host whose filesystem the checkouts have filled recovers without anyone deleting files by hand: The delegation that removes the checkouts may still fail, and the next one finds the room. Removal never fails a delegation.
|
|
117
|
+
- **Each machine applies its own age.** A machine whose user config says `false` removes nothing, while another machine delegating to the same host still removes the checkouts that are older than its own age.
|
|
118
|
+
|
|
108
119
|
Delegations from the same worktree share its checkout on the host, so they run one at a time. A delegation that starts while another holds the checkout waits for it, saying so on stderr, then runs:
|
|
109
120
|
|
|
110
121
|
```console
|
|
@@ -128,7 +139,13 @@ A failure of the delegation itself, rather than of the command, exits 255, the s
|
|
|
128
139
|
❌ my-repo: ci: failed on 📡 imac2 in 10.0s (exit 255) — could not prepare imac2
|
|
129
140
|
```
|
|
130
141
|
|
|
131
|
-
The steps are `prepare`, which reaches the host and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
|
|
142
|
+
The steps are `prepare`, which reaches the host, removes its [unused checkouts](#unused-checkouts), and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
|
|
143
|
+
|
|
144
|
+
A step that fails with `No space left on device` reports which resource the host's filesystem has run out of, because a write fails the same way for both:
|
|
145
|
+
|
|
146
|
+
```console
|
|
147
|
+
❌ my-repo: ci: failed on 📡 imac2 in 3.1s (exit 255) — could not push to imac2: the host's filesystem has run out of inodes
|
|
148
|
+
```
|
|
132
149
|
|
|
133
150
|
### When no host answers
|
|
134
151
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/nmr",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for pnpm monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@williamthorsen/toolbelt.errors": "0.10.0",
|
|
85
85
|
"@williamthorsen/toolbelt.filesystem": "0.10.2",
|
|
86
86
|
"glob": "14.0.4",
|
|
87
|
-
"prettier": "3.
|
|
87
|
+
"prettier": "3.10.0",
|
|
88
88
|
"prettier-plugin-sh": "0.20.2",
|
|
89
89
|
"taze": "21.3.0",
|
|
90
90
|
"unconfig": "7.5.0"
|