@williamthorsen/nmr 0.48.0 → 0.49.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.
Files changed (111) hide show
  1. package/.meta/changelog.json +158 -0
  2. package/.readyup/kits/default.js +1527 -1460
  3. package/.readyup/manifest.json +96 -11
  4. package/CHANGELOG.md +41 -0
  5. package/README.md +36 -8
  6. package/agents/guidance/rulebooks/nmr.md +2 -2
  7. package/dist/esm/check-cache-build-output.d.ts +24 -0
  8. package/dist/esm/check-cache-build-output.js +49 -0
  9. package/dist/esm/check-cache-delegation.d.ts +27 -0
  10. package/dist/esm/check-cache-delegation.js +92 -0
  11. package/dist/esm/check-cache-entries.d.ts +109 -0
  12. package/dist/esm/check-cache-entries.js +87 -0
  13. package/dist/esm/check-cache-gate.d.ts +64 -0
  14. package/dist/esm/check-cache-gate.js +226 -0
  15. package/dist/esm/check-cache-key.d.ts +47 -0
  16. package/dist/esm/check-cache-key.js +77 -0
  17. package/dist/esm/check-cache-record.d.ts +69 -0
  18. package/dist/esm/check-cache-record.js +167 -0
  19. package/dist/esm/check-cache.d.ts +8 -176
  20. package/dist/esm/check-cache.js +9 -214
  21. package/dist/esm/child-env.d.ts +57 -0
  22. package/dist/esm/child-env.js +59 -0
  23. package/dist/esm/child-process.d.ts +55 -0
  24. package/dist/esm/child-process.js +112 -0
  25. package/dist/esm/commands/build.d.ts +0 -16
  26. package/dist/esm/commands/build.js +9 -214
  27. package/dist/esm/commands/clean.js +3 -2
  28. package/dist/esm/commands/compiler.d.ts +18 -0
  29. package/dist/esm/commands/compiler.js +49 -0
  30. package/dist/esm/commands/fmt.d.ts +0 -39
  31. package/dist/esm/commands/fmt.js +4 -81
  32. package/dist/esm/commands/format-targets.d.ts +39 -0
  33. package/dist/esm/commands/format-targets.js +83 -0
  34. package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
  35. package/dist/esm/commands/rewrite-specifiers.js +122 -0
  36. package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
  37. package/dist/esm/commands/tsconfig-chain.js +63 -0
  38. package/dist/esm/config-check-cache.d.ts +17 -0
  39. package/dist/esm/config-check-cache.js +63 -0
  40. package/dist/esm/config-validation.d.ts +20 -0
  41. package/dist/esm/config-validation.js +236 -0
  42. package/dist/esm/config.js +4 -286
  43. package/dist/esm/crossing-diagnostics.d.ts +39 -0
  44. package/dist/esm/crossing-diagnostics.js +132 -0
  45. package/dist/esm/default-scripts.js +1 -1
  46. package/dist/esm/delegate-host.d.ts +43 -0
  47. package/dist/esm/delegate-host.js +178 -0
  48. package/dist/esm/delegation-target.d.ts +52 -0
  49. package/dist/esm/delegation-target.js +71 -0
  50. package/dist/esm/fan-out.d.ts +57 -0
  51. package/dist/esm/fan-out.js +146 -0
  52. package/dist/esm/glyphs.d.ts +1 -1
  53. package/dist/esm/help.js +9 -3
  54. package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
  55. package/dist/esm/helpers/deriveExcerpt.js +1 -1
  56. package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
  57. package/dist/esm/helpers/renderQuotedList.js +9 -0
  58. package/dist/esm/hooks.d.ts +17 -0
  59. package/dist/esm/hooks.js +23 -0
  60. package/dist/esm/invocation-args.d.ts +40 -0
  61. package/dist/esm/invocation-args.js +178 -0
  62. package/dist/esm/invocation.d.ts +67 -0
  63. package/dist/esm/invocation.js +3 -0
  64. package/dist/esm/machine-lock.d.ts +2 -0
  65. package/dist/esm/machine-lock.js +1 -1
  66. package/dist/esm/output-channel.d.ts +23 -0
  67. package/dist/esm/output-channel.js +14 -0
  68. package/dist/esm/pass-manifest.d.ts +53 -0
  69. package/dist/esm/pass-manifest.js +89 -0
  70. package/dist/esm/presentation.d.ts +47 -0
  71. package/dist/esm/presentation.js +32 -0
  72. package/dist/esm/recording.d.ts +1 -1
  73. package/dist/esm/recording.js +2 -2
  74. package/dist/esm/remote-host-session.d.ts +62 -0
  75. package/dist/esm/remote-host-session.js +277 -0
  76. package/dist/esm/remote-probe-host.d.ts +22 -0
  77. package/dist/esm/remote-probe-host.js +50 -0
  78. package/dist/esm/remote-probe.d.ts +31 -0
  79. package/dist/esm/remote-probe.js +78 -0
  80. package/dist/esm/remote-run.d.ts +28 -73
  81. package/dist/esm/remote-run.js +103 -165
  82. package/dist/esm/remote-session.d.ts +40 -0
  83. package/dist/esm/remote-session.js +151 -0
  84. package/dist/esm/remote-snapshot.d.ts +21 -0
  85. package/dist/esm/remote-snapshot.js +80 -0
  86. package/dist/esm/replay-assembly.d.ts +2 -2
  87. package/dist/esm/replay-assembly.js +1 -1
  88. package/dist/esm/run-chain.d.ts +21 -0
  89. package/dist/esm/run-chain.js +108 -0
  90. package/dist/esm/runCli.d.ts +1 -9
  91. package/dist/esm/runCli.js +39 -1002
  92. package/dist/esm/runner.d.ts +1 -54
  93. package/dist/esm/runner.js +2 -125
  94. package/dist/esm/shell-syntax.d.ts +30 -0
  95. package/dist/esm/shell-syntax.js +120 -0
  96. package/dist/esm/steps.d.ts +0 -2
  97. package/dist/esm/steps.js +3 -119
  98. package/dist/esm/types.d.ts +5 -0
  99. package/dist/esm/user-config.d.ts +34 -0
  100. package/dist/esm/user-config.js +93 -0
  101. package/dist/esm/verbosity.d.ts +6 -0
  102. package/dist/esm/verbosity.js +7 -7
  103. package/dist/esm/verdict.d.ts +1 -1
  104. package/dist/esm/verdict.js +1 -1
  105. package/docs/check-cache.md +28 -12
  106. package/docs/delegate.md +96 -16
  107. package/docs/reporting.md +6 -1
  108. package/docs/scripts.md +2 -1
  109. package/package.json +3 -3
  110. /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
  111. /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
