@williamthorsen/nmr 0.47.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.
- package/.meta/changelog.json +206 -0
- package/.readyup/kits/default.js +1534 -1466
- package/.readyup/manifest.json +142 -12
- package/CHANGELOG.md +55 -0
- package/README.md +44 -6
- package/agents/guidance/rulebooks/nmr.md +2 -1
- package/dist/esm/bin-path.js +4 -3
- package/dist/esm/check-cache-build-output.d.ts +24 -0
- package/dist/esm/check-cache-build-output.js +49 -0
- package/dist/esm/check-cache-delegation.d.ts +27 -0
- package/dist/esm/check-cache-delegation.js +92 -0
- package/dist/esm/check-cache-entries.d.ts +109 -0
- package/dist/esm/check-cache-entries.js +87 -0
- package/dist/esm/check-cache-gate.d.ts +64 -0
- package/dist/esm/check-cache-gate.js +226 -0
- package/dist/esm/check-cache-key.d.ts +47 -0
- package/dist/esm/check-cache-key.js +77 -0
- package/dist/esm/check-cache-record.d.ts +69 -0
- package/dist/esm/check-cache-record.js +167 -0
- package/dist/esm/check-cache.d.ts +8 -176
- package/dist/esm/check-cache.js +9 -214
- package/dist/esm/child-env.d.ts +57 -0
- package/dist/esm/child-env.js +59 -0
- package/dist/esm/child-process.d.ts +55 -0
- package/dist/esm/child-process.js +112 -0
- package/dist/esm/commands/build.d.ts +0 -16
- package/dist/esm/commands/build.js +9 -214
- package/dist/esm/commands/clean.js +3 -2
- package/dist/esm/commands/compiler.d.ts +18 -0
- package/dist/esm/commands/compiler.js +49 -0
- package/dist/esm/commands/fmt.d.ts +0 -39
- package/dist/esm/commands/fmt.js +4 -81
- package/dist/esm/commands/format-targets.d.ts +39 -0
- package/dist/esm/commands/format-targets.js +83 -0
- package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
- package/dist/esm/commands/rewrite-specifiers.js +122 -0
- package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
- package/dist/esm/commands/tsconfig-chain.js +63 -0
- package/dist/esm/config-check-cache.d.ts +17 -0
- package/dist/esm/config-check-cache.js +63 -0
- package/dist/esm/config-validation.d.ts +20 -0
- package/dist/esm/config-validation.js +236 -0
- package/dist/esm/config.js +4 -286
- package/dist/esm/crossing-diagnostics.d.ts +39 -0
- package/dist/esm/crossing-diagnostics.js +132 -0
- package/dist/esm/default-scripts.js +1 -1
- package/dist/esm/delegate-host.d.ts +43 -0
- package/dist/esm/delegate-host.js +178 -0
- package/dist/esm/delegation-target.d.ts +52 -0
- package/dist/esm/delegation-target.js +71 -0
- package/dist/esm/fan-out.d.ts +57 -0
- package/dist/esm/fan-out.js +146 -0
- package/dist/esm/glyphs.d.ts +1 -1
- package/dist/esm/help.js +10 -0
- package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
- package/dist/esm/helpers/deriveExcerpt.js +1 -1
- package/dist/esm/helpers/findClosestName.js +4 -3
- package/dist/esm/helpers/quoteForShell.d.ts +5 -0
- package/dist/esm/helpers/quoteForShell.js +3 -0
- package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
- package/dist/esm/helpers/renderQuotedList.js +9 -0
- package/dist/esm/hooks.d.ts +17 -0
- package/dist/esm/hooks.js +23 -0
- package/dist/esm/invocation-args.d.ts +40 -0
- package/dist/esm/invocation-args.js +178 -0
- package/dist/esm/invocation.d.ts +67 -0
- package/dist/esm/invocation.js +3 -0
- package/dist/esm/machine-lock.d.ts +2 -0
- package/dist/esm/machine-lock.js +1 -1
- package/dist/esm/output-channel.d.ts +23 -0
- package/dist/esm/output-channel.js +14 -0
- package/dist/esm/pass-manifest.d.ts +53 -0
- package/dist/esm/pass-manifest.js +89 -0
- package/dist/esm/presentation.d.ts +47 -0
- package/dist/esm/presentation.js +32 -0
- package/dist/esm/recording.d.ts +1 -1
- package/dist/esm/recording.js +2 -2
- package/dist/esm/remote-host-session.d.ts +62 -0
- package/dist/esm/remote-host-session.js +277 -0
- package/dist/esm/remote-probe-host.d.ts +22 -0
- package/dist/esm/remote-probe-host.js +50 -0
- package/dist/esm/remote-probe.d.ts +31 -0
- package/dist/esm/remote-probe.js +78 -0
- package/dist/esm/remote-run.d.ts +91 -0
- package/dist/esm/remote-run.js +189 -0
- package/dist/esm/remote-session.d.ts +40 -0
- package/dist/esm/remote-session.js +151 -0
- package/dist/esm/remote-snapshot.d.ts +21 -0
- package/dist/esm/remote-snapshot.js +80 -0
- package/dist/esm/replay-assembly.d.ts +2 -2
- package/dist/esm/replay-assembly.js +1 -1
- package/dist/esm/run-chain.d.ts +21 -0
- package/dist/esm/run-chain.js +108 -0
- package/dist/esm/runCli.d.ts +1 -9
- package/dist/esm/runCli.js +41 -895
- package/dist/esm/runner.d.ts +1 -54
- package/dist/esm/runner.js +2 -125
- package/dist/esm/shell-syntax.d.ts +30 -0
- package/dist/esm/shell-syntax.js +120 -0
- package/dist/esm/steps.d.ts +0 -2
- package/dist/esm/steps.js +3 -119
- package/dist/esm/types.d.ts +5 -0
- package/dist/esm/user-config.d.ts +34 -0
- package/dist/esm/user-config.js +93 -0
- package/dist/esm/verbosity.d.ts +6 -0
- package/dist/esm/verbosity.js +7 -7
- package/dist/esm/verdict.d.ts +3 -1
- package/dist/esm/verdict.js +4 -3
- package/dist/esm/vitest-source-resolution.js +2 -3
- package/docs/check-cache.md +30 -14
- package/docs/delegate.md +141 -0
- package/docs/machine-lock.md +2 -1
- package/docs/reporting.md +6 -1
- package/docs/scripts.md +2 -1
- package/package.json +5 -5
- /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
- /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
import { type ReplayLine } from './check-cache-entries.js';
|
|
3
|
+
import type { CheckCacheConfig } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* One pass that an nmr process on a host recorded or recalled during a delegated run, as the host session returns it
|
|
6
|
+
* to the delegating machine.
|
|
7
|
+
*/
|
|
8
|
+
export interface PassManifestEntry {
|
|
9
|
+
command: string;
|
|
10
|
+
commandString: string;
|
|
11
|
+
/** How long the pass took when it was earned, which a recall reports as the time that it saved. */
|
|
12
|
+
durationMs: number;
|
|
13
|
+
replay?: ReplayLine[];
|
|
14
|
+
/** The scope's directory, in the form that `formatManifestScope` renders. */
|
|
15
|
+
scope: string;
|
|
16
|
+
treeHash: string;
|
|
17
|
+
}
|
|
18
|
+
/** Renders a scope's directory as a manifest entry names it: relative to the root, `.` for the root itself. */
|
|
19
|
+
export declare function formatManifestScope(monorepoRoot: string, anchorDir: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Returns the entries of a host's pass manifest that this machine may record, dropping each of the others with a debug
|
|
22
|
+
* note. The manifest is untrusted, so an entry is kept only when it has the entry's shape, names the monorepo root or
|
|
23
|
+
* one of its workspace packages by a relative path, names a command that is cacheable here, describes `treeId`, and
|
|
24
|
+
* replays only excerpts that a pass could have recorded.
|
|
25
|
+
*
|
|
26
|
+
* A manifest of more entries than any run produces is rejected whole, so that a hostile one cannot fill the cache.
|
|
27
|
+
*/
|
|
28
|
+
export declare function validatePassManifest(options: {
|
|
29
|
+
checkCache: CheckCacheConfig | undefined;
|
|
30
|
+
entries: readonly unknown[];
|
|
31
|
+
env: NodeJS.ProcessEnv;
|
|
32
|
+
monorepoRoot: string;
|
|
33
|
+
stderr: Writable;
|
|
34
|
+
treeId: string;
|
|
35
|
+
workspacePackageDirs: readonly string[];
|
|
36
|
+
}): PassManifestEntry[];
|
|
37
|
+
/**
|
|
38
|
+
* Writes one entry into the directory that `NMR_PASS_MANIFEST_DIR` names, and does nothing when it is unset. Each
|
|
39
|
+
* entry is a file of its own, so that the processes of a fan-out cannot interleave their writes.
|
|
40
|
+
*
|
|
41
|
+
* A manifest that cannot be written is not worth failing a green run over: the failure goes to the debug note.
|
|
42
|
+
*/
|
|
43
|
+
export declare function writePassManifestEntry(options: {
|
|
44
|
+
anchorDir: string;
|
|
45
|
+
command: string;
|
|
46
|
+
commandString: string;
|
|
47
|
+
durationMs: number;
|
|
48
|
+
env: NodeJS.ProcessEnv;
|
|
49
|
+
monorepoRoot: string;
|
|
50
|
+
replay: ReplayLine[] | undefined;
|
|
51
|
+
stderr: Writable;
|
|
52
|
+
treeHash: string;
|
|
53
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
6
|
+
import { isCacheableCommand, PASS_MANIFEST_DIR_ENV_VAR, writeDebugNote } from "./check-cache.js";
|
|
7
|
+
import { isReplayLine } from "./check-cache-entries.js";
|
|
8
|
+
import { MAX_EXCERPT_BYTES } from "./helpers/deriveExcerpt.js";
|
|
9
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
10
|
+
export function formatManifestScope(monorepoRoot, anchorDir) {
|
|
11
|
+
const scope = path.relative(monorepoRoot, anchorDir);
|
|
12
|
+
return scope === '' ? '.' : scope.split(path.sep).join('/');
|
|
13
|
+
}
|
|
14
|
+
export function validatePassManifest(options) {
|
|
15
|
+
const { entries, env, stderr } = options;
|
|
16
|
+
if (entries.length > MAX_MANIFEST_ENTRIES) {
|
|
17
|
+
writeDebugNote(`not learning from a pass manifest of ${entries.length} entries`, env, stderr);
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const scopeDirs = new Set([options.monorepoRoot, ...options.workspacePackageDirs].map((dir) => path.resolve(dir)));
|
|
21
|
+
return entries.filter((entry) => {
|
|
22
|
+
const reason = findRejection(entry, { ...options, scopeDirs });
|
|
23
|
+
if (reason !== undefined) {
|
|
24
|
+
writeDebugNote(`not learning a pass from the manifest: ${reason}`, env, stderr);
|
|
25
|
+
}
|
|
26
|
+
return reason === undefined;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function writePassManifestEntry(options) {
|
|
30
|
+
const { command, env, stderr } = options;
|
|
31
|
+
const manifestDir = env[PASS_MANIFEST_DIR_ENV_VAR];
|
|
32
|
+
if (manifestDir === undefined || manifestDir === '') {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const entry = {
|
|
36
|
+
command,
|
|
37
|
+
commandString: options.commandString,
|
|
38
|
+
durationMs: options.durationMs,
|
|
39
|
+
...(options.replay !== undefined && options.replay.length > 0 && { replay: options.replay }),
|
|
40
|
+
scope: formatManifestScope(options.monorepoRoot, options.anchorDir),
|
|
41
|
+
treeHash: options.treeHash,
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
await writeFile(path.join(manifestDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify(entry), {
|
|
45
|
+
flag: 'wx',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
writeDebugNote(`could not report ${command} to the delegating machine: ${describeError(error)}`, env, stderr);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const MAX_MANIFEST_ENTRIES = 1_000;
|
|
53
|
+
function findRejection(entry, options) {
|
|
54
|
+
if (!isPassManifestEntry(entry)) {
|
|
55
|
+
return 'the entry is malformed';
|
|
56
|
+
}
|
|
57
|
+
const { command, scope } = entry;
|
|
58
|
+
if (!isRelativeScope(scope) || !options.scopeDirs.has(path.resolve(options.monorepoRoot, scope))) {
|
|
59
|
+
return `${command} names a scope outside the workspace (${scope})`;
|
|
60
|
+
}
|
|
61
|
+
if (!isCacheableCommand(options.checkCache, command)) {
|
|
62
|
+
return `${command} is not cacheable here`;
|
|
63
|
+
}
|
|
64
|
+
if (entry.treeHash !== options.treeId) {
|
|
65
|
+
return `${command} passed on another tree`;
|
|
66
|
+
}
|
|
67
|
+
if (entry.replay?.some((line) => Buffer.byteLength(line.excerpt) > MAX_EXCERPT_BYTES) === true) {
|
|
68
|
+
return `${command} replays an excerpt longer than a pass records`;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
function isPassManifestEntry(value) {
|
|
73
|
+
return (isObject(value) &&
|
|
74
|
+
typeof value['command'] === 'string' &&
|
|
75
|
+
typeof value['commandString'] === 'string' &&
|
|
76
|
+
typeof value['durationMs'] === 'number' &&
|
|
77
|
+
Number.isFinite(value['durationMs']) &&
|
|
78
|
+
value['durationMs'] >= 0 &&
|
|
79
|
+
(value['replay'] === undefined || (Array.isArray(value['replay']) && value['replay'].every(isReplayLine))) &&
|
|
80
|
+
typeof value['scope'] === 'string' &&
|
|
81
|
+
typeof value['treeHash'] === 'string');
|
|
82
|
+
}
|
|
83
|
+
function isRelativeScope(scope) {
|
|
84
|
+
if (scope === '.') {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return (!path.posix.isAbsolute(scope) &&
|
|
88
|
+
scope.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'));
|
|
89
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
import { type OutputStyle, type StreamStyles } from '@williamthorsen/nmr-core';
|
|
3
|
+
import { type ReportFormat } from './report-format.js';
|
|
4
|
+
import { type CommandVerbosity, type ResolveVerbosityOptions } from './verbosity.js';
|
|
5
|
+
import { type Verdict } from './verdict.js';
|
|
6
|
+
/**
|
|
7
|
+
* Reads everything that decides how a run presents itself: the two inherited variables, and the style that each
|
|
8
|
+
* output stream resolves to. Reports the first value that cannot be read.
|
|
9
|
+
*
|
|
10
|
+
* The style is resolved here rather than beside its use, so that a value that does not name a style is rejected
|
|
11
|
+
* at the same point as an unreadable variable, which is ahead of `--version` and of the config load.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readPresentation(options: {
|
|
14
|
+
env: NodeJS.ProcessEnv;
|
|
15
|
+
flagValue: string | undefined;
|
|
16
|
+
stderrIsTty: boolean;
|
|
17
|
+
stdoutIsTty: boolean;
|
|
18
|
+
}): PresentationRead;
|
|
19
|
+
/**
|
|
20
|
+
* Reports an invocation's verdict, unless the levels around it already report for it.
|
|
21
|
+
*
|
|
22
|
+
* A hook leaf reports none: it is not a command that anyone asked for, but part of the chain on which the level
|
|
23
|
+
* that wrapped it reports, and a line here would say the same thing twice under a different name. A delegating
|
|
24
|
+
* invocation returns before composing a verdict, and every scope to which it fans out reports its own.
|
|
25
|
+
*/
|
|
26
|
+
export declare function reportVerdict(verdict: Verdict, stdout: Writable, format: ReportFormat, style: OutputStyle): void;
|
|
27
|
+
/**
|
|
28
|
+
* Resolves how loudly this run reports the output of the commands that it runs.
|
|
29
|
+
*
|
|
30
|
+
* A machine-readable run reports on stdout and nothing else may, so it withholds that output whatever the
|
|
31
|
+
* loudness ladder would have resolved to. A failure still prints it on stderr, as under any quiet run. Because the
|
|
32
|
+
* two leave the child on the same channels, a machine-readable run and a quiet one share a retention key.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveReportingVerbosity(options: ResolveVerbosityOptions & {
|
|
35
|
+
format: ReportFormat;
|
|
36
|
+
}): CommandVerbosity;
|
|
37
|
+
/** What the presentation sources came to, or the message naming why one of them could not be read. */
|
|
38
|
+
type PresentationRead = {
|
|
39
|
+
ok: true;
|
|
40
|
+
format: ReportFormat | undefined;
|
|
41
|
+
styles: StreamStyles;
|
|
42
|
+
verbosity: CommandVerbosity | undefined;
|
|
43
|
+
} | {
|
|
44
|
+
ok: false;
|
|
45
|
+
error: string;
|
|
46
|
+
};
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describeInvalidOutputStyle } from '@williamthorsen/nmr-core';
|
|
2
|
+
import { isHookName } from "./helpers/hook-name.js";
|
|
3
|
+
import { resolveOutputStyles } from "./output-style.js";
|
|
4
|
+
import { readReportFormatEnv } from "./report-format.js";
|
|
5
|
+
import { readVerbosityEnv, resolveVerbosity, } from "./verbosity.js";
|
|
6
|
+
import { writeVerdict } from "./verdict.js";
|
|
7
|
+
export function readPresentation(options) {
|
|
8
|
+
const { env } = options;
|
|
9
|
+
const verbosity = readVerbosityEnv(env);
|
|
10
|
+
if (!verbosity.ok) {
|
|
11
|
+
return verbosity;
|
|
12
|
+
}
|
|
13
|
+
const format = readReportFormatEnv(env);
|
|
14
|
+
if (!format.ok) {
|
|
15
|
+
return format;
|
|
16
|
+
}
|
|
17
|
+
const { invalid: invalidStyle, styles } = resolveOutputStyles(options);
|
|
18
|
+
if (invalidStyle !== undefined) {
|
|
19
|
+
return { ok: false, error: describeInvalidOutputStyle(invalidStyle) };
|
|
20
|
+
}
|
|
21
|
+
return { ok: true, format: format.format, styles, verbosity: verbosity.verbosity };
|
|
22
|
+
}
|
|
23
|
+
export function reportVerdict(verdict, stdout, format, style) {
|
|
24
|
+
if (isHookName(verdict.command)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
writeVerdict(verdict, stdout, format, style);
|
|
28
|
+
}
|
|
29
|
+
export function resolveReportingVerbosity(options) {
|
|
30
|
+
const { format, ...ladder } = options;
|
|
31
|
+
return format === 'json' ? 'quiet' : resolveVerbosity(ladder);
|
|
32
|
+
}
|
package/dist/esm/recording.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type OutputStyle } from '@williamthorsen/nmr-core';
|
|
2
|
-
import { type CheckCacheEntry } from './check-cache.js';
|
|
2
|
+
import { type CheckCacheEntry } from './check-cache-entries.js';
|
|
3
3
|
/**
|
|
4
4
|
* What this invocation would be recorded under, insofar as an entry records the same facts. A mismatch is read
|
|
5
5
|
* against these so that a refusal names the ingredient that moved rather than blaming the tree for all of them.
|
package/dist/esm/recording.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { formatGlyphLine, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
|
|
2
|
-
import { readCheckCacheEntry, readTranscript } from "./check-cache.js";
|
|
2
|
+
import { readCheckCacheEntry, readTranscript } from "./check-cache-entries.js";
|
|
3
3
|
import { NMR_GLYPHS } from "./glyphs.js";
|
|
4
4
|
import { formatDuration } from "./helpers/duration.js";
|
|
5
5
|
export function renderRecording(options) {
|
|
6
6
|
const { entry } = options.recording;
|
|
7
7
|
const age = formatDuration(Math.max(0, Date.now() - Date.parse(entry.recordedAt)));
|
|
8
8
|
const header = formatGlyphLine(NMR_GLYPHS, options.style, 'recording', `${options.scope}: ${options.command} — recorded ${entry.recordedAt} (${age} ago), ` +
|
|
9
|
-
`ran in ${formatDuration(entry.durationMs)}`) + `\n$ ${entry.commandString}\n\n`;
|
|
9
|
+
`ran in ${formatDuration(entry.durationMs)}${entry.host === undefined ? '' : ` on ${entry.host}`}`) + `\n$ ${entry.commandString}\n\n`;
|
|
10
10
|
return `${header}${appendNewline(renderBody(options.recording))}`;
|
|
11
11
|
}
|
|
12
12
|
export function renderRefusal(options) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** The checkout's lock, which a host session holds and the lock probe reads, relative to the checkout. */
|
|
2
|
+
export declare const CHECKOUT_LOCK_PATH = ".git/nmr-delegate.lock";
|
|
3
|
+
/** The local delegation on whose behalf a host session runs, as a waiting session names it. */
|
|
4
|
+
export interface DelegationHolder {
|
|
5
|
+
command: string;
|
|
6
|
+
dir: string;
|
|
7
|
+
pid: number;
|
|
8
|
+
}
|
|
9
|
+
/** What the host session needs, sent to it as JSON. Every directory is relative to the checkout. */
|
|
10
|
+
export interface HostSessionParams {
|
|
11
|
+
holder: DelegationHolder;
|
|
12
|
+
lockPath: string;
|
|
13
|
+
/** Begins each record line on stderr; it contains a per-delegation nonce, so that a command cannot forge one. */
|
|
14
|
+
marker: string;
|
|
15
|
+
nmrArgs: readonly string[];
|
|
16
|
+
/** The directory, created for this delegation, into which every nmr process of the run reports its passes. */
|
|
17
|
+
passManifestDir: string;
|
|
18
|
+
/** The variable that names the pass-manifest directory to nmr. */
|
|
19
|
+
passManifestEnvVar: string;
|
|
20
|
+
remoteEnv: Readonly<Record<string, string>>;
|
|
21
|
+
rootDir: string;
|
|
22
|
+
runDir: string;
|
|
23
|
+
sha: string;
|
|
24
|
+
/** Age at which an unrefreshed lock file is abandoned. */
|
|
25
|
+
staleAfterMs: number;
|
|
26
|
+
/** Where the session records nmr's status when it is 255, so that it can be told apart from a dropped connection. */
|
|
27
|
+
statusFile: string;
|
|
28
|
+
}
|
|
29
|
+
/** A phase of the host session that can fail before the run starts. */
|
|
30
|
+
export type SessionPhase = 'check out' | 'install';
|
|
31
|
+
/**
|
|
32
|
+
* A line in which the host session reports to the local side: its progress before the run starts, and the passes that
|
|
33
|
+
* the run reported once it has ended.
|
|
34
|
+
*/
|
|
35
|
+
export type SessionRecord = {
|
|
36
|
+
kind: 'failed';
|
|
37
|
+
exitCode: number;
|
|
38
|
+
output: string;
|
|
39
|
+
phase: SessionPhase;
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'locked';
|
|
42
|
+
} | {
|
|
43
|
+
kind: 'passes';
|
|
44
|
+
entries: unknown[];
|
|
45
|
+
} | {
|
|
46
|
+
kind: 'run';
|
|
47
|
+
} | {
|
|
48
|
+
kind: 'waiting';
|
|
49
|
+
holder: DelegationHolder | undefined;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Runs on the host, from the checkout, as the whole of a delegation after the push: takes the checkout's lock, checks
|
|
53
|
+
* out the snapshot, installs, runs nmr, and reports the passes that nmr wrote to the pass manifest. It is sent as
|
|
54
|
+
* source, so it refers to nothing outside its own body.
|
|
55
|
+
*
|
|
56
|
+
* The lock follows the machine lock's rules: A waiter reclaims it at once when the holder's pid is gone, and once the
|
|
57
|
+
* holder has stopped refreshing it for a minute otherwise. When the session's stdin ends, which the local `ssh` causes
|
|
58
|
+
* by exiting, the session stops its current phase's process group, releases the lock, and exits.
|
|
59
|
+
*
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
export declare function runHostSession(params: HostSessionParams): void;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
export const CHECKOUT_LOCK_PATH = '.git/nmr-delegate.lock';
|
|
2
|
+
export function runHostSession(params) {
|
|
3
|
+
const fs = process.getBuiltinModule('node:fs');
|
|
4
|
+
const childProcess = process.getBuiltinModule('node:child_process');
|
|
5
|
+
const crypto = process.getBuiltinModule('node:crypto');
|
|
6
|
+
const os = process.getBuiltinModule('node:os');
|
|
7
|
+
const path = process.getBuiltinModule('node:path');
|
|
8
|
+
const LOCK_PATH = params.lockPath;
|
|
9
|
+
const RECLAIM_GUARD_PATH = `${params.lockPath}.reclaim`;
|
|
10
|
+
const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
11
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
12
|
+
const RECLAIM_GUARD_STALE_AFTER_MS = 10_000;
|
|
13
|
+
const OUTPUT_LIMIT_BYTES = 65_536;
|
|
14
|
+
const COMMAND_NOT_FOUND_EXIT_CODE = 127;
|
|
15
|
+
const DELEGATION_FAILURE_EXIT_CODE = 255;
|
|
16
|
+
const STOP_GRACE_MS = 5_000;
|
|
17
|
+
const token = crypto.randomUUID();
|
|
18
|
+
let isHolding = false;
|
|
19
|
+
let activeChild;
|
|
20
|
+
let isStopping = false;
|
|
21
|
+
process.on('exit', releaseLock);
|
|
22
|
+
process.stdin.on('end', stop).on('error', stop).resume();
|
|
23
|
+
void main();
|
|
24
|
+
async function main() {
|
|
25
|
+
await acquireLock();
|
|
26
|
+
emit({ kind: 'locked' });
|
|
27
|
+
for (const args of [
|
|
28
|
+
['checkout', '--quiet', '--force', '--detach', params.sha],
|
|
29
|
+
['clean', '--force', '-d', '--quiet'],
|
|
30
|
+
]) {
|
|
31
|
+
const result = await runPhase('git', args, '.', process.env);
|
|
32
|
+
if (isStopping)
|
|
33
|
+
return;
|
|
34
|
+
if (result.exitCode !== 0) {
|
|
35
|
+
fail('check out', result);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const install = await runPhase('pnpm', ['install', '--frozen-lockfile'], params.rootDir, {
|
|
40
|
+
...process.env,
|
|
41
|
+
CI: 'true',
|
|
42
|
+
});
|
|
43
|
+
if (isStopping)
|
|
44
|
+
return;
|
|
45
|
+
if (install.exitCode !== 0) {
|
|
46
|
+
fail('install', install);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
emit({ kind: 'run' });
|
|
50
|
+
fs.mkdirSync(params.runDir, { recursive: true });
|
|
51
|
+
fs.mkdirSync(params.passManifestDir, { recursive: true });
|
|
52
|
+
process.on('exit', () => fs.rmSync(params.passManifestDir, { force: true, recursive: true }));
|
|
53
|
+
const manifestEnv = { [params.passManifestEnvVar]: path.resolve(process.cwd(), params.passManifestDir) };
|
|
54
|
+
const child = childProcess.spawn('pnpm', ['exec', 'nmr', '--no-delegate', ...params.nmrArgs], {
|
|
55
|
+
cwd: params.runDir,
|
|
56
|
+
detached: true,
|
|
57
|
+
env: { ...process.env, ...params.remoteEnv, ...manifestEnv },
|
|
58
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
59
|
+
});
|
|
60
|
+
activeChild = child;
|
|
61
|
+
child.on('error', (error) => {
|
|
62
|
+
process.stderr.write(`pnpm: ${error.message}\n`, () => {
|
|
63
|
+
process.exit(COMMAND_NOT_FOUND_EXIT_CODE);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
child.on('close', (code, signal) => {
|
|
67
|
+
const status = code ?? 128 + (signal === null ? 0 : os.constants.signals[signal]);
|
|
68
|
+
if (status === DELEGATION_FAILURE_EXIT_CODE) {
|
|
69
|
+
fs.writeFileSync(params.statusFile, `${status}\n`);
|
|
70
|
+
}
|
|
71
|
+
emit({ kind: 'passes', entries: readPassManifest() }, () => {
|
|
72
|
+
process.exit(status);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async function acquireLock() {
|
|
77
|
+
const record = {
|
|
78
|
+
command: params.holder.command,
|
|
79
|
+
dir: params.holder.dir,
|
|
80
|
+
localPid: params.holder.pid,
|
|
81
|
+
pid: process.pid,
|
|
82
|
+
startedAt: new Date().toISOString(),
|
|
83
|
+
token,
|
|
84
|
+
};
|
|
85
|
+
let reportedToken;
|
|
86
|
+
for (;;) {
|
|
87
|
+
try {
|
|
88
|
+
fs.writeFileSync(LOCK_PATH, JSON.stringify(record), { flag: 'wx' });
|
|
89
|
+
isHolding = true;
|
|
90
|
+
setInterval(refreshLock, HEARTBEAT_INTERVAL_MS).unref();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (readErrorCode(error) !== 'EEXIST')
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
const state = readLockState();
|
|
98
|
+
if (state === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
if (isStale(state)) {
|
|
101
|
+
reclaimStaleLock(state);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const holderToken = state.holder?.token ?? 'unreadable';
|
|
105
|
+
if (holderToken !== reportedToken) {
|
|
106
|
+
const holder = state.holder;
|
|
107
|
+
emit({
|
|
108
|
+
kind: 'waiting',
|
|
109
|
+
holder: holder === undefined ? undefined : { command: holder.command, dir: holder.dir, pid: holder.localPid },
|
|
110
|
+
});
|
|
111
|
+
reportedToken = holderToken;
|
|
112
|
+
}
|
|
113
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function emit(record, onWritten) {
|
|
117
|
+
process.stderr.write(`${params.marker} ${JSON.stringify(record)}\n`, onWritten);
|
|
118
|
+
}
|
|
119
|
+
function fail(phase, result) {
|
|
120
|
+
emit({ kind: 'failed', exitCode: result.exitCode, output: result.output, phase }, () => {
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function isLockFile(value) {
|
|
125
|
+
if (typeof value !== 'object' || value === null)
|
|
126
|
+
return false;
|
|
127
|
+
const fields = { ...value };
|
|
128
|
+
return (typeof fields['command'] === 'string' &&
|
|
129
|
+
typeof fields['dir'] === 'string' &&
|
|
130
|
+
typeof fields['localPid'] === 'number' &&
|
|
131
|
+
typeof fields['pid'] === 'number' &&
|
|
132
|
+
typeof fields['startedAt'] === 'string' &&
|
|
133
|
+
typeof fields['token'] === 'string');
|
|
134
|
+
}
|
|
135
|
+
function isProcessGone(pid) {
|
|
136
|
+
try {
|
|
137
|
+
process.kill(pid, 0);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
return readErrorCode(error) === 'ESRCH';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function isStale(state) {
|
|
145
|
+
if (Date.now() - state.mtimeMs > params.staleAfterMs)
|
|
146
|
+
return true;
|
|
147
|
+
return state.holder !== undefined && isProcessGone(state.holder.pid);
|
|
148
|
+
}
|
|
149
|
+
function readErrorCode(error) {
|
|
150
|
+
return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
|
|
151
|
+
}
|
|
152
|
+
function readLockState() {
|
|
153
|
+
try {
|
|
154
|
+
const content = fs.readFileSync(LOCK_PATH, 'utf8');
|
|
155
|
+
const { mtimeMs } = fs.statSync(LOCK_PATH);
|
|
156
|
+
let holder;
|
|
157
|
+
try {
|
|
158
|
+
const value = JSON.parse(content);
|
|
159
|
+
holder = isLockFile(value) ? value : undefined;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
holder = undefined;
|
|
163
|
+
}
|
|
164
|
+
return { holder, mtimeMs };
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function reclaimStaleLock(observed) {
|
|
171
|
+
try {
|
|
172
|
+
fs.mkdirSync(RECLAIM_GUARD_PATH);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
try {
|
|
176
|
+
if (Date.now() - fs.statSync(RECLAIM_GUARD_PATH).mtimeMs > RECLAIM_GUARD_STALE_AFTER_MS) {
|
|
177
|
+
fs.rmSync(RECLAIM_GUARD_PATH, { force: true, recursive: true });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const current = readLockState();
|
|
186
|
+
if (current !== undefined && current.holder?.token === observed.holder?.token && isStale(current)) {
|
|
187
|
+
fs.rmSync(LOCK_PATH, { force: true });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
fs.rmSync(RECLAIM_GUARD_PATH, { force: true, recursive: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function refreshLock() {
|
|
195
|
+
const now = new Date();
|
|
196
|
+
try {
|
|
197
|
+
fs.utimesSync(LOCK_PATH, now, now);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function readPassManifest() {
|
|
203
|
+
const names = fs.existsSync(params.passManifestDir) ? fs.readdirSync(params.passManifestDir) : [];
|
|
204
|
+
return names.flatMap((name) => {
|
|
205
|
+
try {
|
|
206
|
+
return [JSON.parse(fs.readFileSync(path.join(params.passManifestDir, name), 'utf8'))];
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function releaseLock() {
|
|
214
|
+
if (!isHolding)
|
|
215
|
+
return;
|
|
216
|
+
isHolding = false;
|
|
217
|
+
if (readLockState()?.holder?.token === token) {
|
|
218
|
+
fs.rmSync(LOCK_PATH, { force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function runPhase(file, args, cwd, env) {
|
|
222
|
+
const head = [];
|
|
223
|
+
const tail = [];
|
|
224
|
+
let headBytes = 0;
|
|
225
|
+
let tailBytes = 0;
|
|
226
|
+
let droppedBytes = 0;
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const child = childProcess.spawn(file, args, { cwd, detached: true, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
229
|
+
activeChild = child;
|
|
230
|
+
child.stdout.on('data', capture);
|
|
231
|
+
child.stderr.on('data', capture);
|
|
232
|
+
child.on('error', (error) => {
|
|
233
|
+
activeChild = undefined;
|
|
234
|
+
resolve({ exitCode: COMMAND_NOT_FOUND_EXIT_CODE, output: `${file}: ${error.message}` });
|
|
235
|
+
});
|
|
236
|
+
child.on('close', (code, signal) => {
|
|
237
|
+
activeChild = undefined;
|
|
238
|
+
resolve({ exitCode: code ?? 128 + (signal === null ? 0 : os.constants.signals[signal]), output: readOutput() });
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
function capture(chunk) {
|
|
242
|
+
const headPart = chunk.subarray(0, Math.max(0, OUTPUT_LIMIT_BYTES - headBytes));
|
|
243
|
+
head.push(headPart);
|
|
244
|
+
headBytes += headPart.length;
|
|
245
|
+
const rest = chunk.subarray(headPart.length);
|
|
246
|
+
tail.push(rest);
|
|
247
|
+
tailBytes += rest.length;
|
|
248
|
+
while (tailBytes > OUTPUT_LIMIT_BYTES) {
|
|
249
|
+
const first = tail.shift() ?? Buffer.alloc(0);
|
|
250
|
+
const dropped = Math.min(tailBytes - OUTPUT_LIMIT_BYTES, first.length);
|
|
251
|
+
if (dropped < first.length)
|
|
252
|
+
tail.unshift(first.subarray(dropped));
|
|
253
|
+
tailBytes -= dropped;
|
|
254
|
+
droppedBytes += dropped;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function readOutput() {
|
|
258
|
+
const marker = droppedBytes > 0 ? [Buffer.from(`\n[… ${droppedBytes} bytes omitted …]\n`)] : [];
|
|
259
|
+
return Buffer.concat([...head, ...marker, ...tail]).toString('utf8');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function stop() {
|
|
263
|
+
isStopping = true;
|
|
264
|
+
const child = activeChild;
|
|
265
|
+
const pid = child?.pid;
|
|
266
|
+
if (child === undefined || pid === undefined) {
|
|
267
|
+
process.exit(DELEGATION_FAILURE_EXIT_CODE);
|
|
268
|
+
}
|
|
269
|
+
child.on('close', () => process.exit(DELEGATION_FAILURE_EXIT_CODE));
|
|
270
|
+
setTimeout(() => process.exit(DELEGATION_FAILURE_EXIT_CODE), STOP_GRACE_MS);
|
|
271
|
+
try {
|
|
272
|
+
process.kill(-pid, 'SIGTERM');
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** What the host probe needs, sent to it as JSON. */
|
|
2
|
+
export interface HostProbeParams {
|
|
3
|
+
/** The checkout's lock file, relative to the host's home directory. */
|
|
4
|
+
checkoutLockPath: string;
|
|
5
|
+
/** The variable that overrides the machine lock's directory. */
|
|
6
|
+
lockDirEnvVar: string;
|
|
7
|
+
/** The machine lock's file name in its directory. */
|
|
8
|
+
machineLockFile: string;
|
|
9
|
+
/** Age at which an unrefreshed lock file is abandoned. */
|
|
10
|
+
staleAfterMs: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Runs on the host and prints, as one JSON line, whether a delegation started now would wait: whether the machine
|
|
14
|
+
* lock or the checkout's lock has a live holder. It applies the locks' own staleness rules and changes nothing. It is
|
|
15
|
+
* sent as source, so it refers to nothing outside its own body.
|
|
16
|
+
*
|
|
17
|
+
* The machine lock's directory is resolved as the host's nmr resolves it, from the environment that `ssh` gives the
|
|
18
|
+
* session.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export declare function probeHostLocks(params: HostProbeParams): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function probeHostLocks(params) {
|
|
2
|
+
const fs = process.getBuiltinModule('node:fs');
|
|
3
|
+
const os = process.getBuiltinModule('node:os');
|
|
4
|
+
const path = process.getBuiltinModule('node:path');
|
|
5
|
+
const lockDir = resolveLockDir();
|
|
6
|
+
const lockPaths = [path.join(lockDir, params.machineLockFile), path.join(os.homedir(), params.checkoutLockPath)];
|
|
7
|
+
process.stdout.write(`${JSON.stringify({ busy: lockPaths.some(isHeld) })}\n`);
|
|
8
|
+
function isHeld(lockPath) {
|
|
9
|
+
let content;
|
|
10
|
+
let mtimeMs;
|
|
11
|
+
try {
|
|
12
|
+
content = fs.readFileSync(lockPath, 'utf8');
|
|
13
|
+
({ mtimeMs } = fs.statSync(lockPath));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (Date.now() - mtimeMs > params.staleAfterMs)
|
|
19
|
+
return false;
|
|
20
|
+
const pid = readHolderPid(content);
|
|
21
|
+
return pid === undefined || !isProcessGone(pid);
|
|
22
|
+
}
|
|
23
|
+
function isProcessGone(pid) {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function readHolderPid(content) {
|
|
33
|
+
try {
|
|
34
|
+
const value = JSON.parse(content);
|
|
35
|
+
if (typeof value !== 'object' || value === null || !('pid' in value))
|
|
36
|
+
return undefined;
|
|
37
|
+
return typeof value.pid === 'number' ? value.pid : undefined;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function resolveLockDir() {
|
|
44
|
+
const override = process.env[params.lockDirEnvVar];
|
|
45
|
+
if (override !== undefined && override !== '')
|
|
46
|
+
return override;
|
|
47
|
+
const uid = process.getuid?.();
|
|
48
|
+
return uid === undefined ? path.join(os.tmpdir(), 'nmr') : `/tmp/nmr-${uid}`;
|
|
49
|
+
}
|
|
50
|
+
}
|