staysfixed 0.7.2 → 0.9.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/CHANGELOG.md +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +90 -16
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +160 -24
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +55 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
package/src/v2/reference.js
CHANGED
|
@@ -233,6 +233,82 @@ async function readJson(file, fallback) {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Read a JSON file, change it, and write it back with nobody else doing the same thing at
|
|
238
|
+
* the same time.
|
|
239
|
+
*
|
|
240
|
+
* `writeJsonAtomic` makes each individual write whole — nobody ever reads half a file. It
|
|
241
|
+
* does nothing at all about two processes READING the same file, each appending to what they
|
|
242
|
+
* read, and each writing their own version over the other's. Measured on 2026-08-30: six
|
|
243
|
+
* `staysfixed ship` commands started at once on one project, all six reported success, four
|
|
244
|
+
* of them each believed they were cutting the very first reference — and four records
|
|
245
|
+
* survived out of six. This is an MCP server. Two agents shipping at once is not an exotic
|
|
246
|
+
* case, it is the design.
|
|
247
|
+
*
|
|
248
|
+
* The lock is a directory, because creating one either succeeds or fails and never half
|
|
249
|
+
* happens, on every platform this runs on. A lock far older than any write could take is
|
|
250
|
+
* rubbish left behind by a killed process and is taken. Waiting for ever is worse than the
|
|
251
|
+
* bug, so after a long wait it is taken anyway — losing a record is bad, and a release that
|
|
252
|
+
* hangs is worse.
|
|
253
|
+
*
|
|
254
|
+
* @template T
|
|
255
|
+
* @param {string} file
|
|
256
|
+
* @param {(current: T) => T | Promise<T>} change
|
|
257
|
+
* @param {T} fallback
|
|
258
|
+
* @returns {Promise<T>}
|
|
259
|
+
*/
|
|
260
|
+
async function updateJsonAtomic(file, change, fallback) {
|
|
261
|
+
return await withLock(`${file}.lock`, async () => {
|
|
262
|
+
const next = await change(await readJson(file, fallback));
|
|
263
|
+
await writeJsonAtomic(file, next);
|
|
264
|
+
return next;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Do something with nobody else doing it at the same time, across processes.
|
|
270
|
+
*
|
|
271
|
+
* A directory is the lock, because creating one either succeeds or fails and never half
|
|
272
|
+
* happens, on every platform this runs on. A lock far older than the work could take is
|
|
273
|
+
* rubbish left behind by a killed process and is taken. Waiting for ever is worse than the
|
|
274
|
+
* bug it prevents, so after a long wait it is taken anyway: losing a record is bad, and a
|
|
275
|
+
* release that never returns is worse.
|
|
276
|
+
*
|
|
277
|
+
* @template T
|
|
278
|
+
* @param {string} lock
|
|
279
|
+
* @param {() => Promise<T>} work
|
|
280
|
+
* @returns {Promise<T>}
|
|
281
|
+
*/
|
|
282
|
+
async function withLock(lock, work) {
|
|
283
|
+
const STALE_MS = 30_000;
|
|
284
|
+
const GIVE_UP_MS = 15_000;
|
|
285
|
+
await fsp.mkdir(path.dirname(lock), { recursive: true });
|
|
286
|
+
const startedAt = Date.now();
|
|
287
|
+
for (;;) {
|
|
288
|
+
try {
|
|
289
|
+
await fsp.mkdir(lock);
|
|
290
|
+
break;
|
|
291
|
+
} catch {
|
|
292
|
+
let age = 0;
|
|
293
|
+
try {
|
|
294
|
+
age = Date.now() - (await fsp.stat(lock)).mtimeMs;
|
|
295
|
+
} catch {
|
|
296
|
+
continue; // It went away between the failure and the question. Try again.
|
|
297
|
+
}
|
|
298
|
+
if (age > STALE_MS || Date.now() - startedAt > GIVE_UP_MS) {
|
|
299
|
+
await fsp.rm(lock, { recursive: true, force: true }).catch(() => {});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
await new Promise((done) => setTimeout(done, 15 + Math.floor(Math.random() * 35)));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
return await work();
|
|
307
|
+
} finally {
|
|
308
|
+
await fsp.rm(lock, { recursive: true, force: true }).catch(() => {});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
236
312
|
/**
|
|
237
313
|
* A sortable, file-safe id for one cut.
|
|
238
314
|
* @param {Date} [now]
|
|
@@ -308,12 +384,16 @@ export async function measureStability(store, buildId) {
|
|
|
308
384
|
let measuredJourneys = 0;
|
|
309
385
|
|
|
310
386
|
for (const journey of journeys) {
|
|
311
|
-
const
|
|
387
|
+
const looked = await twoRunsOf(store, buildId, journey);
|
|
388
|
+
const pair = looked.pair;
|
|
312
389
|
if (!pair) {
|
|
313
390
|
byJourney.push({
|
|
314
391
|
journey,
|
|
315
392
|
measured: false,
|
|
316
|
-
why:
|
|
393
|
+
why:
|
|
394
|
+
looked.unreadable > 0
|
|
395
|
+
? `${looked.unreadable} of the ${looked.stored} stored runs of this journey could not be read, so the pair needed to measure how steady it was is not there. This build DID walk it more than once — the evidence is on the disk and it is damaged, which is a different problem from never having walked it.`
|
|
396
|
+
: 'This build only ever walked this journey once, so nothing here says how steady it was.',
|
|
317
397
|
paths: 0,
|
|
318
398
|
steady: 0,
|
|
319
399
|
unstableCount: 0,
|
|
@@ -412,36 +492,47 @@ function stabilityNote(measured, journeys, measuredJourneys, steady, unstable) {
|
|
|
412
492
|
* would measure the difference between two afternoons and call it wobble. So: take the
|
|
413
493
|
* newest second run, then the newest first run that came before it.
|
|
414
494
|
*
|
|
495
|
+
* WHY IT SAYS HOW MANY IT COULD NOT READ. One unreadable capture must never take the whole
|
|
496
|
+
* stability record with it, and it never did — but it used to vanish into a `continue`, and
|
|
497
|
+
* a journey that lost its pair that way came back as the same plain `null` a journey that
|
|
498
|
+
* was genuinely only ever walked once comes back as. The caller then wrote "this build only
|
|
499
|
+
* ever walked this journey once, so nothing here says how steady it was" onto the reference,
|
|
500
|
+
* for good, about a journey that was walked twice and whose evidence is sitting on the disk
|
|
501
|
+
* damaged. That sentence sends somebody to walk it again; the truth would have sent them to
|
|
502
|
+
* look at their store.
|
|
503
|
+
*
|
|
415
504
|
* @param {Store} store
|
|
416
505
|
* @param {string} buildId
|
|
417
506
|
* @param {string} journey
|
|
418
|
-
* @returns {Promise<{a: Capture, b: Capture}|null>}
|
|
507
|
+
* @returns {Promise<{pair: {a: Capture, b: Capture}|null, stored: number, unreadable: number}>}
|
|
419
508
|
*/
|
|
420
509
|
async function twoRunsOf(store, buildId, journey) {
|
|
421
510
|
const refs = await listCaptures(store, { buildId, journey });
|
|
422
|
-
if (refs.length < 2) return null;
|
|
511
|
+
if (refs.length < 2) return { pair: null, stored: refs.length, unreadable: 0 };
|
|
423
512
|
|
|
424
513
|
/** @type {Capture[]} */
|
|
425
514
|
const captures = [];
|
|
515
|
+
let unreadable = 0;
|
|
426
516
|
for (const ref of refs) {
|
|
427
517
|
/** @type {Capture|null} */
|
|
428
518
|
let capture = null;
|
|
429
519
|
try {
|
|
430
520
|
capture = await loadCapture(store, ref);
|
|
431
521
|
} catch {
|
|
432
|
-
|
|
522
|
+
unreadable += 1;
|
|
433
523
|
continue;
|
|
434
524
|
}
|
|
435
525
|
if (capture) captures.push(capture);
|
|
526
|
+
else unreadable += 1;
|
|
436
527
|
}
|
|
437
528
|
|
|
438
529
|
for (let i = captures.length - 1; i >= 0; i--) {
|
|
439
530
|
if (captures[i].run !== 'b') continue;
|
|
440
531
|
for (let j = i - 1; j >= 0; j--) {
|
|
441
|
-
if (captures[j].run === 'a') return { a: captures[j], b: captures[i] };
|
|
532
|
+
if (captures[j].run === 'a') return { pair: { a: captures[j], b: captures[i] }, stored: refs.length, unreadable };
|
|
442
533
|
}
|
|
443
534
|
}
|
|
444
|
-
return null;
|
|
535
|
+
return { pair: null, stored: refs.length, unreadable };
|
|
445
536
|
}
|
|
446
537
|
|
|
447
538
|
// ---------------------------------------------------------------------------
|
|
@@ -853,6 +944,32 @@ export async function cutReference(store, opts) {
|
|
|
853
944
|
|
|
854
945
|
await ensureStore(store);
|
|
855
946
|
|
|
947
|
+
// ONE AT A TIME, PER PRODUCT. Everything below reads the current reference, decides
|
|
948
|
+
// whether this build is already it, moves the pointer, retires waivers and writes the log
|
|
949
|
+
// — and until 2026-08-30 nothing stopped two of them doing all of that at once.
|
|
950
|
+
//
|
|
951
|
+
// Measured: six `staysfixed ship` commands started together on one project. All six
|
|
952
|
+
// reported success. FOUR of them each said "Nothing was being compared against before
|
|
953
|
+
// this", because all four had read an empty reference and none had seen the others. Four
|
|
954
|
+
// records survived out of six, and the "already the reference, change nothing" path — the
|
|
955
|
+
// one that stops a release script running twice from writing history twice — never fired
|
|
956
|
+
// once. This is an MCP server: two agents shipping at once is the design, not an exotic
|
|
957
|
+
// case, and the file they were racing on is the one that defines what "working" means.
|
|
958
|
+
return await withLock(path.join(store.dir, `cut.${safeName(product)}.lock`), async () =>
|
|
959
|
+
cutReferenceHoldingTheLock(store, opts, product, buildId),
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The cut itself, with the lock already held.
|
|
965
|
+
*
|
|
966
|
+
* @param {Store} store
|
|
967
|
+
* @param {any} opts
|
|
968
|
+
* @param {string} product
|
|
969
|
+
* @param {string} buildId
|
|
970
|
+
* @returns {Promise<ReferenceCut>}
|
|
971
|
+
*/
|
|
972
|
+
async function cutReferenceHoldingTheLock(store, opts, product, buildId) {
|
|
856
973
|
const decision = await shouldCut(store, product, opts.build);
|
|
857
974
|
if (!decision.ok && opts.force !== true) {
|
|
858
975
|
throw new StaysFixedError(decision.refusal ?? decision.why, {
|
|
@@ -962,20 +1079,24 @@ function summarise(cut, name, decision) {
|
|
|
962
1079
|
*/
|
|
963
1080
|
async function appendToLog(store, cut) {
|
|
964
1081
|
const file = fileIn(store, 'reference-log.json');
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1082
|
+
const archiveFile = fileIn(store, 'reference-log-archive.json');
|
|
1083
|
+
await updateJsonAtomic(
|
|
1084
|
+
file,
|
|
1085
|
+
async (log) => {
|
|
1086
|
+
const all = [...(Array.isArray(log) ? log : []), cut];
|
|
1087
|
+
if (all.length > MAX_LOG_ENTRIES) {
|
|
1088
|
+
const overflow = all.slice(0, all.length - MAX_LOG_ENTRIES);
|
|
1089
|
+
await updateJsonAtomic(
|
|
1090
|
+
archiveFile,
|
|
1091
|
+
(archive) => [...(Array.isArray(archive) ? archive : []), ...overflow],
|
|
1092
|
+
/** @type {ReferenceCut[]} */ ([]),
|
|
1093
|
+
);
|
|
1094
|
+
return all.slice(-MAX_LOG_ENTRIES);
|
|
1095
|
+
}
|
|
1096
|
+
return all;
|
|
1097
|
+
},
|
|
1098
|
+
/** @type {ReferenceCut[]} */ ([]),
|
|
1099
|
+
);
|
|
979
1100
|
}
|
|
980
1101
|
|
|
981
1102
|
/**
|
|
@@ -1032,11 +1153,26 @@ export async function currentReference(store, product) {
|
|
|
1032
1153
|
* The ship hook uses this to work out which product it is looking at when nobody said, and
|
|
1033
1154
|
* a `doctor` or summary uses it to name the products that are not being checked at all.
|
|
1034
1155
|
*
|
|
1156
|
+
* BOTH OF THOSE USES ARE RUINED BY A LIST THAT IS QUIETLY SHORT. A build record that cannot
|
|
1157
|
+
* be read is a build that is not in the list, and a product whose only builds are damaged
|
|
1158
|
+
* records is a product that is not in the list at all — so the ship hook sees one product
|
|
1159
|
+
* where there are two and blesses the wrong one without a word, and the summary says a
|
|
1160
|
+
* product is not being checked when it is. Until 2026-08-30 this asked for the builds without
|
|
1161
|
+
* asking to be told about the ones that were skipped, so neither could have known.
|
|
1162
|
+
*
|
|
1163
|
+
* The problems come back BESIDE the list rather than through a callback nobody has to pass,
|
|
1164
|
+
* because a caller that does not want to hear them now has to say so on purpose.
|
|
1165
|
+
*
|
|
1035
1166
|
* @param {Store} store
|
|
1036
|
-
* @returns {Promise<{product: string, hasReference: boolean, builds: number}[]>}
|
|
1167
|
+
* @returns {Promise<{products: {product: string, hasReference: boolean, builds: number}[], problems: string[]}>}
|
|
1168
|
+
* `problems` is empty when every build folder could be read. Each entry is a plain
|
|
1169
|
+
* sentence naming a folder that could not be, and anything built on this list is weaker
|
|
1170
|
+
* for as long as one is there.
|
|
1037
1171
|
*/
|
|
1038
1172
|
export async function productsKnown(store) {
|
|
1039
|
-
|
|
1173
|
+
/** @type {string[]} */
|
|
1174
|
+
const problems = [];
|
|
1175
|
+
const builds = await listBuilds(store, { onProblem: (message) => problems.push(message) });
|
|
1040
1176
|
/** @type {Map<string, {product: string, hasReference: boolean, builds: number}>} */
|
|
1041
1177
|
const seen = new Map();
|
|
1042
1178
|
for (const record of builds) {
|
|
@@ -1047,5 +1183,5 @@ export async function productsKnown(store) {
|
|
|
1047
1183
|
if (record.isReference) entry.hasReference = true;
|
|
1048
1184
|
seen.set(product, entry);
|
|
1049
1185
|
}
|
|
1050
|
-
return [...seen.values()].sort((a, b) => a.product.localeCompare(b.product));
|
|
1186
|
+
return { products: [...seen.values()].sort((a, b) => a.product.localeCompare(b.product)), problems };
|
|
1051
1187
|
}
|
package/src/v2/remote.js
CHANGED
|
@@ -57,9 +57,30 @@ import { howLongItTook, joinPath, notCovered, observation, sizeBucket, timeBucke
|
|
|
57
57
|
*/
|
|
58
58
|
export const SENTINEL = '#SF#';
|
|
59
59
|
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* The kinds of far side this file knows how to start.
|
|
62
|
+
*
|
|
63
|
+
* Checked at runtime rather than only in the types, because the types are not there when it
|
|
64
|
+
* matters. `farSideCommand` branches on 'windows' and treats everything else as posix, so a
|
|
65
|
+
* kind spelled 'win' used to be handed the Node bootstrap and sent to a Windows box, where it
|
|
66
|
+
* failed several seconds later as "node: not found" — a message about the far machine for a
|
|
67
|
+
* mistake made on this one.
|
|
68
|
+
*/
|
|
61
69
|
export const RUNNER_KINDS = /** @type {const} */ (['posix', 'windows']);
|
|
62
70
|
|
|
71
|
+
/**
|
|
72
|
+
* @param {unknown} kind
|
|
73
|
+
* @returns {RunnerKind}
|
|
74
|
+
*/
|
|
75
|
+
function checkKind(kind) {
|
|
76
|
+
if (!(/** @type {readonly unknown[]} */ (RUNNER_KINDS).includes(kind))) {
|
|
77
|
+
throw new StaysFixedError(`There is no far side called "${String(kind)}".`, {
|
|
78
|
+
hint: `The kinds this file can start are: ${RUNNER_KINDS.join(', ')}.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return /** @type {RunnerKind} */ (kind);
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
/** @typedef {'posix'|'windows'} RunnerKind */
|
|
64
85
|
|
|
65
86
|
/**
|
|
@@ -191,7 +212,7 @@ export function nodeBootstrap() {
|
|
|
191
212
|
* @returns {string}
|
|
192
213
|
*/
|
|
193
214
|
export function farSideCommand(kind, opts = {}) {
|
|
194
|
-
if (kind === 'windows') {
|
|
215
|
+
if (checkKind(kind) === 'windows') {
|
|
195
216
|
const encoded = encodePowerShell(powerShellBootstrap());
|
|
196
217
|
if (opts.psPath) return `exec "${opts.psPath}" -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
|
|
197
218
|
const candidates = POWERSHELL_PATHS.map((p) => `"${p}"`).join(' ');
|
|
@@ -302,6 +323,12 @@ export function makeFrames() {
|
|
|
302
323
|
* Written as text rather than shipped as a file because it must never be installed. It exists
|
|
303
324
|
* in the memory of one `node -e` for the length of one run.
|
|
304
325
|
*
|
|
326
|
+
* `read` says how long the file really was and whether it cut it. It used to hand back the
|
|
327
|
+
* first 64K with nothing to say it had stopped there, and two builds of a product whose file
|
|
328
|
+
* differs only after byte 65536 would then be compared, byte for byte, on two identical
|
|
329
|
+
* halves — and reported as unchanged. A cap that cannot be seen from the outside is not a cap,
|
|
330
|
+
* it is a wrong answer.
|
|
331
|
+
*
|
|
305
332
|
* @returns {string}
|
|
306
333
|
*/
|
|
307
334
|
export function posixAgentScript() {
|
|
@@ -331,7 +358,11 @@ const ops = {
|
|
|
331
358
|
done({ ok: true, found });
|
|
332
359
|
},
|
|
333
360
|
read: (req, done) => {
|
|
334
|
-
try {
|
|
361
|
+
try {
|
|
362
|
+
const all = fs.readFileSync(req.file, 'utf8');
|
|
363
|
+
const cap = req.limit || 65536;
|
|
364
|
+
done({ ok: true, text: all.slice(0, cap), length: all.length, truncated: all.length > cap });
|
|
365
|
+
}
|
|
335
366
|
catch (e) { done({ ok: false, error: String(e.message) }); }
|
|
336
367
|
},
|
|
337
368
|
sh: (req, done) => {
|
|
@@ -422,7 +453,7 @@ process.stdin.on('end', () => process.exit(0));
|
|
|
422
453
|
* @param {RemoteRunnerOptions} opts
|
|
423
454
|
*/
|
|
424
455
|
export function remoteRunner(opts) {
|
|
425
|
-
const kind = opts.kind ?? 'posix';
|
|
456
|
+
const kind = checkKind(opts.kind ?? 'posix');
|
|
426
457
|
const host = opts.host;
|
|
427
458
|
const say = opts.log ?? (() => {});
|
|
428
459
|
const surface = opts.surface;
|
|
@@ -740,7 +771,13 @@ export function describeFacts(f) {
|
|
|
740
771
|
/**
|
|
741
772
|
* @typedef {object} RemoteDescription
|
|
742
773
|
* @property {string} host
|
|
743
|
-
* @property {boolean} reachable
|
|
774
|
+
* @property {boolean} reachable A shell on that machine answers. NOT the same
|
|
775
|
+
* question as `runnerStarted`, and folding the two together was a real bug: a machine with
|
|
776
|
+
* ssh working and no Node on it came back as "could not be reached", and the fix offered
|
|
777
|
+
* was to go and check the ssh config that already works.
|
|
778
|
+
* @property {boolean} runnerStarted The small program this tool pushes down the
|
|
779
|
+
* connection actually ran there. False on a reachable machine means Node is missing or too
|
|
780
|
+
* old, and everything below is unknown for that reason rather than because nothing answered.
|
|
744
781
|
* @property {string} how Plain English: what answered, or why nothing did.
|
|
745
782
|
* @property {string|null} os
|
|
746
783
|
* @property {boolean} windows A real Windows desktop sits behind this host.
|
|
@@ -767,20 +804,35 @@ const TOOLS_WORTH_ASKING_ABOUT = ['node', 'git', 'adb', 'emulator', 'java', 'pyt
|
|
|
767
804
|
*
|
|
768
805
|
* It never throws. Somebody running doctor is already stuck.
|
|
769
806
|
*
|
|
807
|
+
* WHAT THE CALLER MAY ALREADY KNOW. Starting the far side needs Node on that machine, so a
|
|
808
|
+
* machine with a perfectly good shell and no Node used to come back from here as "it could
|
|
809
|
+
* not be reached" — and `missingOn` then told somebody to go and fix the ssh config that
|
|
810
|
+
* already works. A caller that has proved the shell answers with something cheaper (doctor
|
|
811
|
+
* dials every host with a plain `echo` before it gets here) says so with `answered`, and
|
|
812
|
+
* hands over whatever its own probe found, so a failure to start the runner is reported as
|
|
813
|
+
* the missing Node it actually is.
|
|
814
|
+
*
|
|
770
815
|
* @param {string} host
|
|
771
|
-
* @param {
|
|
816
|
+
* @param {object} [opts]
|
|
817
|
+
* @param {number} [opts.timeoutMs] How long one request may take. Default 20s.
|
|
818
|
+
* @param {number} [opts.windowsTimeoutMs] The Windows probe on its own, which pays for
|
|
819
|
+
* PowerShell's start-up and is therefore slower than everything else. Default 45s.
|
|
820
|
+
* @param {(m: string) => void} [opts.log]
|
|
821
|
+
* @param {boolean} [opts.answered] The caller has already proved a shell answers here.
|
|
822
|
+
* @param {string|null} [opts.powershell] A powershell.exe path the caller's own probe found.
|
|
772
823
|
* @returns {Promise<RemoteDescription>}
|
|
773
824
|
*/
|
|
774
825
|
export async function describeRemote(host, opts = {}) {
|
|
775
826
|
/** @type {RemoteDescription} */
|
|
776
827
|
const out = {
|
|
777
828
|
host,
|
|
778
|
-
reachable:
|
|
779
|
-
|
|
829
|
+
reachable: opts.answered === true,
|
|
830
|
+
runnerStarted: false,
|
|
831
|
+
how: opts.answered === true ? 'a shell on it answered when the caller dialled it' : 'it did not answer',
|
|
780
832
|
os: null,
|
|
781
|
-
windows:
|
|
833
|
+
windows: typeof opts.powershell === 'string' && opts.powershell !== '',
|
|
782
834
|
windowsVersion: null,
|
|
783
|
-
powershell: null,
|
|
835
|
+
powershell: opts.powershell ?? null,
|
|
784
836
|
desktopLoggedIn: null,
|
|
785
837
|
desktopLocked: null,
|
|
786
838
|
tools: {},
|
|
@@ -792,6 +844,7 @@ export async function describeRemote(host, opts = {}) {
|
|
|
792
844
|
try {
|
|
793
845
|
const facts = await runner.open();
|
|
794
846
|
out.reachable = true;
|
|
847
|
+
out.runnerStarted = true;
|
|
795
848
|
out.how = 'it answered over ssh with the key already in the config';
|
|
796
849
|
out.os = [facts.platform, facts.release].filter(Boolean).join(' ') || null;
|
|
797
850
|
|
|
@@ -799,10 +852,21 @@ export async function describeRemote(host, opts = {}) {
|
|
|
799
852
|
out.tools = /** @type {Record<string, string|null>} */ (found.found ?? {});
|
|
800
853
|
|
|
801
854
|
// The Windows question, asked of the filesystem rather than of $PATH. See POWERSHELL_PATHS.
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
855
|
+
// Skipped entirely when the caller's own probe already found the path: it is the same
|
|
856
|
+
// question and asking it twice only creates a second chance for the two to disagree.
|
|
857
|
+
let psPath = out.powershell;
|
|
858
|
+
if (!psPath) {
|
|
859
|
+
const test = await runner.shell(
|
|
860
|
+
POWERSHELL_PATHS.map((p) => `if [ -x "${p}" ]; then echo "${p}"; fi`).join('; ')
|
|
861
|
+
);
|
|
862
|
+
psPath = test.stdout.split('\n').map((l) => l.trim()).find((l) => l !== '') ?? null;
|
|
863
|
+
// A probe that was killed comes back with an empty answer, which is the same empty
|
|
864
|
+
// answer a machine with no Windows on it gives. Reported as "no Windows here", that
|
|
865
|
+
// loses somebody the Windows runner they already have, and nothing anywhere says why.
|
|
866
|
+
if (!psPath && test.killed) {
|
|
867
|
+
out.notes.push('Whether there is a Windows desktop behind this machine is unknown: the question timed out rather than came back "no".');
|
|
868
|
+
}
|
|
869
|
+
}
|
|
806
870
|
if (psPath) {
|
|
807
871
|
out.powershell = psPath;
|
|
808
872
|
out.windows = true;
|
|
@@ -819,7 +883,10 @@ export async function describeRemote(host, opts = {}) {
|
|
|
819
883
|
].join('; ');
|
|
820
884
|
const probe = await runner.shell(
|
|
821
885
|
`"${psPath}" -NoProfile -NonInteractive -EncodedCommand ${encodePowerShell(script)}`,
|
|
822
|
-
|
|
886
|
+
// Its own knob, because this one call costs PowerShell's start-up and everything else
|
|
887
|
+
// here costs a round trip. Folding it into `timeoutMs` would mean a caller that wants
|
|
888
|
+
// the rest to be quick has to allow forty-five seconds for it too.
|
|
889
|
+
{ timeoutMs: opts.windowsTimeoutMs ?? 45_000 }
|
|
823
890
|
);
|
|
824
891
|
const line = probe.stdout.split('\n').map((l) => l.trim()).filter(Boolean).pop() ?? '';
|
|
825
892
|
const [caption, version, explorers, logonui] = line.split('|');
|
|
@@ -828,12 +895,26 @@ export async function describeRemote(host, opts = {}) {
|
|
|
828
895
|
out.desktopLoggedIn = Number(explorers) > 0;
|
|
829
896
|
out.desktopLocked = Number(logonui) > 0;
|
|
830
897
|
} else {
|
|
831
|
-
|
|
898
|
+
// Which of the two it was matters to whoever reads it: a probe that ran out of time
|
|
899
|
+
// says nothing about the machine, and a probe that answered something unreadable says
|
|
900
|
+
// something is wrong with PowerShell there. Both used to arrive as one sentence.
|
|
901
|
+
out.notes.push(
|
|
902
|
+
probe.killed
|
|
903
|
+
? `PowerShell is there and the question about the desktop was still running after ${Math.round((opts.windowsTimeoutMs ?? 45_000) / 1000)} seconds, so how much of Windows is usable is unknown — not "not much".`
|
|
904
|
+
: 'PowerShell is there but did not answer a question about the desktop, so how much of Windows is usable is unknown.',
|
|
905
|
+
);
|
|
832
906
|
}
|
|
833
907
|
}
|
|
834
908
|
await runner.close();
|
|
835
909
|
} catch (error) {
|
|
836
|
-
|
|
910
|
+
const why = error instanceof RemoteLinkLost ? error.message : String(error);
|
|
911
|
+
// Two different failures, and they were being reported as one. A machine that never
|
|
912
|
+
// answered is an ssh problem. A machine that answers and could not start the runner is a
|
|
913
|
+
// Node problem, and saying "it could not be reached" about it sends somebody to fix a
|
|
914
|
+
// connection that is working — while the one thing that would help goes unmentioned.
|
|
915
|
+
out.how = out.reachable
|
|
916
|
+
? `a shell on it answers, but the small program this tool sends down the connection would not start there (${why})`
|
|
917
|
+
: error instanceof RemoteLinkLost ? why : `it could not be reached (${why})`;
|
|
837
918
|
try { await runner.close(); } catch { /* nothing to close */ }
|
|
838
919
|
}
|
|
839
920
|
|
|
@@ -868,8 +949,16 @@ export function missingOn(d) {
|
|
|
868
949
|
if (!d.tools.node) {
|
|
869
950
|
missing.push({
|
|
870
951
|
what: 'Node on that machine',
|
|
952
|
+
// Two ways to get here and they deserve different words. Either the runner started and
|
|
953
|
+
// `which node` came back empty, or the runner never started at all — in which case the
|
|
954
|
+
// tools list is empty because nothing could ask, and a Node that is present but too old
|
|
955
|
+
// looks exactly the same from here. Saying "install Node" at somebody who has Node 12
|
|
956
|
+
// and is not told the version matters is how a person spends an afternoon on the wrong
|
|
957
|
+
// thing.
|
|
871
958
|
unlocks: 'the general remote runner, which is how any platform there is walked',
|
|
872
|
-
howToGet:
|
|
959
|
+
howToGet: d.reachable && !d.runnerStarted
|
|
960
|
+
? `The program this tool sends down would not start there, which is Node missing or too old. Check it with: ssh ${d.host} 'node --version' — it has to be 22 or newer. Install or upgrade it with whatever that machine installs packages with, for example: ssh ${d.host} 'sudo apt-get install -y nodejs'`
|
|
961
|
+
: `ssh ${d.host} 'sudo apt-get install -y nodejs' — or whatever that machine installs packages with.`,
|
|
873
962
|
blocking: true,
|
|
874
963
|
});
|
|
875
964
|
}
|
|
@@ -901,6 +990,12 @@ export function notesOn(d) {
|
|
|
901
990
|
const notes = [];
|
|
902
991
|
if (!d.reachable) return notes;
|
|
903
992
|
notes.push('Nothing is installed on that machine. The program that does the watching is sent down the connection each run and dies with it.');
|
|
993
|
+
// Said out loud, because everything else in this description is empty for one reason and an
|
|
994
|
+
// empty list that does not say why reads as "there is nothing there". There may be plenty
|
|
995
|
+
// there; nothing could ask.
|
|
996
|
+
if (!d.runnerStarted) {
|
|
997
|
+
notes.push(`A shell on ${d.host} answers, but the program this tool sends down the connection did not start, so nothing below was actually asked of that machine — what is installed on it is unknown rather than absent.`);
|
|
998
|
+
}
|
|
904
999
|
if (d.windows) {
|
|
905
1000
|
notes.push(`Windows is reached through ${d.powershell}, called from the Linux side. That absolute path is used deliberately: powershell.exe is not on the path of a non-interactive ssh session even when the machine is configured to add it.`);
|
|
906
1001
|
notes.push('Windows shows one desktop, so two builds can never run there at the same time. Runs are one after the other, and that is a real weakening of the same-machine guarantee, not a detail.');
|