@@ -0,0 +1,31 @@
1
+ import { type HostList } from './remote-run.js';
2
+ /** What a probe found on a host: whether a delegation started now would wait there, or that the host did not answer. */
3
+ export type ProbeResult = 'busy' | 'free' | 'no-answer';
4
+ /**
5
+ * Probes every host at once and resolves to the one on which to run, per `selectHost`, as soon as the results that
6
+ * have arrived settle it. Reports each result that arrives before then, and aborts the probes still running once it
7
+ * has chosen.
8
+ */
9
+ export declare function chooseHost(options: {
10
+ hosts: HostList;
11
+ onSettled: (host: string, result: ProbeResult) => void;
12
+ probe: (host: string, signal: AbortSignal) => Promise<ProbeResult>;
13
+ }): Promise<string>;
14
+ /**
15
+ * Asks `host` whether a delegation from this checkout would wait there: whether its machine lock, or the lock of the
16
+ * checkout at `checkoutPath`, has a live holder. A host that fails to run the probe, or does not print its report,
17
+ * did not answer.
18
+ */
19
+ export declare function probeHost(options: {
20
+ checkoutPath: string;
21
+ env: NodeJS.ProcessEnv;
22
+ host: string;
23
+ signal: AbortSignal;
24
+ }): Promise<ProbeResult>;
25
+ /**
26
+ * Returns the host on which to run, or `undefined` while a host whose result could change the choice has not
27
+ * answered: the first free host in list order, otherwise the first busy one, otherwise the first host.
28
+ *
29
+ * @internal
30
+ */
31
+ export declare function selectHost(hosts: HostList, results: ReadonlyMap<string, ProbeResult>): string | undefined;
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import { quoteForShell } from "./helpers/quoteForShell.js";
3
+ import { isObject } from "./helpers/type-guards.js";
4
+ import { LOCK_DIR_ENV_VAR, LOCK_FILE, STALE_AFTER_MS } from "./machine-lock.js";
5
+ import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
6
+ import { probeHostLocks } from "./remote-probe-host.js";
7
+ import { runSsh } from "./remote-run.js";
8
+ export function chooseHost(options) {
9
+ const { hosts, onSettled, probe } = options;
10
+ const controller = new AbortController();
11
+ const results = new Map();
12
+ return new Promise((resolve) => {
13
+ for (const host of hosts) {
14
+ void settle(host, resolve);
15
+ }
16
+ });
17
+ async function settle(host, resolve) {
18
+ let result;
19
+ try {
20
+ result = await probe(host, controller.signal);
21
+ }
22
+ catch {
23
+ result = 'no-answer';
24
+ }
25
+ if (controller.signal.aborted)
26
+ return;
27
+ results.set(host, result);
28
+ onSettled(host, result);
29
+ const chosen = selectHost(hosts, results);
30
+ if (chosen === undefined)
31
+ return;
32
+ controller.abort();
33
+ resolve(chosen);
34
+ }
35
+ }
36
+ export async function probeHost(options) {
37
+ const { checkoutPath, env, host, signal } = options;
38
+ const params = {
39
+ checkoutLockPath: path.posix.join(checkoutPath, CHECKOUT_LOCK_PATH),
40
+ lockDirEnvVar: LOCK_DIR_ENV_VAR,
41
+ machineLockFile: LOCK_FILE,
42
+ staleAfterMs: STALE_AFTER_MS,
43
+ };
44
+ const source = `(${probeHostLocks.toString()})(JSON.parse(process.argv[1]));`;
45
+ const { exitCode, output } = await runSsh(host, `exec node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`, env, signal);
46
+ if (exitCode !== 0) {
47
+ return 'no-answer';
48
+ }
49
+ const busy = output
50
+ .split('\n')
51
+ .map(readBusyField)
52
+ .findLast((value) => value !== undefined);
53
+ if (busy === undefined) {
54
+ return 'no-answer';
55
+ }
56
+ return busy ? 'busy' : 'free';
57
+ }
58
+ export function selectHost(hosts, results) {
59
+ for (const host of hosts) {
60
+ const result = results.get(host);
61
+ if (result === undefined) {
62
+ return undefined;
63
+ }
64
+ if (result === 'free') {
65
+ return host;
66
+ }
67
+ }
68
+ return hosts.find((host) => results.get(host) === 'busy') ?? hosts[0];
69
+ }
70
+ function readBusyField(line) {
71
+ try {
72
+ const value = JSON.parse(line);
73
+ return isObject(value) && typeof value['busy'] === 'boolean' ? value['busy'] : undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
@@ -1,78 +1,38 @@
1
1
  import type { Writable } from 'node:stream';
2
2
  import { type OutputStyle } from '@williamthorsen/nmr-core';
3
- /** Names the host to which nmr delegates a command when the invocation does not say otherwise. */
3
+ /** Names the hosts to which nmr delegates a command when the invocation does not say otherwise. */
4
4
  export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
5
- /** The flag that names the host on which to run the command. */
5
+ /** The flag that runs the command on a host that `NMR_DELEGATE` or the user config names. */
6
6
  export declare const DELEGATE_FLAG = "--delegate";
7
+ /** The flag that names the hosts on which to run the command. */
8
+ export declare const DELEGATE_TO_FLAG = "--delegate-to";
7
9
  /** The exit status of a delegation that failed outside its command, which matches `ssh`'s own. */
8
10
  export declare const DELEGATION_FAILURE_EXIT_CODE = 255;
9
- /** The flag that runs the command here, whatever `NMR_DELEGATE` names. */
11
+ /** The flag that runs the command here, whatever `NMR_DELEGATE` or the user config says. */
10
12
  export declare const NO_DELEGATE_FLAG = "--no-delegate";
11
13
  /** Keeps `ssh` from prompting or waiting on a terminal, and bounds the wait for a host that does not answer. */
12
14
  export declare const SSH_OPTIONS: readonly string[];
13
- /** Where a delegation runs, or why the invocation cannot run where it asked to. */
14
- export type DelegationTarget = {
15
- kind: 'local';
16
- } | {
17
- kind: 'remote';
18
- host: string;
19
- source: 'env' | 'flag';
20
- } | {
21
- kind: 'refused';
22
- error: string;
23
- };
15
+ /** Candidate hosts in order of preference. */
16
+ export type HostList = readonly [string, ...string[]];
24
17
  /** The phases of a delegation that run on the host, each reported by name when it fails. */
25
18
  export type RemotePhase = 'check out' | 'install' | 'prepare' | 'push' | 'run';
26
- /** The status of a delegated command, or the phase that kept it from finishing and what that phase printed. */
19
+ /**
20
+ * The status of a delegated command and the pass-manifest entries that its run reported, unvalidated, or the phase
21
+ * that kept it from finishing and what that phase printed, with how long the delegation waited for another
22
+ * delegation's checkout.
23
+ */
27
24
  export type RemoteRunResult = {
28
25
  ok: true;
29
26
  exitCode: number;
27
+ passes: unknown[];
28
+ waitedMs: number;
30
29
  } | {
31
30
  ok: false;
32
31
  message: string;
33
32
  output: string;
34
33
  phase: RemotePhase;
35
- };
36
- /** A commit that records the working tree, or why one could not be made. */
37
- export type SnapshotResult = {
38
- ok: true;
39
- sha: string;
40
- toplevel: string;
41
- } | {
42
- ok: false;
43
- isUnsupported: boolean;
44
- reason: string;
34
+ waitedMs: number;
45
35
  };
46
- /**
47
- * Composes the script that runs nmr on the host, from the directory that corresponds to the local one.
48
- *
49
- * A Node launcher starts nmr in its own process group, with stdin closed, and reads the connection's stdin, which the
50
- * local `ssh` holds open and closes when it exits, as on Ctrl-C; on that end of input, the launcher stops nmr's whole
51
- * group, so that an interrupted run does not continue on the host. The launcher rather than the shell's job control
52
- * makes the group, because a shell such as dash refuses job control without a terminal. `--no-delegate` keeps a host
53
- * whose environment sets `NMR_DELEGATE` from passing the command on again.
54
- *
55
- * The script writes nmr's status to `statusPath` before it exits, so that a 255 from `ssh` can be told apart: It is
56
- * nmr's own when the file records it, and a dropped connection otherwise.
57
- *
58
- * @internal
59
- */
60
- export declare function composeRunScript(options: {
61
- nmrArgs: readonly string[];
62
- remoteEnv: Readonly<Record<string, string>>;
63
- runDir: string;
64
- statusPath: string;
65
- }): string;
66
- /**
67
- * Builds a commit whose tree is the working tree as it is on disk: tracked changes, staged or not, and untracked
68
- * files that git does not ignore. The commit is built in a temporary index, so the repository's index, refs, and
69
- * stash are untouched; the only write is to the object database.
70
- *
71
- * The identity and date are fixed, so that the same tree on the same `HEAD` always yields the same commit and a
72
- * repeated push transfers nothing. A submodule, or a nested repository that `git add` would record as one, is
73
- * refused: Its content is in a repository that the snapshot does not include.
74
- */
75
- export declare function createSnapshot(cwd: string, env: NodeJS.ProcessEnv): SnapshotResult;
76
36
  /**
77
37
  * Returns the checkout's path on the host, relative to its home directory: a key derived from this machine and the
78
38
  * repository, followed by the repository's own directory name, which nmr on the host reports as the root's scope.
@@ -81,34 +41,22 @@ export declare function createSnapshot(cwd: string, env: NodeJS.ProcessEnv): Sna
81
41
  */
82
42
  export declare function deriveCheckoutPath(hostname: string, toplevel: string): string;
83
43
  /**
84
- * Renders the warning for a `--delegate` or `--no-delegate` that appears after the command name, which makes it an
85
- * argument to the command rather than a flag to nmr.
44
+ * Renders the warning for a delegation flag that appears after the command name, which makes it an argument to the
45
+ * command rather than a flag to nmr.
86
46
  */
87
47
  export declare function formatMisplacedDelegationWarning(command: string, style: OutputStyle): string;
88
- /**
89
- * Decides where an invocation runs. `--no-delegate` outranks `--delegate`, which outranks `NMR_DELEGATE`.
90
- *
91
- * A command that rewrites the tree, or that runs until it is stopped, cannot be delegated, and neither can `--log`,
92
- * which reads this machine's recordings. Asked for by the flag, such an invocation is refused; asked for by the
93
- * variable, which sets a default for every invocation, it runs here.
94
- */
95
- export declare function resolveDelegationTarget(options: {
96
- command: string;
97
- env: NodeJS.ProcessEnv;
98
- flagHost: string | undefined;
99
- isOptedOut: boolean;
100
- isReading: boolean;
101
- }): DelegationTarget;
102
48
  /**
103
49
  * Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
104
50
  * `stdout` and `stderr` and returning its exit status.
105
51
  *
106
52
  * The host keeps one checkout per local repository and host name, under `~/.cache/nmr/delegate/`, so concurrent
107
53
  * delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
108
- * between runs. Each phase is its own connection, so that a failure names its phase; what a phase other than `run`
109
- * prints is returned rather than streamed, because it matters only when the phase fails.
54
+ * between runs. Check out, install, and run take one connection, whose session holds the checkout's lock, so that two
55
+ * delegations from one worktree run one after the other; what a phase other than `run` prints is returned rather than
56
+ * streamed, because it matters only when the phase fails.
110
57
  */
111
58
  export declare function runRemotely(options: {
59
+ command: string;
112
60
  cwd: string;
113
61
  env: NodeJS.ProcessEnv;
114
62
  host: string;
@@ -122,6 +70,13 @@ export declare function runRemotely(options: {
122
70
  stderr: Writable;
123
71
  stdout: Writable;
124
72
  }): Promise<RemoteRunResult>;
73
+ /**
74
+ * Runs a script on the host through `wrapRemoteScript`, capturing what it prints. An abort stops the `ssh` process.
75
+ */
76
+ export declare function runSsh(host: string, script: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<{
77
+ exitCode: number;
78
+ output: string;
79
+ }>;
125
80
  /**
126
81
  * Wraps a script to run under `/bin/sh` with the `PATH` that the user's interactive login shell sets. pnpm's own
127
82
  * installer and the common version managers set it up in the interactive startup file, such as `~/.zshrc`, which a
@@ -1,124 +1,37 @@
1
- import { spawn, spawnSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import fs from 'node:fs';
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
4
3
  import os from 'node:os';
5
4
  import path from 'node:path';
6
- import { GIT_OUTPUT_LIMIT, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
5
+ import process from 'node:process';
6
+ import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
7
+ import { PASS_MANIFEST_DIR_ENV_VAR, writeDebugNote } from "./check-cache.js";
7
8
  import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
8
9
  import { quoteForShell } from "./helpers/quoteForShell.js";
10
+ import { STALE_AFTER_MS } from "./machine-lock.js";
11
+ import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
12
+ import { composeSessionScript, createSessionDemultiplexer, formatDelegationWaitingLine } from "./remote-session.js";
9
13
  export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
10
14
  export const DELEGATE_FLAG = '--delegate';
15
+ export const DELEGATE_TO_FLAG = '--delegate-to';
11
16
  export const DELEGATION_FAILURE_EXIT_CODE = 255;
12
17
  export const NO_DELEGATE_FLAG = '--no-delegate';
13
18
  export const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
14
- export function composeRunScript(options) {
15
- const { nmrArgs, remoteEnv, runDir, statusPath } = options;
16
- const assignments = Object.entries(remoteEnv).map(([name, value]) => `${name}=${quoteForShell(value)}`);
17
- const invocation = [
18
- ...assignments,
19
- 'node',
20
- '-e',
21
- quoteForShell(RUN_LAUNCHER_SOURCE),
22
- 'pnpm',
23
- 'exec',
24
- 'nmr',
25
- '--no-delegate',
26
- ...nmrArgs.map(quoteForShell),
27
- ].join(' ');
28
- return [
29
- `rm -f ${statusPath}`,
30
- `mkdir -p ${runDir} && cd ${runDir} || exit 1`,
31
- invocation,
32
- 'nmr_status=$?',
33
- String.raw `printf '%s\n' "$nmr_status" > ${statusPath}`,
34
- 'exit "$nmr_status"',
35
- ].join('\n');
36
- }
37
- export function createSnapshot(cwd, env) {
38
- const toplevelResult = runGit(['rev-parse', '--show-toplevel'], cwd, env);
39
- if (!toplevelResult.ok) {
40
- return { ok: false, isUnsupported: true, reason: `not a git repository: ${toplevelResult.error}` };
41
- }
42
- const toplevel = toplevelResult.stdout.trim();
43
- if (fs.existsSync(path.join(toplevel, '.gitmodules'))) {
44
- return { ok: false, isUnsupported: true, reason: 'the repository declares submodules' };
45
- }
46
- const indexPathResult = runGit(['rev-parse', '--git-path', 'index'], toplevel, env);
47
- if (!indexPathResult.ok) {
48
- return { ok: false, isUnsupported: false, reason: indexPathResult.error };
49
- }
50
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nmr-snapshot-'));
51
- try {
52
- const tempIndex = path.join(tempDir, 'index');
53
- const indexPath = path.resolve(toplevel, indexPathResult.stdout.trim());
54
- if (fs.existsSync(indexPath)) {
55
- fs.copyFileSync(indexPath, tempIndex);
56
- }
57
- const indexEnv = { ...env, GIT_INDEX_FILE: tempIndex };
58
- const addResult = runGit(['add', '--all'], toplevel, indexEnv);
59
- if (!addResult.ok) {
60
- return { ok: false, isUnsupported: false, reason: addResult.error };
61
- }
62
- const stageResult = runGit(['ls-files', '--stage', '-z'], toplevel, indexEnv);
63
- if (!stageResult.ok) {
64
- return { ok: false, isUnsupported: false, reason: stageResult.error };
65
- }
66
- if (stageResult.stdout.split('\0').some((entry) => entry.startsWith('160000 '))) {
67
- return { ok: false, isUnsupported: true, reason: 'the working tree contains a submodule or a nested repository' };
68
- }
69
- const treeResult = runGit(['write-tree'], toplevel, indexEnv);
70
- if (!treeResult.ok) {
71
- return { ok: false, isUnsupported: false, reason: treeResult.error };
72
- }
73
- const headResult = runGit(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], toplevel, env);
74
- const parentArgs = headResult.ok ? ['-p', headResult.stdout.trim()] : [];
75
- const commitResult = runGit(['commit-tree', treeResult.stdout.trim(), ...parentArgs, '-m', 'nmr delegate snapshot'], toplevel, { ...env, ...SNAPSHOT_IDENTITY_ENV });
76
- if (!commitResult.ok) {
77
- return { ok: false, isUnsupported: false, reason: commitResult.error };
78
- }
79
- return { ok: true, sha: commitResult.stdout.trim(), toplevel };
80
- }
81
- finally {
82
- fs.rmSync(tempDir, { force: true, recursive: true });
83
- }
84
- }
85
19
  export function deriveCheckoutPath(hostname, toplevel) {
86
20
  const key = createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
87
21
  return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
88
22
  }
89
23
  export function formatMisplacedDelegationWarning(command, style) {
90
- return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG} and ${NO_DELEGATE_FLAG} after the command name are passed ` +
91
- `to \`${command}\`, not read by nmr. Did you mean \`nmr ${DELEGATE_FLAG} <host> ${command}\`?`);
92
- }
93
- export function resolveDelegationTarget(options) {
94
- const { command, env, flagHost, isOptedOut, isReading } = options;
95
- if (isOptedOut) {
96
- return { kind: 'local' };
97
- }
98
- const isDelegable = !isReading && !NON_DELEGABLE_COMMANDS.has(command);
99
- if (flagHost !== undefined) {
100
- if (isDelegable) {
101
- return { kind: 'remote', host: flagHost, source: 'flag' };
102
- }
103
- const subject = isReading ? '--log' : `\`${command}\``;
104
- return {
105
- kind: 'refused',
106
- error: `${subject} cannot be delegated: it ${isReading ? "reads this machine's recordings" : 'rewrites the working tree or runs until stopped'}. Run it without ${DELEGATE_FLAG}.`,
107
- };
108
- }
109
- const envHost = env[DELEGATE_ENV_VAR];
110
- if (envHost !== undefined && envHost !== '' && isDelegable) {
111
- return { kind: 'remote', host: envHost, source: 'env' };
112
- }
113
- return { kind: 'local' };
24
+ return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG}, ${DELEGATE_TO_FLAG}, and ${NO_DELEGATE_FLAG} after the ` +
25
+ `command name are passed to \`${command}\`, not read by nmr. Write them before it: ` +
26
+ `\`nmr ${DELEGATE_TO_FLAG} <host> ${command}\`.`);
114
27
  }
115
28
  export async function runRemotely(options) {
116
- const { cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
29
+ const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
117
30
  const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
118
31
  const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
119
32
  const prepare = await runSsh(host, `mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; }`, env);
120
33
  if (prepare.exitCode !== 0) {
121
- return { ok: false, message: `could not prepare ${host}`, output: prepare.output, phase: 'prepare' };
34
+ return { ok: false, message: `could not prepare ${host}`, output: prepare.output, phase: 'prepare', waitedMs: 0 };
122
35
  }
123
36
  const push = await runProcess([
124
37
  'git',
@@ -131,35 +44,84 @@ export async function runRemotely(options) {
131
44
  `${snapshot.sha}:${SNAPSHOT_REF}`,
132
45
  ], { cwd: snapshot.toplevel, env: { ...env, GIT_SSH_COMMAND: ['ssh', ...SSH_OPTIONS].join(' ') } });
133
46
  if (push.exitCode !== 0) {
134
- return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push' };
135
- }
136
- const checkout = await runSsh(host, `cd ${checkoutDir} && git checkout --quiet --force --detach ${snapshot.sha} && git clean --force -d --quiet`, env);
137
- if (checkout.exitCode !== 0) {
138
- return { ok: false, message: `could not check out on ${host}`, output: checkout.output, phase: 'check out' };
139
- }
140
- const rootDir = joinRemotePath(checkoutDir, path.relative(snapshot.toplevel, monorepoRoot));
141
- const install = await runSsh(host, `cd ${rootDir} && CI=true pnpm install --frozen-lockfile`, env);
142
- if (install.exitCode !== 0) {
143
- const message = install.exitCode === COMMAND_NOT_FOUND_EXIT_CODE
144
- ? `could not install on ${host}: pnpm is not on the PATH that the interactive login shell sets there`
145
- : `could not install on ${host}`;
146
- return { ok: false, message, output: install.output, phase: 'install' };
147
- }
148
- const runDir = joinRemotePath(checkoutDir, path.relative(snapshot.toplevel, cwd));
149
- const statusPath = `${checkoutDir}/.git/${quoteForShell(RUN_STATUS_FILE)}`;
150
- const run = await runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(composeRunScript({ nmrArgs, remoteEnv, runDir, statusPath }))], {
47
+ return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push', waitedMs: 0 };
48
+ }
49
+ const nonce = randomUUID();
50
+ const marker = `__NMR_DELEGATE_${nonce}__`;
51
+ const statusFile = `.git/nmr-run-status-${nonce}`;
52
+ const passes = [];
53
+ let waitStartedAtMs;
54
+ let waitedMs = 0;
55
+ let failure;
56
+ const demultiplexer = createSessionDemultiplexer({
57
+ marker,
58
+ onRecord(record) {
59
+ if (record.kind === 'waiting') {
60
+ waitStartedAtMs ??= Date.now();
61
+ stderr.write(`${formatDelegationWaitingLine(record.holder, host)}\n`);
62
+ }
63
+ else if (record.kind === 'locked' && waitStartedAtMs !== undefined) {
64
+ waitedMs = Date.now() - waitStartedAtMs;
65
+ }
66
+ else if (record.kind === 'failed') {
67
+ failure = record;
68
+ }
69
+ else if (record.kind === 'passes') {
70
+ passes.push(...record.entries);
71
+ }
72
+ },
73
+ onMalformedRecord() {
74
+ writeDebugNote(`discarded a malformed pass manifest from ${host}`, env, stderr);
75
+ },
76
+ stderr,
77
+ });
78
+ const script = composeSessionScript({
79
+ checkoutDir,
80
+ params: {
81
+ holder: { command, dir: cwd, pid: process.pid },
82
+ lockPath: CHECKOUT_LOCK_PATH,
83
+ marker,
84
+ nmrArgs,
85
+ passManifestDir: `.git/nmr-passes-${nonce}`,
86
+ passManifestEnvVar: PASS_MANIFEST_DIR_ENV_VAR,
87
+ remoteEnv,
88
+ rootDir: toRemoteRelativeDir(path.relative(snapshot.toplevel, monorepoRoot)),
89
+ runDir: toRemoteRelativeDir(path.relative(snapshot.toplevel, cwd)),
90
+ sha: snapshot.sha,
91
+ staleAfterMs: STALE_AFTER_MS,
92
+ statusFile,
93
+ },
94
+ });
95
+ const session = await runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], {
151
96
  env,
152
97
  isStreaming: true,
153
- stderr,
98
+ stderr: demultiplexer.stream,
154
99
  stdout,
155
100
  });
156
- if (run.exitCode === DELEGATION_FAILURE_EXIT_CODE) {
157
- const recorded = await runSsh(host, `cat ${statusPath}`, env);
101
+ demultiplexer.flush();
102
+ if (failure !== undefined) {
103
+ const output = [demultiplexer.readOutput(), failure.output].filter((text) => text !== '').join('\n');
104
+ return { ok: false, message: describeFailedPhase(failure, host), output, phase: failure.phase, waitedMs };
105
+ }
106
+ if (!demultiplexer.hasStarted()) {
107
+ return {
108
+ ok: false,
109
+ message: describeUnfinishedSession(session.exitCode, host),
110
+ output: demultiplexer.readOutput(),
111
+ phase: 'check out',
112
+ waitedMs,
113
+ };
114
+ }
115
+ if (session.exitCode === DELEGATION_FAILURE_EXIT_CODE) {
116
+ const recorded = await runSsh(host, `cd ${checkoutDir} && cat ${quoteForShell(statusFile)} && rm -f ${quoteForShell(statusFile)}`, env);
158
117
  if (recorded.exitCode !== 0 || recorded.output.trim() !== String(DELEGATION_FAILURE_EXIT_CODE)) {
159
- return { ok: false, message: `lost connection to ${host}`, output: '', phase: 'run' };
118
+ return { ok: false, message: `lost connection to ${host}`, output: '', phase: 'run', waitedMs };
160
119
  }
161
120
  }
162
- return { ok: true, exitCode: run.exitCode };
121
+ return { ok: true, exitCode: session.exitCode, passes, waitedMs };
122
+ }
123
+ export function runSsh(host, script, env, signal) {
124
+ return runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], { env, signal });
163
125
  }
164
126
  export function wrapRemoteScript(script) {
165
127
  return `exec /bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}\n${script}`)}`;
@@ -171,33 +133,23 @@ const LOGIN_PATH_PREAMBLE = [
171
133
  'export PATH',
172
134
  ].join('; ');
173
135
  const RECEIVE_PACK_COMMAND = `/bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}; exec git receive-pack "$1"`)} nmr-receive-pack`;
174
- const RUN_LAUNCHER_SOURCE = [
175
- "const { spawn } = require('node:child_process');",
176
- "const { constants } = require('node:os');",
177
- 'const [file, ...args] = process.argv.slice(1);',
178
- "const child = spawn(file, args, { detached: true, stdio: ['ignore', 'inherit', 'inherit'] });",
179
- "const stop = () => { try { process.kill(-child.pid, 'SIGTERM'); } catch {} };",
180
- "process.stdin.on('end', stop).on('error', stop).resume();",
181
- "child.on('error', (error) => { console.error(file + ': ' + error.message); process.exit(127); });",
182
- "child.on('close', (code, signal) => process.exit(code ?? 128 + (constants.signals[signal] ?? 0)));",
183
- ].join('\n');
184
- const RUN_STATUS_FILE = 'nmr-run-status';
185
136
  const COMMAND_NOT_FOUND_EXIT_CODE = 127;
186
137
  const PHASE_OUTPUT_LIMIT_BYTES = 65_536;
187
- const NON_DELEGABLE_BASE_COMMANDS = ['build', 'clean', 'compile', 'fix', 'fmt', 'lint', 'test:watch', 'upgrade'];
188
- const NON_DELEGABLE_COMMANDS = new Set([
189
- ...NON_DELEGABLE_BASE_COMMANDS,
190
- ...NON_DELEGABLE_BASE_COMMANDS.map((command) => `root:${command}`),
191
- ]);
192
- const SNAPSHOT_IDENTITY_ENV = {
193
- GIT_AUTHOR_DATE: '@0 +0000',
194
- GIT_AUTHOR_EMAIL: 'nmr@localhost',
195
- GIT_AUTHOR_NAME: 'nmr delegate',
196
- GIT_COMMITTER_DATE: '@0 +0000',
197
- GIT_COMMITTER_EMAIL: 'nmr@localhost',
198
- GIT_COMMITTER_NAME: 'nmr delegate',
199
- };
200
138
  const SNAPSHOT_REF = 'refs/nmr/delegate';
139
+ function describeFailedPhase(failure, host) {
140
+ if (failure.phase === 'check out') {
141
+ return `could not check out on ${host}`;
142
+ }
143
+ return failure.exitCode === COMMAND_NOT_FOUND_EXIT_CODE
144
+ ? `could not install on ${host}: pnpm is not on the PATH that the interactive login shell sets there`
145
+ : `could not install on ${host}`;
146
+ }
147
+ function describeUnfinishedSession(exitCode, host) {
148
+ if (exitCode === COMMAND_NOT_FOUND_EXIT_CODE) {
149
+ return `could not check out on ${host}: node is not on the PATH that the interactive login shell sets there`;
150
+ }
151
+ return exitCode === DELEGATION_FAILURE_EXIT_CODE ? `lost connection to ${host}` : `could not check out on ${host}`;
152
+ }
201
153
  function forwardChunk(chunk, stream, buffer) {
202
154
  if (stream === undefined) {
203
155
  buffer.append(chunk);
@@ -205,29 +157,15 @@ function forwardChunk(chunk, stream, buffer) {
205
157
  }
206
158
  stream.write(chunk);
207
159
  }
208
- function joinRemotePath(checkoutDir, relativeDir) {
209
- const posixDir = relativeDir.split(path.sep).join('/');
210
- return posixDir === '' ? checkoutDir : `${checkoutDir}/${quoteForShell(posixDir)}`;
211
- }
212
- function runGit(args, cwd, env) {
213
- const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, maxBuffer: GIT_OUTPUT_LIMIT });
214
- if (result.error !== undefined) {
215
- return { ok: false, error: result.error.message };
216
- }
217
- if (result.status !== 0) {
218
- return { ok: false, error: result.stderr.trim() || `git ${args[0] ?? ''} exited ${result.status ?? 'abnormally'}` };
219
- }
220
- return { ok: true, stdout: result.stdout };
221
- }
222
160
  function runProcess(argv, options) {
223
- const { cwd, env, isStreaming = false, stderr, stdout } = options;
161
+ const { cwd, env, isStreaming = false, signal, stderr, stdout } = options;
224
162
  const [file = '', ...args] = argv;
225
163
  const buffer = createBoundedBuffer({
226
164
  headLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
227
165
  tailLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
228
166
  });
229
167
  return new Promise((resolve) => {
230
- const child = spawn(file, args, { cwd, env, stdio: [isStreaming ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
168
+ const child = spawn(file, args, { cwd, env, signal, stdio: [isStreaming ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
231
169
  child.stdout?.on('data', (chunk) => {
232
170
  forwardChunk(chunk, isStreaming ? stdout : undefined, buffer);
233
171
  });
@@ -246,6 +184,6 @@ function runProcess(argv, options) {
246
184
  });
247
185
  });
248
186
  }
249
- function runSsh(host, script, env) {
250
- return runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], { env });
187
+ function toRemoteRelativeDir(relativeDir) {
188
+ return relativeDir === '' ? '.' : relativeDir.split(path.sep).join('/');
251
189
  }
@@ -0,0 +1,40 @@
1
+ import { Writable } from 'node:stream';
2
+ import { type DelegationHolder, type HostSessionParams, type SessionRecord } from './remote-host-session.js';
3
+ /**
4
+ * Reads a host session's stderr: its records until the run starts, then the run's own output, and the session's
5
+ * report of the passes that the run recorded once it has ended.
6
+ */
7
+ export interface SessionDemultiplexer {
8
+ /** Forwards what the stream held back as the possible start of a record, once the session has ended. */
9
+ flush: () => void;
10
+ /** Whether the session reported that the run started. */
11
+ hasStarted: () => boolean;
12
+ /** Returns what the session printed before the run started, other than its records. */
13
+ readOutput: () => string;
14
+ stream: Writable;
15
+ }
16
+ /**
17
+ * Composes the script that runs the host session from the checkout. `checkoutDir` is a shell expression, so that it
18
+ * can name the host's home directory.
19
+ */
20
+ export declare function composeSessionScript(options: {
21
+ checkoutDir: string;
22
+ params: HostSessionParams;
23
+ }): string;
24
+ /**
25
+ * Returns a demultiplexer that passes each of the session's records to `onRecord` until the `run` record, keeps the
26
+ * other text that arrives before it as the session's output, and forwards everything after it to `stderr` as it
27
+ * arrives, except a `passes` record, which it passes to `onRecord` too. A `passes` record whose entries are not a list
28
+ * goes to `onMalformedRecord` instead.
29
+ *
30
+ * The run's output is forwarded as bytes, so that a character split across chunks arrives unchanged. Only trailing bytes
31
+ * that may still begin a record are held back, until they turn out not to or the record's newline arrives.
32
+ */
33
+ export declare function createSessionDemultiplexer(options: {
34
+ marker: string;
35
+ onMalformedRecord?: () => void;
36
+ onRecord: (record: SessionRecord) => void;
37
+ stderr: Writable;
38
+ }): SessionDemultiplexer;
39
+ /** Renders the line that a delegation prints when it starts waiting for another delegation's checkout. */
40
+ export declare function formatDelegationWaitingLine(holder: DelegationHolder | undefined, host: string): string;