@martintrojer/murmur 0.2.1 → 0.2.2
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/ARCHITECTURE.md +129 -81
- package/CHANGELOG.md +98 -0
- package/README.md +105 -70
- package/dist/cli.js +834 -269
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +54 -2
- package/dist/index.js +19 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -915,6 +915,8 @@ function parseSnapshot(input) {
|
|
|
915
915
|
// src/collector.ts
|
|
916
916
|
var MAX_CONCURRENT_PEERS = 8;
|
|
917
917
|
var COLLECT_DEADLINE_MS = 4e3;
|
|
918
|
+
var COLLECT_FLOOR_MS = 3e4;
|
|
919
|
+
var COLLECT_JITTER_MS = 2e4;
|
|
918
920
|
async function mapSettled(items, limit, task, deadline) {
|
|
919
921
|
const results = new Array(items.length);
|
|
920
922
|
let cursor = 0;
|
|
@@ -936,6 +938,14 @@ async function mapSettled(items, limit, task, deadline) {
|
|
|
936
938
|
await (stop ? Promise.race([pool, stop]) : pool);
|
|
937
939
|
return results;
|
|
938
940
|
}
|
|
941
|
+
function duePeers(peers, now, floorMs, random) {
|
|
942
|
+
if (floorMs <= 0) return [...peers];
|
|
943
|
+
return peers.filter((peer) => {
|
|
944
|
+
if (peer.last_attempt_at === null) return true;
|
|
945
|
+
const jitter = (random() - 0.5) * COLLECT_JITTER_MS;
|
|
946
|
+
return now - peer.last_attempt_at >= floorMs + jitter;
|
|
947
|
+
});
|
|
948
|
+
}
|
|
939
949
|
function isUnreachable(message) {
|
|
940
950
|
return /Host is down|No route to host|Connection refused|Connection timed out|Connection closed|Operation timed out|Network is unreachable|Name or service not known|Could not resolve hostname|timed out after/i.test(
|
|
941
951
|
message
|
|
@@ -959,11 +969,12 @@ function describeFailure(peer, message) {
|
|
|
959
969
|
const detail = collapsed.length > 160 ? `${collapsed.slice(0, 157)}...` : collapsed;
|
|
960
970
|
return `${peer}: ${detail}`;
|
|
961
971
|
}
|
|
962
|
-
async function collect(store, channel, now = Date.now(),
|
|
972
|
+
async function collect(store, channel, now = Date.now(), options = {}) {
|
|
973
|
+
const { deadline, mux = tmux, floorMs = 0, random = Math.random } = options;
|
|
963
974
|
const results = [];
|
|
964
975
|
let timer;
|
|
965
976
|
try {
|
|
966
|
-
const peers = store.peers();
|
|
977
|
+
const peers = duePeers(store.peers(), now, floorMs, random);
|
|
967
978
|
const bounded = deadline ?? new Promise((resolve) => {
|
|
968
979
|
timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
|
|
969
980
|
timer.unref?.();
|
|
@@ -1054,23 +1065,785 @@ function requireIdentity() {
|
|
|
1054
1065
|
process.exitCode = 1;
|
|
1055
1066
|
return null;
|
|
1056
1067
|
}
|
|
1057
|
-
|
|
1058
|
-
// src/cli/collect.ts
|
|
1059
|
-
function registerCollect(program2) {
|
|
1060
|
-
program2.command("collect").description("Fetch each peer's snapshot").option("-q, --quiet", "report nothing, not even unreachable peers").action(async (options) => {
|
|
1061
|
-
if (!requireIdentity()) return;
|
|
1068
|
+
|
|
1069
|
+
// src/cli/collect.ts
|
|
1070
|
+
function registerCollect(program2) {
|
|
1071
|
+
program2.command("collect").description("Fetch each peer's snapshot").option("-q, --quiet", "report nothing, not even unreachable peers").action(async (options) => {
|
|
1072
|
+
if (!requireIdentity()) return;
|
|
1073
|
+
const store = openStore();
|
|
1074
|
+
try {
|
|
1075
|
+
const results = await collect(store, ssh);
|
|
1076
|
+
if (options.quiet) return;
|
|
1077
|
+
for (const result of results) {
|
|
1078
|
+
if (result.ok || !result.error) continue;
|
|
1079
|
+
process.stderr.write(`murmur: ${describeFailure(result.peer, result.error)}
|
|
1080
|
+
`);
|
|
1081
|
+
}
|
|
1082
|
+
if (results.some((result) => !result.ok && !result.unreachable)) {
|
|
1083
|
+
process.exitCode = 1;
|
|
1084
|
+
}
|
|
1085
|
+
} finally {
|
|
1086
|
+
store.close();
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// src/cli/peer.ts
|
|
1092
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
1093
|
+
import { homedir as homedir2 } from "os";
|
|
1094
|
+
import { join as join3 } from "path";
|
|
1095
|
+
var SNAPSHOT_VERSION = 1;
|
|
1096
|
+
function parseSshHosts(config) {
|
|
1097
|
+
const hosts = [];
|
|
1098
|
+
for (const line of config.split("\n")) {
|
|
1099
|
+
const tokens = line.replace(/#.*$/, "").trim().split(/\s+/);
|
|
1100
|
+
if (tokens[0]?.toLowerCase() !== "host") continue;
|
|
1101
|
+
for (const host of tokens.slice(1)) {
|
|
1102
|
+
if (!/[*?!]/.test(host)) hosts.push(host);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return hosts;
|
|
1106
|
+
}
|
|
1107
|
+
function sshHosts() {
|
|
1108
|
+
try {
|
|
1109
|
+
return parseSshHosts(readFileSync2(join3(homedir2(), ".ssh", "config"), "utf8"));
|
|
1110
|
+
} catch {
|
|
1111
|
+
return [];
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
function lastSeen(fetchedAt, now) {
|
|
1115
|
+
if (fetchedAt === null) return "never";
|
|
1116
|
+
if (freshness(fetchedAt, now, STALENESS_MS) === "fresh") return "just now";
|
|
1117
|
+
return `${age(now - fetchedAt)} ago`;
|
|
1118
|
+
}
|
|
1119
|
+
function versionCell(peer, ours = SNAPSHOT_VERSION) {
|
|
1120
|
+
if (peer.murmur_version === null && peer.snapshot_version === null) {
|
|
1121
|
+
return { text: "unknown", incompatible: false };
|
|
1122
|
+
}
|
|
1123
|
+
const version = peer.murmur_version ?? "unreported";
|
|
1124
|
+
const incompatible = peer.snapshot_version !== null && peer.snapshot_version !== ours;
|
|
1125
|
+
return {
|
|
1126
|
+
text: incompatible ? `${version} (snapshot ${peer.snapshot_version} \u2260 ${ours})` : version,
|
|
1127
|
+
incompatible
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
function formatTable(rows) {
|
|
1131
|
+
const widths = [];
|
|
1132
|
+
for (const row of rows) {
|
|
1133
|
+
row.forEach((cell, index) => {
|
|
1134
|
+
widths[index] = Math.max(widths[index] ?? 0, cell.length);
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
return rows.map(
|
|
1138
|
+
(row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd()
|
|
1139
|
+
).map((line) => `${line}
|
|
1140
|
+
`).join("");
|
|
1141
|
+
}
|
|
1142
|
+
function peerAddDecision(input) {
|
|
1143
|
+
const { name, target, snapshot, selfHostId, peers } = input;
|
|
1144
|
+
if (!snapshot) return null;
|
|
1145
|
+
if (snapshot.host_id === selfHostId) {
|
|
1146
|
+
return `${target} is this node; not adding it as a peer
|
|
1147
|
+
`;
|
|
1148
|
+
}
|
|
1149
|
+
const existing = peers.find(
|
|
1150
|
+
(candidate) => candidate.host_id === snapshot.host_id && candidate.name !== name
|
|
1151
|
+
);
|
|
1152
|
+
if (existing) {
|
|
1153
|
+
return `${target} is already configured as peer "${existing.name}" (${snapshot.display_name}); remove it first to rename
|
|
1154
|
+
`;
|
|
1155
|
+
}
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
function registerPeer(program2) {
|
|
1159
|
+
const peer = program2.command("peer").description("Manage peers");
|
|
1160
|
+
peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
|
|
1161
|
+
const store = openStore();
|
|
1162
|
+
try {
|
|
1163
|
+
let snapshot = null;
|
|
1164
|
+
try {
|
|
1165
|
+
snapshot = parseSnapshot(await ssh.exec(target, ["murmur", "export"]));
|
|
1166
|
+
} catch {
|
|
1167
|
+
snapshot = null;
|
|
1168
|
+
}
|
|
1169
|
+
const refusal = peerAddDecision({
|
|
1170
|
+
name,
|
|
1171
|
+
target,
|
|
1172
|
+
snapshot,
|
|
1173
|
+
selfHostId: loadIdentity()?.host_id ?? null,
|
|
1174
|
+
peers: store.peers()
|
|
1175
|
+
});
|
|
1176
|
+
if (refusal) {
|
|
1177
|
+
process.stderr.write(refusal);
|
|
1178
|
+
process.exitCode = 1;
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
store.addPeer(name, target);
|
|
1182
|
+
if (snapshot) {
|
|
1183
|
+
store.replacePeerSnapshot(name, { ok: true, snapshot, at: Date.now() });
|
|
1184
|
+
}
|
|
1185
|
+
process.stdout.write(
|
|
1186
|
+
snapshot ? `Added ${name} (${snapshot.display_name})
|
|
1187
|
+
` : `Added ${name} (identity pending)
|
|
1188
|
+
`
|
|
1189
|
+
);
|
|
1190
|
+
} finally {
|
|
1191
|
+
store.close();
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
peer.command("remove").description("Remove a peer").argument("<name>", "peer to remove").action((name) => {
|
|
1195
|
+
const store = openStore();
|
|
1196
|
+
try {
|
|
1197
|
+
if (store.removePeer(name)) process.stdout.write(`Removed ${name}
|
|
1198
|
+
`);
|
|
1199
|
+
else {
|
|
1200
|
+
process.stderr.write(`no such peer: ${name}
|
|
1201
|
+
`);
|
|
1202
|
+
process.exitCode = 1;
|
|
1203
|
+
}
|
|
1204
|
+
} finally {
|
|
1205
|
+
store.close();
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
peer.command("list").description("List peers; --all adds SSH hosts that could become peers").option("--json", "print JSON").option("-a, --all", "also show SSH hosts that are not peers yet").action((options) => {
|
|
1209
|
+
const store = openStore();
|
|
1210
|
+
try {
|
|
1211
|
+
const peers = store.peers();
|
|
1212
|
+
const configured = new Map(peers.map((entry) => [entry.target, entry]));
|
|
1213
|
+
const discovered = options.all ? sshHosts().filter((host) => !configured.has(host)) : [];
|
|
1214
|
+
const now = Date.now();
|
|
1215
|
+
const rows = [...configured.keys(), ...discovered].map((target) => {
|
|
1216
|
+
const entry = configured.get(target);
|
|
1217
|
+
return {
|
|
1218
|
+
// The handle other commands take: a peer's name, or for a host that
|
|
1219
|
+
// is not one yet, the ssh host `peer add` wants.
|
|
1220
|
+
name: entry?.name ?? target,
|
|
1221
|
+
target,
|
|
1222
|
+
peer: entry !== void 0,
|
|
1223
|
+
// What the node called itself. Shown, never typed: it can be a
|
|
1224
|
+
// container id.
|
|
1225
|
+
hostname: entry?.display_name ?? null,
|
|
1226
|
+
// Being a peer is not the same as being reachable, and the old
|
|
1227
|
+
// output said only the first. A node asleep for twelve hours read
|
|
1228
|
+
// exactly like one polled a second ago.
|
|
1229
|
+
last_seen: entry === void 0 ? null : lastSeen(entry.fetched_at, now),
|
|
1230
|
+
// A warm ControlMaster socket makes a collect ~10ms instead of
|
|
1231
|
+
// ~170ms, and is the only path that works on a host demanding a
|
|
1232
|
+
// hardware-token touch per connection. A speed hint, never a
|
|
1233
|
+
// requirement -- which is why the old bare `[x]` / `[ ]` was
|
|
1234
|
+
// unreadable: it never said what was being checked.
|
|
1235
|
+
//
|
|
1236
|
+
// Safe for every row: `ssh -O check` talks to a local socket and
|
|
1237
|
+
// never dials, so a host that is down or does not exist answers in
|
|
1238
|
+
// ~16ms. Measured.
|
|
1239
|
+
ssh: hasWarmSocket(target) ? "warm" : "cold",
|
|
1240
|
+
// What it is running, or undefined when nothing is known -- either
|
|
1241
|
+
// because the host is not a peer yet, or because it is a peer that
|
|
1242
|
+
// has never answered. Undefined is what drops the column, so the
|
|
1243
|
+
// test is "has anything told us", not "is this configured": a fleet
|
|
1244
|
+
// of asleep peers must not buy a column of "unknown".
|
|
1245
|
+
version: entry === void 0 || entry.murmur_version === null && entry.snapshot_version === null ? void 0 : versionCell(entry),
|
|
1246
|
+
// Named where it can be acted on: a peer that answered with a bad
|
|
1247
|
+
// document is reachable but broken, which is an operator task and
|
|
1248
|
+
// reads nothing like a sleeping laptop.
|
|
1249
|
+
error: entry?.last_error ?? null
|
|
1250
|
+
};
|
|
1251
|
+
});
|
|
1252
|
+
if (options.json) {
|
|
1253
|
+
process.stdout.write(`${JSON.stringify(rows)}
|
|
1254
|
+
`);
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
if (rows.length === 0) {
|
|
1258
|
+
process.stdout.write(
|
|
1259
|
+
options.all ? "no peers configured, and no hosts in ~/.ssh/config\n" : "no peers configured. See what could be added with: murmur peer list --all\n"
|
|
1260
|
+
);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
const showPeerColumn = rows.some((row) => !row.peer);
|
|
1264
|
+
const showVersionColumn = rows.some((row) => row.version !== void 0);
|
|
1265
|
+
process.stdout.write(
|
|
1266
|
+
formatTable([
|
|
1267
|
+
[
|
|
1268
|
+
"NAME",
|
|
1269
|
+
"TARGET",
|
|
1270
|
+
...showPeerColumn ? ["PEER"] : [],
|
|
1271
|
+
"HOSTNAME",
|
|
1272
|
+
...showVersionColumn ? ["VERSION"] : [],
|
|
1273
|
+
"LAST SEEN",
|
|
1274
|
+
"SSH"
|
|
1275
|
+
],
|
|
1276
|
+
...rows.map((row) => [
|
|
1277
|
+
row.name,
|
|
1278
|
+
row.target,
|
|
1279
|
+
...showPeerColumn ? [row.peer ? "yes" : "-"] : [],
|
|
1280
|
+
row.hostname ?? "unknown",
|
|
1281
|
+
...showVersionColumn ? [row.version?.text ?? "-"] : [],
|
|
1282
|
+
row.last_seen ?? "-",
|
|
1283
|
+
row.ssh
|
|
1284
|
+
])
|
|
1285
|
+
])
|
|
1286
|
+
);
|
|
1287
|
+
const incompatible = rows.filter((row) => row.version?.incompatible);
|
|
1288
|
+
if (incompatible.length > 0) {
|
|
1289
|
+
process.stdout.write(
|
|
1290
|
+
`
|
|
1291
|
+
${incompatible.length} peer${incompatible.length === 1 ? "" : "s"} speak an incompatible snapshot version; state will not sync until murmur versions match: ${incompatible.map((row) => row.name).join(", ")}
|
|
1292
|
+
`
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
const broken = rows.filter((row) => row.error);
|
|
1296
|
+
for (const row of broken) {
|
|
1297
|
+
process.stdout.write(`
|
|
1298
|
+
${row.name}: last attempt failed -- ${row.error}
|
|
1299
|
+
`);
|
|
1300
|
+
}
|
|
1301
|
+
const addable = rows.filter((row) => !row.peer).length;
|
|
1302
|
+
if (addable > 0) {
|
|
1303
|
+
process.stdout.write(
|
|
1304
|
+
`
|
|
1305
|
+
${addable} host${addable === 1 ? "" : "s"} not yet a peer. Add one with: murmur peer add <name>
|
|
1306
|
+
`
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
} finally {
|
|
1310
|
+
store.close();
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// src/doctor.ts
|
|
1316
|
+
var DOCTOR_DEADLINE_MS = 15e3;
|
|
1317
|
+
var OUTDATED = /\berror: unknown (?:option|command)\b/i;
|
|
1318
|
+
function failure(target, reason, error) {
|
|
1319
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1320
|
+
return { ok: false, target, reason, detail: describeFailure(target, message) };
|
|
1321
|
+
}
|
|
1322
|
+
function parseRoster(input) {
|
|
1323
|
+
let parsed;
|
|
1324
|
+
try {
|
|
1325
|
+
parsed = JSON.parse(input);
|
|
1326
|
+
} catch {
|
|
1327
|
+
throw new Error("peer list did not answer with JSON");
|
|
1328
|
+
}
|
|
1329
|
+
if (!Array.isArray(parsed)) throw new Error("peer list: expected an array of peers");
|
|
1330
|
+
return parsed.map((row, index) => {
|
|
1331
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
|
1332
|
+
throw new Error(`peer list[${index}]: expected an object`);
|
|
1333
|
+
}
|
|
1334
|
+
const record = row;
|
|
1335
|
+
const { name, target, hostname: hostname2 } = record;
|
|
1336
|
+
if (typeof name !== "string" || name === "") {
|
|
1337
|
+
throw new Error(`peer list[${index}]: expected a non-empty name`);
|
|
1338
|
+
}
|
|
1339
|
+
if (typeof target !== "string" || target === "") {
|
|
1340
|
+
throw new Error(`peer list[${index}]: expected a non-empty target`);
|
|
1341
|
+
}
|
|
1342
|
+
return { name, target, hostname: typeof hostname2 === "string" ? hostname2 : null };
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
async function surveyPeer(channel, target) {
|
|
1346
|
+
let host_id;
|
|
1347
|
+
let display_name;
|
|
1348
|
+
let murmur_version;
|
|
1349
|
+
try {
|
|
1350
|
+
const snapshot = parseSnapshot(await channel.exec(target, ["murmur", "export"]));
|
|
1351
|
+
({ host_id, display_name, murmur_version } = snapshot);
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
return failure(target, "identity-unavailable", error);
|
|
1354
|
+
}
|
|
1355
|
+
let output;
|
|
1356
|
+
try {
|
|
1357
|
+
output = await channel.exec(target, ["murmur", "peer", "list", "--json"]);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1360
|
+
return failure(
|
|
1361
|
+
target,
|
|
1362
|
+
OUTDATED.test(message) ? "roster-unsupported" : "roster-unavailable",
|
|
1363
|
+
error
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
try {
|
|
1367
|
+
return { ok: true, target, host_id, display_name, murmur_version, roster: parseRoster(output) };
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
return failure(target, "roster-invalid", error);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
function withoutTargetPrefix(target, detail) {
|
|
1373
|
+
return detail.startsWith(`${target}: `) ? detail.slice(target.length + 2) : detail;
|
|
1374
|
+
}
|
|
1375
|
+
function rowIsAbout(row, host) {
|
|
1376
|
+
if (host.display_name === null) return false;
|
|
1377
|
+
if (row.hostname !== null) return row.hostname === host.display_name;
|
|
1378
|
+
return row.name === host.display_name || row.target === host.display_name;
|
|
1379
|
+
}
|
|
1380
|
+
function diagnose(local, surveys) {
|
|
1381
|
+
const findings = [];
|
|
1382
|
+
const answered = surveys.filter((survey) => survey.ok);
|
|
1383
|
+
const byTarget = new Map(answered.map((survey) => [survey.target, survey]));
|
|
1384
|
+
const nameOf = (target) => local.peers.find((peer) => peer.target === target)?.name ?? target;
|
|
1385
|
+
const self = {
|
|
1386
|
+
host_id: local.host_id,
|
|
1387
|
+
display_name: local.display_name,
|
|
1388
|
+
localName: null,
|
|
1389
|
+
target: null
|
|
1390
|
+
};
|
|
1391
|
+
const hosts = [self];
|
|
1392
|
+
for (const peer of local.peers) {
|
|
1393
|
+
const survey = byTarget.get(peer.target);
|
|
1394
|
+
const host_id = survey?.host_id ?? peer.host_id;
|
|
1395
|
+
if (host_id === null) continue;
|
|
1396
|
+
hosts.push({
|
|
1397
|
+
host_id,
|
|
1398
|
+
display_name: survey?.display_name ?? peer.display_name,
|
|
1399
|
+
localName: peer.name,
|
|
1400
|
+
target: peer.target
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1404
|
+
for (const host of hosts) {
|
|
1405
|
+
if (host.localName === null) continue;
|
|
1406
|
+
const first = seen.get(host.host_id);
|
|
1407
|
+
if (first === void 0) {
|
|
1408
|
+
seen.set(host.host_id, host);
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
const label = host.display_name ?? host.host_id;
|
|
1412
|
+
findings.push({
|
|
1413
|
+
kind: "duplicate-host-id",
|
|
1414
|
+
severity: "problem",
|
|
1415
|
+
subject: host.localName,
|
|
1416
|
+
message: `${first.localName} and ${host.localName} are the same machine (${label}), so every command reaches it twice`,
|
|
1417
|
+
detail: `same machine as ${first.localName} (${label}); every command reaches it twice`,
|
|
1418
|
+
remedy: `murmur peer remove ${host.localName}`
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
for (const peer of local.peers) {
|
|
1422
|
+
const cell = versionCell(peer);
|
|
1423
|
+
if (!cell.incompatible) continue;
|
|
1424
|
+
findings.push({
|
|
1425
|
+
kind: "snapshot-skew",
|
|
1426
|
+
severity: "problem",
|
|
1427
|
+
subject: peer.name,
|
|
1428
|
+
message: `${peer.name} speaks an incompatible snapshot version (${cell.text}); state will not sync until murmur versions match`,
|
|
1429
|
+
detail: `incompatible snapshot version (${cell.text}); state will not sync`,
|
|
1430
|
+
remedy: `ssh ${peer.target} npm i -g @martintrojer/murmur`
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
const peersThisNode = answered.filter(
|
|
1434
|
+
(survey) => survey.roster.some((row) => rowIsAbout(row, self))
|
|
1435
|
+
);
|
|
1436
|
+
for (const survey of answered) {
|
|
1437
|
+
if (peersThisNode.includes(survey)) continue;
|
|
1438
|
+
const name = nameOf(survey.target);
|
|
1439
|
+
findings.push({
|
|
1440
|
+
kind: "asymmetry",
|
|
1441
|
+
severity: "observation",
|
|
1442
|
+
subject: name,
|
|
1443
|
+
message: `${name} does not peer this node (${local.display_name}), so its picker cannot see this node's agents`,
|
|
1444
|
+
// The consequence is identical on every asymmetric row, so it belongs in
|
|
1445
|
+
// the column header once rather than in each cell N times.
|
|
1446
|
+
detail: `does not peer ${local.display_name}`,
|
|
1447
|
+
// display_name is what the operator would type, but it is not guaranteed
|
|
1448
|
+
// to resolve from THAT host's ssh config, which murmur cannot see. So it
|
|
1449
|
+
// is a command to check, and `peer add` tolerates a target that does not
|
|
1450
|
+
// answer.
|
|
1451
|
+
remedy: `ssh ${survey.target} murmur peer add ${local.display_name}`
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
if (answered.length > 0 && peersThisNode.length === 0) {
|
|
1455
|
+
findings.push({
|
|
1456
|
+
kind: "island",
|
|
1457
|
+
severity: "observation",
|
|
1458
|
+
subject: local.display_name,
|
|
1459
|
+
message: `no peer that this node surveyed peers this host (${local.display_name}); ${answered.length} of ${answered.length} surveyed peers cannot see this node's agents`,
|
|
1460
|
+
// "surveyed" is load-bearing and survives the shortening: this is a
|
|
1461
|
+
// one-hop claim, and dropping the word would turn it into a statement
|
|
1462
|
+
// about machines never contacted.
|
|
1463
|
+
detail: `none of the ${answered.length} surveyed peers can see this node`,
|
|
1464
|
+
remedy: null
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
for (const host of hosts) {
|
|
1468
|
+
const names = /* @__PURE__ */ new Map();
|
|
1469
|
+
const record = (name, where) => {
|
|
1470
|
+
const nodes = names.get(name);
|
|
1471
|
+
if (nodes) nodes.push(where);
|
|
1472
|
+
else names.set(name, [where]);
|
|
1473
|
+
};
|
|
1474
|
+
if (host.localName !== null) record(host.localName, "here");
|
|
1475
|
+
for (const survey of answered) {
|
|
1476
|
+
if (survey.host_id === host.host_id) continue;
|
|
1477
|
+
for (const row of survey.roster) {
|
|
1478
|
+
if (rowIsAbout(row, host)) record(row.name, nameOf(survey.target));
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (names.size < 2) continue;
|
|
1482
|
+
const label = host.display_name ?? host.host_id;
|
|
1483
|
+
const spelled = [...names].map(([name, nodes]) => `"${name}" on ${nodes.join(", ")}`).join("; ");
|
|
1484
|
+
findings.push({
|
|
1485
|
+
kind: "naming-drift",
|
|
1486
|
+
severity: "observation",
|
|
1487
|
+
subject: host.localName ?? local.display_name,
|
|
1488
|
+
message: `one machine (${label}) is configured under different names: ${spelled}`,
|
|
1489
|
+
detail: `one machine (${label}) under different names: ${spelled}`,
|
|
1490
|
+
remedy: null
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
for (const survey of surveys) {
|
|
1494
|
+
if (survey.ok) continue;
|
|
1495
|
+
const name = nameOf(survey.target);
|
|
1496
|
+
const detail = withoutTargetPrefix(survey.target, survey.detail);
|
|
1497
|
+
findings.push({
|
|
1498
|
+
kind: "unsurveyable",
|
|
1499
|
+
severity: "observation",
|
|
1500
|
+
subject: name,
|
|
1501
|
+
message: survey.reason === "roster-unsupported" ? `${name} runs a murmur too old to report its roster, so it could not be checked -- upgrade that host` : `${name} could not be surveyed -- ${detail}`,
|
|
1502
|
+
detail: survey.reason === "roster-unsupported" ? "murmur too old to report its roster" : detail,
|
|
1503
|
+
remedy: survey.reason === "roster-unsupported" ? `ssh ${survey.target} npm i -g @martintrojer/murmur` : null
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
return findings;
|
|
1507
|
+
}
|
|
1508
|
+
var TOPOLOGY_DEADLINE_MS = 3e4;
|
|
1509
|
+
var UNRESOLVED = /could not resolve hostname|name or service not known|nodename nor servname/i;
|
|
1510
|
+
function isNameResolutionFailure(detail) {
|
|
1511
|
+
return detail !== null && UNRESOLVED.test(detail);
|
|
1512
|
+
}
|
|
1513
|
+
async function probeReach(channel, from, to) {
|
|
1514
|
+
const inner = ["ssh", "-o", "BatchMode=yes", `-o`, "ConnectTimeout=1", to.target, "true"];
|
|
1515
|
+
try {
|
|
1516
|
+
await channel.exec(from.self ? to.target : from.target, from.self ? ["true"] : inner);
|
|
1517
|
+
return { ok: true, detail: null };
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1520
|
+
return { ok: false, detail: describeFailure(to.name, message) };
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
function reachableFromHere(surveys) {
|
|
1524
|
+
return new Set(surveys.filter((survey) => survey.ok).map((survey) => survey.target));
|
|
1525
|
+
}
|
|
1526
|
+
async function buildTopology(channel, nodes, upFromHere, deadline) {
|
|
1527
|
+
const isUp = (node) => node.self || upFromHere.has(node.target);
|
|
1528
|
+
const pairs = [];
|
|
1529
|
+
for (const from of nodes) {
|
|
1530
|
+
for (const to of nodes) {
|
|
1531
|
+
if (from.name !== to.name) pairs.push({ from, to });
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
const dialled = pairs.filter((pair) => isUp(pair.from));
|
|
1535
|
+
let timer;
|
|
1536
|
+
try {
|
|
1537
|
+
const bounded = deadline ?? new Promise((resolve) => {
|
|
1538
|
+
timer = setTimeout(resolve, TOPOLOGY_DEADLINE_MS);
|
|
1539
|
+
timer.unref?.();
|
|
1540
|
+
});
|
|
1541
|
+
const settled = await mapSettled(
|
|
1542
|
+
dialled,
|
|
1543
|
+
MAX_CONCURRENT_PEERS,
|
|
1544
|
+
async (pair) => probeReach(channel, pair.from, pair.to),
|
|
1545
|
+
bounded
|
|
1546
|
+
);
|
|
1547
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
1548
|
+
let probes = 0;
|
|
1549
|
+
for (const [index, pair] of dialled.entries()) {
|
|
1550
|
+
const result = settled[index];
|
|
1551
|
+
if (result !== void 0) probes += 1;
|
|
1552
|
+
outcomes.set(
|
|
1553
|
+
`${pair.from.name}\0${pair.to.name}`,
|
|
1554
|
+
result?.status === "fulfilled" ? result.value : void 0
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
const edges = pairs.map(({ from, to }) => {
|
|
1558
|
+
const outcome = outcomes.get(`${from.name}\0${to.name}`);
|
|
1559
|
+
if (outcome === void 0) {
|
|
1560
|
+
return {
|
|
1561
|
+
from: from.name,
|
|
1562
|
+
to: to.name,
|
|
1563
|
+
reach: "unknown",
|
|
1564
|
+
detail: isUp(from) ? "not probed within the deadline" : `${from.name} did not answer`
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
if (outcome.ok) return { from: from.name, to: to.name, reach: "reaches", detail: null };
|
|
1568
|
+
return {
|
|
1569
|
+
from: from.name,
|
|
1570
|
+
to: to.name,
|
|
1571
|
+
reach: isUp(to) ? "unreachable" : "unknown",
|
|
1572
|
+
detail: outcome.detail
|
|
1573
|
+
};
|
|
1574
|
+
});
|
|
1575
|
+
return { nodes, edges, probes };
|
|
1576
|
+
} finally {
|
|
1577
|
+
if (timer) clearTimeout(timer);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
function hubCandidates(topology) {
|
|
1581
|
+
const { nodes, edges } = topology;
|
|
1582
|
+
const lookup = new Map(edges.map((edge) => [`${edge.from}\0${edge.to}`, edge.reach]));
|
|
1583
|
+
const reachOf = (from, to) => from === to ? "reaches" : lookup.get(`${from}\0${to}`) ?? "unknown";
|
|
1584
|
+
return nodes.map((hub) => {
|
|
1585
|
+
const others = nodes.filter((node) => node.name !== hub.name);
|
|
1586
|
+
const reaches = others.filter((node) => reachOf(hub.name, node.name) === "reaches");
|
|
1587
|
+
return {
|
|
1588
|
+
node: hub.name,
|
|
1589
|
+
reaches: reaches.map((node) => node.name),
|
|
1590
|
+
cannotReach: others.filter((node) => reachOf(hub.name, node.name) === "unreachable").map((node) => node.name),
|
|
1591
|
+
unknown: others.filter((node) => reachOf(hub.name, node.name) === "unknown").map((node) => node.name),
|
|
1592
|
+
// Proven in both directions, and `unknown` never counts as proven: a star
|
|
1593
|
+
// built on a guess is the thing that cannot be allowed to look computed.
|
|
1594
|
+
star: [
|
|
1595
|
+
hub.name,
|
|
1596
|
+
...reaches.filter((node) => reachOf(node.name, hub.name) === "reaches").map((node) => node.name)
|
|
1597
|
+
]
|
|
1598
|
+
};
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
function bestHub(candidates) {
|
|
1602
|
+
let best = null;
|
|
1603
|
+
for (const candidate of candidates) {
|
|
1604
|
+
if (candidate.star.length < 2) continue;
|
|
1605
|
+
if (best === null || candidate.star.length > best.star.length) best = candidate;
|
|
1606
|
+
}
|
|
1607
|
+
return best;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// src/cli/doctor.ts
|
|
1611
|
+
async function surveyFleet(channel, peers, deadline) {
|
|
1612
|
+
let timer;
|
|
1613
|
+
try {
|
|
1614
|
+
const bounded = deadline ?? new Promise((resolve) => {
|
|
1615
|
+
timer = setTimeout(resolve, DOCTOR_DEADLINE_MS);
|
|
1616
|
+
timer.unref?.();
|
|
1617
|
+
});
|
|
1618
|
+
const settled = await mapSettled(
|
|
1619
|
+
peers,
|
|
1620
|
+
MAX_CONCURRENT_PEERS,
|
|
1621
|
+
async (peer) => surveyPeer(channel, peer.target),
|
|
1622
|
+
bounded
|
|
1623
|
+
);
|
|
1624
|
+
return peers.map((peer, index) => {
|
|
1625
|
+
const result = settled[index];
|
|
1626
|
+
if (result?.status === "fulfilled") return result.value;
|
|
1627
|
+
const detail = result === void 0 ? `not surveyed within ${DOCTOR_DEADLINE_MS / 1e3}s` : result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
1628
|
+
return { ok: false, target: peer.target, reason: "identity-unavailable", detail };
|
|
1629
|
+
});
|
|
1630
|
+
} finally {
|
|
1631
|
+
if (timer) clearTimeout(timer);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
var DIM = "\x1B[2m";
|
|
1635
|
+
var RESET = "\x1B[0m";
|
|
1636
|
+
function indent(block) {
|
|
1637
|
+
return block.split("\n").map((line) => line ? ` ${line}` : line).join("\n");
|
|
1638
|
+
}
|
|
1639
|
+
var GROUP = {
|
|
1640
|
+
"duplicate-host-id": {
|
|
1641
|
+
heading: "Duplicate peers",
|
|
1642
|
+
because: "One machine configured twice. Every command reaches it twice."
|
|
1643
|
+
},
|
|
1644
|
+
"snapshot-skew": {
|
|
1645
|
+
heading: "Incompatible versions",
|
|
1646
|
+
because: "State will not sync with these until murmur versions match."
|
|
1647
|
+
},
|
|
1648
|
+
asymmetry: {
|
|
1649
|
+
heading: "One-way peering",
|
|
1650
|
+
because: "These do not peer this node, so their pickers cannot see its agents."
|
|
1651
|
+
},
|
|
1652
|
+
island: {
|
|
1653
|
+
heading: "Not visible to the fleet",
|
|
1654
|
+
because: null
|
|
1655
|
+
},
|
|
1656
|
+
"naming-drift": {
|
|
1657
|
+
heading: "Naming drift",
|
|
1658
|
+
because: "Harmless to murmur, confusing to read: one machine, several names."
|
|
1659
|
+
},
|
|
1660
|
+
unsurveyable: {
|
|
1661
|
+
heading: "Could not be surveyed",
|
|
1662
|
+
because: "Normal for a sleeping laptop or a box switched off."
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
var GROUP_ORDER = [
|
|
1666
|
+
"duplicate-host-id",
|
|
1667
|
+
"snapshot-skew",
|
|
1668
|
+
"island",
|
|
1669
|
+
"asymmetry",
|
|
1670
|
+
"naming-drift",
|
|
1671
|
+
"unsurveyable"
|
|
1672
|
+
];
|
|
1673
|
+
function render(local, surveys, findings) {
|
|
1674
|
+
const answered = surveys.filter((survey) => survey.ok).length;
|
|
1675
|
+
const out = [];
|
|
1676
|
+
if (surveys.length === 0) return "No peers configured, so there is nothing to survey.\n";
|
|
1677
|
+
out.push(
|
|
1678
|
+
`Surveyed ${surveys.length} peer${surveys.length === 1 ? "" : "s"}, ${answered} answered.
|
|
1679
|
+
`
|
|
1680
|
+
);
|
|
1681
|
+
if (findings.length === 0) {
|
|
1682
|
+
out.push("\nNo problems found.\n");
|
|
1683
|
+
return out.join("");
|
|
1684
|
+
}
|
|
1685
|
+
const problems = findings.filter((finding) => finding.severity === "problem").length;
|
|
1686
|
+
const counts = [
|
|
1687
|
+
problems > 0 ? `${problems} problem${problems === 1 ? "" : "s"}` : "",
|
|
1688
|
+
findings.length - problems > 0 ? `${findings.length - problems} observation${findings.length - problems === 1 ? "" : "s"}` : ""
|
|
1689
|
+
].filter(Boolean);
|
|
1690
|
+
out.push(
|
|
1691
|
+
problems > 0 ? `${counts.join(", ")}. See "Do this" below.
|
|
1692
|
+
` : `${counts.join(", ")}, nothing broken.
|
|
1693
|
+
`
|
|
1694
|
+
);
|
|
1695
|
+
for (const kind of GROUP_ORDER) {
|
|
1696
|
+
const group = findings.filter((finding) => finding.kind === kind);
|
|
1697
|
+
if (group.length === 0) continue;
|
|
1698
|
+
const { heading, because } = GROUP[kind] ?? { heading: kind, because: null };
|
|
1699
|
+
const marked = group.some((finding) => finding.severity === "problem");
|
|
1700
|
+
const rows = group.map(
|
|
1701
|
+
(finding) => marked ? [finding.severity === "problem" ? "!" : " ", finding.subject, finding.detail] : [finding.subject, finding.detail]
|
|
1702
|
+
);
|
|
1703
|
+
out.push(`
|
|
1704
|
+
${heading}
|
|
1705
|
+
`);
|
|
1706
|
+
if (because) out.push(` ${DIM}${because}${RESET}
|
|
1707
|
+
`);
|
|
1708
|
+
out.push(indent(formatTable(rows)));
|
|
1709
|
+
}
|
|
1710
|
+
const remedies = findings.filter(
|
|
1711
|
+
(finding) => finding.remedy !== null
|
|
1712
|
+
);
|
|
1713
|
+
if (remedies.length > 0) {
|
|
1714
|
+
out.push("\nDo this\n");
|
|
1715
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1716
|
+
for (const finding of remedies) {
|
|
1717
|
+
if (seen.has(finding.remedy)) continue;
|
|
1718
|
+
seen.add(finding.remedy);
|
|
1719
|
+
out.push(` ${finding.remedy}
|
|
1720
|
+
`);
|
|
1721
|
+
}
|
|
1722
|
+
if (remedies.some((f) => f.remedy.includes(`murmur peer add ${local.display_name}`))) {
|
|
1723
|
+
out.push(
|
|
1724
|
+
`
|
|
1725
|
+
${DIM}These name this node "${local.display_name}". Whether a peer resolves that
|
|
1726
|
+
depends on its own ssh config, which murmur cannot see -- so check, do not
|
|
1727
|
+
trust. \`peer add\` accepts a target that does not answer yet.${RESET}
|
|
1728
|
+
`
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return out.join("");
|
|
1733
|
+
}
|
|
1734
|
+
function renderTopology(topology) {
|
|
1735
|
+
const candidates = hubCandidates(topology);
|
|
1736
|
+
const hub = bestHub(candidates);
|
|
1737
|
+
const out = [];
|
|
1738
|
+
const all = topology.nodes.length - 1;
|
|
1739
|
+
out.push(
|
|
1740
|
+
`
|
|
1741
|
+
Reachability ${DIM}${topology.probes} ordered pair${topology.probes === 1 ? "" : "s"} probed across ${topology.nodes.length} nodes${RESET}
|
|
1742
|
+
`
|
|
1743
|
+
);
|
|
1744
|
+
const anyUnknown = candidates.some((candidate) => candidate.unknown.length > 0);
|
|
1745
|
+
const rows = [["", "REACHES", "CANNOT REACH", ...anyUnknown ? ["UNKNOWN"] : []]];
|
|
1746
|
+
for (const candidate of candidates) {
|
|
1747
|
+
rows.push([
|
|
1748
|
+
candidate.node,
|
|
1749
|
+
candidate.reaches.length === all && all > 0 ? `all ${all}` : candidate.reaches.length > 0 ? candidate.reaches.join(" ") : "-",
|
|
1750
|
+
candidate.cannotReach.length > 0 ? candidate.cannotReach.join(" ") : "-",
|
|
1751
|
+
...anyUnknown ? [candidate.unknown.length > 0 ? candidate.unknown.join(" ") : "-"] : []
|
|
1752
|
+
]);
|
|
1753
|
+
}
|
|
1754
|
+
out.push(indent(formatTable(rows)));
|
|
1755
|
+
const unresolved = topology.edges.filter((edge) => isNameResolutionFailure(edge.detail));
|
|
1756
|
+
if (unresolved.length > 0) {
|
|
1757
|
+
const targets = [...new Set(unresolved.map((edge) => edge.to))];
|
|
1758
|
+
const from = [...new Set(unresolved.map((edge) => edge.from))];
|
|
1759
|
+
out.push(
|
|
1760
|
+
`
|
|
1761
|
+
${DIM}${targets.join(", ")} is not resolvable by name from ${from.join(", ")} -- a naming
|
|
1762
|
+
problem, not a network one. It may be reachable under another address.${RESET}
|
|
1763
|
+
`
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
if (hub === null) {
|
|
1767
|
+
out.push(
|
|
1768
|
+
`
|
|
1769
|
+
Hub ${DIM}none possible${RESET}
|
|
1770
|
+
A hub must reach every spoke and be reachable from each in turn.
|
|
1771
|
+
No node here does both, so none is recommended.
|
|
1772
|
+
`
|
|
1773
|
+
);
|
|
1774
|
+
return out.join("");
|
|
1775
|
+
}
|
|
1776
|
+
const spokes = hub.star.filter((name) => name !== hub.node);
|
|
1777
|
+
const excluded = topology.nodes.map((node) => node.name).filter((name) => !hub.star.includes(name));
|
|
1778
|
+
out.push(
|
|
1779
|
+
excluded.length === 0 ? `
|
|
1780
|
+
Hub ${hub.node} ${DIM}serves the whole fleet${RESET}
|
|
1781
|
+
` : `
|
|
1782
|
+
Hub ${hub.node} ${DIM}serves {${hub.star.join(", ")}}, leaves out ${excluded.join(", ")}${RESET}
|
|
1783
|
+
`
|
|
1784
|
+
);
|
|
1785
|
+
out.push(`
|
|
1786
|
+
To build that star
|
|
1787
|
+
`);
|
|
1788
|
+
for (const spoke of spokes) out.push(` ssh ${spoke} murmur peer add ${hub.node}
|
|
1789
|
+
`);
|
|
1790
|
+
out.push(
|
|
1791
|
+
`
|
|
1792
|
+
${DIM}Cost: spokes would see ${hub.node}'s agents and it would see theirs, but
|
|
1793
|
+
SPOKES WOULD NOT SEE EACH OTHER. \`murmur export\` publishes a node's own
|
|
1794
|
+
panes only, so a hub cannot re-serve what it learned. A star is not a mesh.${RESET}
|
|
1795
|
+
`
|
|
1796
|
+
);
|
|
1797
|
+
return out.join("");
|
|
1798
|
+
}
|
|
1799
|
+
function jsonReport(surveys, findings) {
|
|
1800
|
+
return {
|
|
1801
|
+
surveyed: surveys.length,
|
|
1802
|
+
answered: surveys.filter((survey) => survey.ok).length,
|
|
1803
|
+
findings
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
function exitCodeFor(findings) {
|
|
1807
|
+
return findings.some((finding) => finding.severity === "problem") ? 1 : 0;
|
|
1808
|
+
}
|
|
1809
|
+
function registerDoctor(program2) {
|
|
1810
|
+
program2.command("doctor").description("Survey peers over ssh and report what only a fleet-wide view can see").option("--json", "print the finding list").option("--topology", "also probe who can reach whom, and compute hub options").action(async (options) => {
|
|
1811
|
+
const identity = requireIdentity();
|
|
1812
|
+
if (!identity) return;
|
|
1062
1813
|
const store = openStore();
|
|
1063
1814
|
try {
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1815
|
+
const peers = store.peers();
|
|
1816
|
+
const surveys = await surveyFleet(ssh, peers);
|
|
1817
|
+
const local = {
|
|
1818
|
+
host_id: identity.host_id,
|
|
1819
|
+
display_name: identity.display_name,
|
|
1820
|
+
peers
|
|
1821
|
+
};
|
|
1822
|
+
const findings = diagnose(local, surveys);
|
|
1823
|
+
let topology = null;
|
|
1824
|
+
if (options.topology) {
|
|
1825
|
+
const nodes = [
|
|
1826
|
+
{ name: identity.display_name, target: identity.display_name, self: true },
|
|
1827
|
+
...peers.map((peer) => ({ name: peer.name, target: peer.target, self: false }))
|
|
1828
|
+
];
|
|
1829
|
+
topology = await buildTopology(ssh, nodes, reachableFromHere(surveys));
|
|
1070
1830
|
}
|
|
1071
|
-
if (
|
|
1072
|
-
process.
|
|
1831
|
+
if (options.json) {
|
|
1832
|
+
process.stdout.write(
|
|
1833
|
+
`${JSON.stringify({
|
|
1834
|
+
...jsonReport(surveys, findings),
|
|
1835
|
+
// Omitted entirely rather than null when the phase did not run: a
|
|
1836
|
+
// consumer must not have to tell "no topology" apart from "a
|
|
1837
|
+
// topology with nothing in it".
|
|
1838
|
+
...topology ? { topology, hubs: hubCandidates(topology) } : {}
|
|
1839
|
+
})}
|
|
1840
|
+
`
|
|
1841
|
+
);
|
|
1842
|
+
} else {
|
|
1843
|
+
process.stdout.write(render(local, surveys, findings));
|
|
1844
|
+
if (topology) process.stdout.write(renderTopology(topology));
|
|
1073
1845
|
}
|
|
1846
|
+
process.exitCode = exitCodeFor(findings);
|
|
1074
1847
|
} finally {
|
|
1075
1848
|
store.close();
|
|
1076
1849
|
}
|
|
@@ -1104,9 +1877,9 @@ function registerInit(program2) {
|
|
|
1104
1877
|
}
|
|
1105
1878
|
|
|
1106
1879
|
// src/cli/link.ts
|
|
1107
|
-
import { mkdirSync as mkdirSync3, readFileSync as
|
|
1108
|
-
import { homedir as
|
|
1109
|
-
import { dirname as dirname2, join as
|
|
1880
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
1881
|
+
import { homedir as homedir3 } from "os";
|
|
1882
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
1110
1883
|
import { fileURLToPath } from "url";
|
|
1111
1884
|
var SHIM_MARKER = "// murmur:shim";
|
|
1112
1885
|
function shim(entry, storePath) {
|
|
@@ -1137,8 +1910,8 @@ function registerLink(program2) {
|
|
|
1137
1910
|
"inline the extension instead of re-exporting it (pins to this version; needs re-linking after an upgrade)"
|
|
1138
1911
|
).action((target, options) => {
|
|
1139
1912
|
if (target !== "pi") throw new Error(`unsupported link target: ${target}`);
|
|
1140
|
-
const destination =
|
|
1141
|
-
process.env.MURMUR_PI_HOME ??
|
|
1913
|
+
const destination = join4(
|
|
1914
|
+
process.env.MURMUR_PI_HOME ?? homedir3(),
|
|
1142
1915
|
".pi",
|
|
1143
1916
|
"agent",
|
|
1144
1917
|
"extensions",
|
|
@@ -1151,7 +1924,7 @@ function registerLink(program2) {
|
|
|
1151
1924
|
if (!options.copy) {
|
|
1152
1925
|
let replacedCopy = false;
|
|
1153
1926
|
try {
|
|
1154
|
-
const existing =
|
|
1927
|
+
const existing = readFileSync3(destination, "utf8");
|
|
1155
1928
|
replacedCopy = !existing.includes(SHIM_MARKER);
|
|
1156
1929
|
} catch {
|
|
1157
1930
|
}
|
|
@@ -1169,7 +1942,7 @@ function registerLink(program2) {
|
|
|
1169
1942
|
}
|
|
1170
1943
|
return;
|
|
1171
1944
|
}
|
|
1172
|
-
const source =
|
|
1945
|
+
const source = readFileSync3(entry, "utf8");
|
|
1173
1946
|
const pinned = source.replace(
|
|
1174
1947
|
/"@martintrojer\/murmur\/extension-store"/,
|
|
1175
1948
|
JSON.stringify(storePath)
|
|
@@ -1283,237 +2056,17 @@ async function readStdin() {
|
|
|
1283
2056
|
});
|
|
1284
2057
|
}
|
|
1285
2058
|
|
|
1286
|
-
// src/cli/peer.ts
|
|
1287
|
-
import { readFileSync as readFileSync3 } from "fs";
|
|
1288
|
-
import { homedir as homedir3 } from "os";
|
|
1289
|
-
import { join as join4 } from "path";
|
|
1290
|
-
var SNAPSHOT_VERSION = 1;
|
|
1291
|
-
function parseSshHosts(config) {
|
|
1292
|
-
const hosts = [];
|
|
1293
|
-
for (const line of config.split("\n")) {
|
|
1294
|
-
const tokens = line.replace(/#.*$/, "").trim().split(/\s+/);
|
|
1295
|
-
if (tokens[0]?.toLowerCase() !== "host") continue;
|
|
1296
|
-
for (const host of tokens.slice(1)) {
|
|
1297
|
-
if (!/[*?!]/.test(host)) hosts.push(host);
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
return hosts;
|
|
1301
|
-
}
|
|
1302
|
-
function sshHosts() {
|
|
1303
|
-
try {
|
|
1304
|
-
return parseSshHosts(readFileSync3(join4(homedir3(), ".ssh", "config"), "utf8"));
|
|
1305
|
-
} catch {
|
|
1306
|
-
return [];
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
function lastSeen(fetchedAt, now) {
|
|
1310
|
-
if (fetchedAt === null) return "never";
|
|
1311
|
-
if (freshness(fetchedAt, now, STALENESS_MS) === "fresh") return "just now";
|
|
1312
|
-
return `${age(now - fetchedAt)} ago`;
|
|
1313
|
-
}
|
|
1314
|
-
function versionCell(peer, ours = SNAPSHOT_VERSION) {
|
|
1315
|
-
if (peer.murmur_version === null && peer.snapshot_version === null) {
|
|
1316
|
-
return { text: "unknown", incompatible: false };
|
|
1317
|
-
}
|
|
1318
|
-
const version = peer.murmur_version ?? "unreported";
|
|
1319
|
-
const incompatible = peer.snapshot_version !== null && peer.snapshot_version !== ours;
|
|
1320
|
-
return {
|
|
1321
|
-
text: incompatible ? `${version} (snapshot ${peer.snapshot_version} \u2260 ${ours})` : version,
|
|
1322
|
-
incompatible
|
|
1323
|
-
};
|
|
1324
|
-
}
|
|
1325
|
-
function formatTable(rows) {
|
|
1326
|
-
const widths = [];
|
|
1327
|
-
for (const row of rows) {
|
|
1328
|
-
row.forEach((cell, index) => {
|
|
1329
|
-
widths[index] = Math.max(widths[index] ?? 0, cell.length);
|
|
1330
|
-
});
|
|
1331
|
-
}
|
|
1332
|
-
return rows.map(
|
|
1333
|
-
(row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd()
|
|
1334
|
-
).map((line) => `${line}
|
|
1335
|
-
`).join("");
|
|
1336
|
-
}
|
|
1337
|
-
function peerAddDecision(input) {
|
|
1338
|
-
const { name, target, snapshot, selfHostId, peers } = input;
|
|
1339
|
-
if (!snapshot) return null;
|
|
1340
|
-
if (snapshot.host_id === selfHostId) {
|
|
1341
|
-
return `${target} is this node; not adding it as a peer
|
|
1342
|
-
`;
|
|
1343
|
-
}
|
|
1344
|
-
const existing = peers.find(
|
|
1345
|
-
(candidate) => candidate.host_id === snapshot.host_id && candidate.name !== name
|
|
1346
|
-
);
|
|
1347
|
-
if (existing) {
|
|
1348
|
-
return `${target} is already configured as peer "${existing.name}" (${snapshot.display_name}); remove it first to rename
|
|
1349
|
-
`;
|
|
1350
|
-
}
|
|
1351
|
-
return null;
|
|
1352
|
-
}
|
|
1353
|
-
function registerPeer(program2) {
|
|
1354
|
-
const peer = program2.command("peer").description("Manage peers");
|
|
1355
|
-
peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
|
|
1356
|
-
const store = openStore();
|
|
1357
|
-
try {
|
|
1358
|
-
let snapshot = null;
|
|
1359
|
-
try {
|
|
1360
|
-
snapshot = parseSnapshot(await ssh.exec(target, ["murmur", "export"]));
|
|
1361
|
-
} catch {
|
|
1362
|
-
snapshot = null;
|
|
1363
|
-
}
|
|
1364
|
-
const refusal = peerAddDecision({
|
|
1365
|
-
name,
|
|
1366
|
-
target,
|
|
1367
|
-
snapshot,
|
|
1368
|
-
selfHostId: loadIdentity()?.host_id ?? null,
|
|
1369
|
-
peers: store.peers()
|
|
1370
|
-
});
|
|
1371
|
-
if (refusal) {
|
|
1372
|
-
process.stderr.write(refusal);
|
|
1373
|
-
process.exitCode = 1;
|
|
1374
|
-
return;
|
|
1375
|
-
}
|
|
1376
|
-
store.addPeer(name, target);
|
|
1377
|
-
if (snapshot) {
|
|
1378
|
-
store.replacePeerSnapshot(name, { ok: true, snapshot, at: Date.now() });
|
|
1379
|
-
}
|
|
1380
|
-
process.stdout.write(
|
|
1381
|
-
snapshot ? `Added ${name} (${snapshot.display_name})
|
|
1382
|
-
` : `Added ${name} (identity pending)
|
|
1383
|
-
`
|
|
1384
|
-
);
|
|
1385
|
-
} finally {
|
|
1386
|
-
store.close();
|
|
1387
|
-
}
|
|
1388
|
-
});
|
|
1389
|
-
peer.command("remove").description("Remove a peer").argument("<name>", "peer to remove").action((name) => {
|
|
1390
|
-
const store = openStore();
|
|
1391
|
-
try {
|
|
1392
|
-
if (store.removePeer(name)) process.stdout.write(`Removed ${name}
|
|
1393
|
-
`);
|
|
1394
|
-
else {
|
|
1395
|
-
process.stderr.write(`no such peer: ${name}
|
|
1396
|
-
`);
|
|
1397
|
-
process.exitCode = 1;
|
|
1398
|
-
}
|
|
1399
|
-
} finally {
|
|
1400
|
-
store.close();
|
|
1401
|
-
}
|
|
1402
|
-
});
|
|
1403
|
-
peer.command("list").description("List peers; --all adds SSH hosts that could become peers").option("--json", "print JSON").option("-a, --all", "also show SSH hosts that are not peers yet").action((options) => {
|
|
1404
|
-
const store = openStore();
|
|
1405
|
-
try {
|
|
1406
|
-
const peers = store.peers();
|
|
1407
|
-
const configured = new Map(peers.map((entry) => [entry.target, entry]));
|
|
1408
|
-
const discovered = options.all ? sshHosts().filter((host) => !configured.has(host)) : [];
|
|
1409
|
-
const now = Date.now();
|
|
1410
|
-
const rows = [...configured.keys(), ...discovered].map((target) => {
|
|
1411
|
-
const entry = configured.get(target);
|
|
1412
|
-
return {
|
|
1413
|
-
// The handle other commands take: a peer's name, or for a host that
|
|
1414
|
-
// is not one yet, the ssh host `peer add` wants.
|
|
1415
|
-
name: entry?.name ?? target,
|
|
1416
|
-
target,
|
|
1417
|
-
peer: entry !== void 0,
|
|
1418
|
-
// What the node called itself. Shown, never typed: it can be a
|
|
1419
|
-
// container id.
|
|
1420
|
-
hostname: entry?.display_name ?? null,
|
|
1421
|
-
// Being a peer is not the same as being reachable, and the old
|
|
1422
|
-
// output said only the first. A node asleep for twelve hours read
|
|
1423
|
-
// exactly like one polled a second ago.
|
|
1424
|
-
last_seen: entry === void 0 ? null : lastSeen(entry.fetched_at, now),
|
|
1425
|
-
// A warm ControlMaster socket makes a collect ~10ms instead of
|
|
1426
|
-
// ~170ms, and is the only path that works on a host demanding a
|
|
1427
|
-
// hardware-token touch per connection. A speed hint, never a
|
|
1428
|
-
// requirement -- which is why the old bare `[x]` / `[ ]` was
|
|
1429
|
-
// unreadable: it never said what was being checked.
|
|
1430
|
-
//
|
|
1431
|
-
// Safe for every row: `ssh -O check` talks to a local socket and
|
|
1432
|
-
// never dials, so a host that is down or does not exist answers in
|
|
1433
|
-
// ~16ms. Measured.
|
|
1434
|
-
ssh: hasWarmSocket(target) ? "warm" : "cold",
|
|
1435
|
-
// What it is running, or undefined when nothing is known -- either
|
|
1436
|
-
// because the host is not a peer yet, or because it is a peer that
|
|
1437
|
-
// has never answered. Undefined is what drops the column, so the
|
|
1438
|
-
// test is "has anything told us", not "is this configured": a fleet
|
|
1439
|
-
// of asleep peers must not buy a column of "unknown".
|
|
1440
|
-
version: entry === void 0 || entry.murmur_version === null && entry.snapshot_version === null ? void 0 : versionCell(entry),
|
|
1441
|
-
// Named where it can be acted on: a peer that answered with a bad
|
|
1442
|
-
// document is reachable but broken, which is an operator task and
|
|
1443
|
-
// reads nothing like a sleeping laptop.
|
|
1444
|
-
error: entry?.last_error ?? null
|
|
1445
|
-
};
|
|
1446
|
-
});
|
|
1447
|
-
if (options.json) {
|
|
1448
|
-
process.stdout.write(`${JSON.stringify(rows)}
|
|
1449
|
-
`);
|
|
1450
|
-
return;
|
|
1451
|
-
}
|
|
1452
|
-
if (rows.length === 0) {
|
|
1453
|
-
process.stdout.write(
|
|
1454
|
-
options.all ? "no peers configured, and no hosts in ~/.ssh/config\n" : "no peers configured. See what could be added with: murmur peer list --all\n"
|
|
1455
|
-
);
|
|
1456
|
-
return;
|
|
1457
|
-
}
|
|
1458
|
-
const showPeerColumn = rows.some((row) => !row.peer);
|
|
1459
|
-
const showVersionColumn = rows.some((row) => row.version !== void 0);
|
|
1460
|
-
process.stdout.write(
|
|
1461
|
-
formatTable([
|
|
1462
|
-
[
|
|
1463
|
-
"NAME",
|
|
1464
|
-
"TARGET",
|
|
1465
|
-
...showPeerColumn ? ["PEER"] : [],
|
|
1466
|
-
"HOSTNAME",
|
|
1467
|
-
...showVersionColumn ? ["VERSION"] : [],
|
|
1468
|
-
"LAST SEEN",
|
|
1469
|
-
"SSH"
|
|
1470
|
-
],
|
|
1471
|
-
...rows.map((row) => [
|
|
1472
|
-
row.name,
|
|
1473
|
-
row.target,
|
|
1474
|
-
...showPeerColumn ? [row.peer ? "yes" : "-"] : [],
|
|
1475
|
-
row.hostname ?? "unknown",
|
|
1476
|
-
...showVersionColumn ? [row.version?.text ?? "-"] : [],
|
|
1477
|
-
row.last_seen ?? "-",
|
|
1478
|
-
row.ssh
|
|
1479
|
-
])
|
|
1480
|
-
])
|
|
1481
|
-
);
|
|
1482
|
-
const incompatible = rows.filter((row) => row.version?.incompatible);
|
|
1483
|
-
if (incompatible.length > 0) {
|
|
1484
|
-
process.stdout.write(
|
|
1485
|
-
`
|
|
1486
|
-
${incompatible.length} peer${incompatible.length === 1 ? "" : "s"} speak an incompatible snapshot version; state will not sync until murmur versions match: ${incompatible.map((row) => row.name).join(", ")}
|
|
1487
|
-
`
|
|
1488
|
-
);
|
|
1489
|
-
}
|
|
1490
|
-
const broken = rows.filter((row) => row.error);
|
|
1491
|
-
for (const row of broken) {
|
|
1492
|
-
process.stdout.write(`
|
|
1493
|
-
${row.name}: last attempt failed -- ${row.error}
|
|
1494
|
-
`);
|
|
1495
|
-
}
|
|
1496
|
-
const addable = rows.filter((row) => !row.peer).length;
|
|
1497
|
-
if (addable > 0) {
|
|
1498
|
-
process.stdout.write(
|
|
1499
|
-
`
|
|
1500
|
-
${addable} host${addable === 1 ? "" : "s"} not yet a peer. Add one with: murmur peer add <name>
|
|
1501
|
-
`
|
|
1502
|
-
);
|
|
1503
|
-
}
|
|
1504
|
-
} finally {
|
|
1505
|
-
store.close();
|
|
1506
|
-
}
|
|
1507
|
-
});
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
2059
|
// src/cli/pick.ts
|
|
1511
2060
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
1512
2061
|
|
|
1513
2062
|
// src/agents.ts
|
|
1514
2063
|
import { spawnSync } from "child_process";
|
|
2064
|
+
function sessionLeaf(session) {
|
|
2065
|
+
const leaf = session.split("/").filter(Boolean).at(-1);
|
|
2066
|
+
return leaf ?? session;
|
|
2067
|
+
}
|
|
1515
2068
|
function agentLabel(agent) {
|
|
1516
|
-
const name = agent.agent_name ?? agent.pi_session ?? agent.window_name ?? agent.session_name;
|
|
2069
|
+
const name = agent.agent_name ?? agent.pi_session ?? agent.window_name ?? (agent.session_name === null ? null : sessionLeaf(agent.session_name));
|
|
1517
2070
|
return terminalText(name ?? agent.window);
|
|
1518
2071
|
}
|
|
1519
2072
|
function agentLocation(agent) {
|
|
@@ -1712,9 +2265,9 @@ function status(store, identity, now = Date.now()) {
|
|
|
1712
2265
|
}))
|
|
1713
2266
|
};
|
|
1714
2267
|
}
|
|
1715
|
-
async function statusWithCollect(store, identity, now = Date.now(), channel = ssh,
|
|
2268
|
+
async function statusWithCollect(store, identity, now = Date.now(), channel = ssh, options = {}) {
|
|
1716
2269
|
try {
|
|
1717
|
-
await collect(store, channel, now,
|
|
2270
|
+
await collect(store, channel, now, options);
|
|
1718
2271
|
} catch {
|
|
1719
2272
|
}
|
|
1720
2273
|
return status(store, identity, now);
|
|
@@ -1752,8 +2305,8 @@ var ANSI_AT_START = new RegExp(`^${ANSI_PATTERN}`);
|
|
|
1752
2305
|
var ANSI_AT_END = new RegExp(`(?:${ANSI_PATTERN})+$`);
|
|
1753
2306
|
var REMOTE = "\x1B[36m";
|
|
1754
2307
|
var BOLD = "\x1B[1m";
|
|
1755
|
-
var
|
|
1756
|
-
var
|
|
2308
|
+
var DIM2 = "\x1B[2m";
|
|
2309
|
+
var RESET2 = "\x1B[0m";
|
|
1757
2310
|
var CREW_MARK = "crew ";
|
|
1758
2311
|
function isVisible(agent) {
|
|
1759
2312
|
return agent.driver === "human" || NEEDS_HUMAN.some((kind) => agent.attention.includes(kind));
|
|
@@ -1824,11 +2377,11 @@ function pickerRow(agent, showHost, current, local = agent.local) {
|
|
|
1824
2377
|
const state = renderState(agent);
|
|
1825
2378
|
const colour = COLOUR[state] ?? "";
|
|
1826
2379
|
const glyph = GLYPH[state] ?? "?";
|
|
1827
|
-
const marker = current ? `${BOLD}\u25C6${
|
|
1828
|
-
const name =
|
|
1829
|
-
const host = showHost ? local ? `${
|
|
2380
|
+
const marker = current ? `${BOLD}\u25C6${RESET2}` : " ";
|
|
2381
|
+
const name = agentLabel(agent);
|
|
2382
|
+
const host = showHost ? local ? `${DIM2} here${RESET2}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET2}` : "";
|
|
1830
2383
|
const group = agent.workstream ?? agent.session_name;
|
|
1831
|
-
const workstream = group && group !== name ? `${
|
|
2384
|
+
const workstream = group && group !== name ? `${DIM2}${terminalText(group)}${RESET2}` : "";
|
|
1832
2385
|
const extra = agent.attention.filter((kind) => kind !== state);
|
|
1833
2386
|
const flags = [
|
|
1834
2387
|
agent.driver === "orchestrated" ? "crew" : "",
|
|
@@ -1841,12 +2394,14 @@ function pickerRow(agent, showHost, current, local = agent.local) {
|
|
|
1841
2394
|
age(agent.updated_at === null ? null : Date.now() - agent.updated_at)
|
|
1842
2395
|
].filter(Boolean).join(" ");
|
|
1843
2396
|
const label = [
|
|
1844
|
-
`${marker} ${colour}${glyph}${
|
|
1845
|
-
`${colour}${pad(state, COLUMNS.state)}${
|
|
1846
|
-
|
|
2397
|
+
`${marker} ${colour}${glyph}${RESET2}`,
|
|
2398
|
+
`${colour}${pad(state, COLUMNS.state)}${RESET2}`,
|
|
2399
|
+
// No `terminalText` here: `agentLabel` already sanitised it, and wrapping it
|
|
2400
|
+
// again implied this value was raw.
|
|
2401
|
+
pad(`${BOLD}${name}${RESET2}`, COLUMNS.name),
|
|
1847
2402
|
pad(workstream, showHost ? COLUMNS.stream : COLUMNS.streamWide),
|
|
1848
2403
|
showHost ? pad(host, COLUMNS.host) : "",
|
|
1849
|
-
flags ? `${
|
|
2404
|
+
flags ? `${DIM2}${flags}${RESET2}` : ""
|
|
1850
2405
|
].filter(Boolean).join(" ");
|
|
1851
2406
|
return `${agent.host_id} ${agent.pane} ${label}`;
|
|
1852
2407
|
}
|
|
@@ -1854,11 +2409,16 @@ function previewText(store, agent) {
|
|
|
1854
2409
|
const state = renderState(agent);
|
|
1855
2410
|
const colour = COLOUR[state] ?? "";
|
|
1856
2411
|
const head = [
|
|
1857
|
-
|
|
2412
|
+
// Same one chain the row uses. This was
|
|
2413
|
+
// `agent.agent_name ? terminalText(agent.agent_name) : agentLabel(agent)`,
|
|
2414
|
+
// whose true branch is exactly what `agentLabel` does first anyway, down to
|
|
2415
|
+
// the `terminalText` -- a no-op fork that existed only to fall out of step
|
|
2416
|
+
// with the row above it.
|
|
2417
|
+
`${colour}${GLYPH[state] ?? "?"} ${state}${RESET2} ${BOLD}${agentLabel(agent)}${RESET2}`,
|
|
1858
2418
|
// Says where, and whether "where" is this machine. The glance below is a
|
|
1859
2419
|
// local capture-pane or an ssh depending on this one fact, so it belongs in
|
|
1860
2420
|
// the header rather than being inferred from a hostname.
|
|
1861
|
-
agent.local ? `${
|
|
2421
|
+
agent.local ? `${DIM2}here ${agentLocation(agent)}${RESET2}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET2} ${DIM2}${agentLocation(agent)}${RESET2}`
|
|
1862
2422
|
];
|
|
1863
2423
|
const facts = [
|
|
1864
2424
|
`activity ${agent.activity ?? "none (attention only)"}`,
|
|
@@ -1872,15 +2432,15 @@ function previewText(store, agent) {
|
|
|
1872
2432
|
// three-hour-old fact, and collapsing them is how that read as fresh.
|
|
1873
2433
|
agent.updated_at === null ? "" : `said ${timestamp2(agent.updated_at)}`,
|
|
1874
2434
|
agent.local ? "" : `fetched ${agent.fetched_at === null ? "never" : timestamp2(agent.fetched_at)}`,
|
|
1875
|
-
agent.freshness === "stale" ? `${
|
|
2435
|
+
agent.freshness === "stale" ? `${DIM2}host is stale: fields below are last-known${RESET2}` : ""
|
|
1876
2436
|
].filter(Boolean);
|
|
1877
2437
|
const pane = glance(store, agent);
|
|
1878
2438
|
const live = pane?.trimEnd() ? [
|
|
1879
|
-
`${
|
|
2439
|
+
`${DIM2}\u2500\u2500 pane \u2500\u2500${RESET2}`,
|
|
1880
2440
|
pane.trimEnd().slice(-PREVIEW_MESSAGE_MAX * 20)
|
|
1881
2441
|
] : [
|
|
1882
|
-
`${
|
|
1883
|
-
`${
|
|
2442
|
+
`${DIM2}\u2500\u2500 pane \u2500\u2500${RESET2}`,
|
|
2443
|
+
`${DIM2}unavailable (host unreachable, or pane gone)${RESET2}`
|
|
1884
2444
|
];
|
|
1885
2445
|
return [...head, "", ...facts, "", ...live].join("\n");
|
|
1886
2446
|
}
|
|
@@ -1892,7 +2452,7 @@ function runPreview(store, paneId, hostId) {
|
|
|
1892
2452
|
);
|
|
1893
2453
|
process.stdout.write(
|
|
1894
2454
|
agent ? `${previewText(store, agent)}
|
|
1895
|
-
` : `${
|
|
2455
|
+
` : `${DIM2}${paneId} is no longer here.${RESET2}
|
|
1896
2456
|
`
|
|
1897
2457
|
);
|
|
1898
2458
|
}
|
|
@@ -1901,7 +2461,9 @@ async function runPick(store, options = {}, deps = {}) {
|
|
|
1901
2461
|
const jumpTo = deps.jump ?? jumpToAgent;
|
|
1902
2462
|
const identity = requireIdentity();
|
|
1903
2463
|
if (!identity) return;
|
|
1904
|
-
const view = await statusWithCollect(store, identity, Date.now(), ssh,
|
|
2464
|
+
const view = await statusWithCollect(store, identity, Date.now(), ssh, {
|
|
2465
|
+
mux: deps.mux ?? tmux
|
|
2466
|
+
});
|
|
1905
2467
|
const agents = view.panes.filter((agent2) => options.all || isVisible(agent2));
|
|
1906
2468
|
const hidden = view.panes.length - agents.length;
|
|
1907
2469
|
if (agents.length === 0) {
|
|
@@ -1919,7 +2481,7 @@ async function runPick(store, options = {}, deps = {}) {
|
|
|
1919
2481
|
const state = renderState(agent2);
|
|
1920
2482
|
counts.set(state, (counts.get(state) ?? 0) + 1);
|
|
1921
2483
|
}
|
|
1922
|
-
const prompt = RENDER_PRIORITY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${
|
|
2484
|
+
const prompt = RENDER_PRIORITY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${RESET2}`).join(" ");
|
|
1923
2485
|
const basePrompt = `${prompt}${prompt ? " " : ""}`;
|
|
1924
2486
|
const self = process.argv[1] ?? "murmur";
|
|
1925
2487
|
const allFlag = options.all ? " --all" : "";
|
|
@@ -2079,7 +2641,9 @@ function registerStatus(program2) {
|
|
|
2079
2641
|
if (!identity) return;
|
|
2080
2642
|
const store = openStore();
|
|
2081
2643
|
try {
|
|
2082
|
-
const view = await statusWithCollect(store, identity)
|
|
2644
|
+
const view = await statusWithCollect(store, identity, Date.now(), ssh, {
|
|
2645
|
+
floorMs: COLLECT_FLOOR_MS
|
|
2646
|
+
});
|
|
2083
2647
|
process.stdout.write(
|
|
2084
2648
|
options.json ? `${JSON.stringify(view, null, 2)}
|
|
2085
2649
|
` : tmuxStatus(view)
|
|
@@ -2100,6 +2664,7 @@ registerCollect(program);
|
|
|
2100
2664
|
registerClear(program);
|
|
2101
2665
|
registerNotify(program);
|
|
2102
2666
|
registerPeer(program);
|
|
2667
|
+
registerDoctor(program);
|
|
2103
2668
|
registerStatus(program);
|
|
2104
2669
|
registerPick(program);
|
|
2105
2670
|
program.parse();
|