@martintrojer/murmur 0.1.3 → 0.1.4
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 +13 -5
- package/CHANGELOG.md +75 -0
- package/dist/cli.js +181 -100
- package/dist/cli.js.map +1 -1
- package/dist/extension/murmur-pi.js +14 -8
- package/dist/extension/murmur-pi.js.map +1 -1
- package/dist/extension/store.js +8 -0
- package/dist/extension/store.js.map +1 -1
- package/dist/index.d.ts +67 -14
- package/dist/index.js +171 -83
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -4,6 +4,43 @@ import { createRequire } from "module";
|
|
|
4
4
|
// src/agents.ts
|
|
5
5
|
import { spawnSync } from "child_process";
|
|
6
6
|
|
|
7
|
+
// src/channel.ts
|
|
8
|
+
import { execFile, execFileSync } from "child_process";
|
|
9
|
+
import { promisify } from "util";
|
|
10
|
+
var execFileAsync = promisify(execFile);
|
|
11
|
+
var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
|
|
12
|
+
var CONNECT_TIMEOUT_S = 1;
|
|
13
|
+
var EXEC_TIMEOUT_MS = 3e3;
|
|
14
|
+
var SSH_OPTIONS = [
|
|
15
|
+
"-o",
|
|
16
|
+
"BatchMode=yes",
|
|
17
|
+
"-o",
|
|
18
|
+
"ControlMaster=no",
|
|
19
|
+
"-o",
|
|
20
|
+
`ControlPath=${CONTROL_PATH}`,
|
|
21
|
+
"-o",
|
|
22
|
+
`ConnectTimeout=${CONNECT_TIMEOUT_S}`
|
|
23
|
+
];
|
|
24
|
+
var MAX_EXPORT_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
var ssh = {
|
|
26
|
+
async exec(target, argv) {
|
|
27
|
+
const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
30
|
+
maxBuffer: MAX_EXPORT_BYTES
|
|
31
|
+
});
|
|
32
|
+
return stdout;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
function hasWarmSocket(target) {
|
|
36
|
+
try {
|
|
37
|
+
execFileSync("ssh", [...SSH_OPTIONS, "-O", "check", target], { stdio: "ignore" });
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
7
44
|
// src/identity.ts
|
|
8
45
|
import { randomUUID } from "crypto";
|
|
9
46
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -39,10 +76,10 @@ function ensureIdentity(displayName = hostname()) {
|
|
|
39
76
|
}
|
|
40
77
|
|
|
41
78
|
// src/mux.ts
|
|
42
|
-
import { execFileSync } from "child_process";
|
|
79
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
43
80
|
function runTmux(args) {
|
|
44
81
|
try {
|
|
45
|
-
return
|
|
82
|
+
return execFileSync2("tmux", args, {
|
|
46
83
|
encoding: "utf8",
|
|
47
84
|
timeout: 3e3,
|
|
48
85
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -103,7 +140,7 @@ var tmux = {
|
|
|
103
140
|
},
|
|
104
141
|
attach(session, window) {
|
|
105
142
|
runTmux(["switch-client", "-t", session]);
|
|
106
|
-
runTmux(["select-window", "-t", window]);
|
|
143
|
+
return runTmux(["select-window", "-t", window]) !== null;
|
|
107
144
|
},
|
|
108
145
|
// Window ids are what the log stores, because they are stable; names are
|
|
109
146
|
// what a human recognises in a picker. Names are live tmux state, not
|
|
@@ -135,7 +172,10 @@ var tmux = {
|
|
|
135
172
|
return null;
|
|
136
173
|
},
|
|
137
174
|
selectWindow(window) {
|
|
138
|
-
runTmux(["select-window", "-t", window]);
|
|
175
|
+
return runTmux(["select-window", "-t", window]) !== null;
|
|
176
|
+
},
|
|
177
|
+
newWindow(name, command) {
|
|
178
|
+
return runTmux(["new-window", "-n", name, command]) !== null;
|
|
139
179
|
},
|
|
140
180
|
// The window a pane belongs to, for a pane murmur has no event for. Clearing
|
|
141
181
|
// a badge is a tmux operation and does not require murmur to own the pane.
|
|
@@ -176,6 +216,21 @@ function terminalText(value) {
|
|
|
176
216
|
function shellQuote(value) {
|
|
177
217
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
178
218
|
}
|
|
219
|
+
var spawnRunner = (file, args, inherit = false) => {
|
|
220
|
+
const result = spawnSync(file, args, {
|
|
221
|
+
encoding: "utf8",
|
|
222
|
+
timeout: 1e4,
|
|
223
|
+
...inherit ? { stdio: "inherit" } : {}
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
status: result.status,
|
|
227
|
+
stdout: result.stdout ?? "",
|
|
228
|
+
// spawnSync reports a failure to even start the child in `error`, leaving
|
|
229
|
+
// status null. Collapsing both here keeps the decision table below reading
|
|
230
|
+
// as one question rather than two.
|
|
231
|
+
failed: result.error !== void 0
|
|
232
|
+
};
|
|
233
|
+
};
|
|
179
234
|
function forgetHostReplica(store, hostId) {
|
|
180
235
|
try {
|
|
181
236
|
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
@@ -198,10 +253,10 @@ function forgetReplica(store, agentId, hostId) {
|
|
|
198
253
|
} catch {
|
|
199
254
|
}
|
|
200
255
|
}
|
|
201
|
-
function jumpToAgent(store, agent) {
|
|
256
|
+
function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
202
257
|
const identity = loadIdentity();
|
|
203
258
|
if (agent.host_id === identity?.host_id) {
|
|
204
|
-
const live =
|
|
259
|
+
const live = mux.liveWindows();
|
|
205
260
|
if (live && !live.has(agent.window)) {
|
|
206
261
|
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
207
262
|
return {
|
|
@@ -210,7 +265,13 @@ function jumpToAgent(store, agent) {
|
|
|
210
265
|
message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
|
|
211
266
|
};
|
|
212
267
|
}
|
|
213
|
-
|
|
268
|
+
if (!mux.attach(agent.session, agent.window)) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
reason: "attach_failed",
|
|
272
|
+
message: `could not attach to ${agentLabel(agent)} (tmux select-window failed).`
|
|
273
|
+
};
|
|
274
|
+
}
|
|
214
275
|
return { ok: true };
|
|
215
276
|
}
|
|
216
277
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
@@ -222,18 +283,18 @@ function jumpToAgent(store, agent) {
|
|
|
222
283
|
message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
|
|
223
284
|
};
|
|
224
285
|
}
|
|
225
|
-
const probe =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
);
|
|
286
|
+
const probe = run("ssh", [
|
|
287
|
+
...SSH_OPTIONS,
|
|
288
|
+
target,
|
|
289
|
+
`tmux list-windows -a -F ${shellQuote("#{window_id}")}`
|
|
290
|
+
]);
|
|
230
291
|
if (probe.status !== 0) {
|
|
231
|
-
const sshFailed = probe.status === 255 || probe.
|
|
292
|
+
const sshFailed = probe.status === 255 || probe.failed;
|
|
232
293
|
if (sshFailed) {
|
|
233
294
|
return {
|
|
234
295
|
ok: false,
|
|
235
296
|
reason: "unreachable",
|
|
236
|
-
message: `cannot reach ${target} over ssh.
|
|
297
|
+
message: `cannot reach ${target} over ssh. Nothing here ever prompts for auth, so check the host is awake and reachable, or connect once by hand to see the real error.`
|
|
237
298
|
};
|
|
238
299
|
}
|
|
239
300
|
forgetHostReplica(store, agent.host_id);
|
|
@@ -243,7 +304,7 @@ function jumpToAgent(store, agent) {
|
|
|
243
304
|
message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
|
|
244
305
|
};
|
|
245
306
|
}
|
|
246
|
-
const remoteWindows = new Set(
|
|
307
|
+
const remoteWindows = new Set(probe.stdout.split("\n").filter(Boolean));
|
|
247
308
|
if (!remoteWindows.has(agent.window)) {
|
|
248
309
|
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
249
310
|
return {
|
|
@@ -256,51 +317,26 @@ function jumpToAgent(store, agent) {
|
|
|
256
317
|
if (process.env.TMUX) {
|
|
257
318
|
const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
|
|
258
319
|
const name = `@${peer?.name ?? target}`;
|
|
259
|
-
const existing =
|
|
320
|
+
const existing = mux.windowNamed(name);
|
|
260
321
|
if (existing) {
|
|
261
|
-
|
|
262
|
-
|
|
322
|
+
return mux.selectWindow(existing) ? { ok: true } : {
|
|
323
|
+
ok: false,
|
|
324
|
+
reason: "attach_failed",
|
|
325
|
+
message: `could not switch to the existing ${name} window.`
|
|
326
|
+
};
|
|
263
327
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// src/channel.ts
|
|
272
|
-
import { execFile, execFileSync as execFileSync2 } from "child_process";
|
|
273
|
-
import { promisify } from "util";
|
|
274
|
-
var execFileAsync = promisify(execFile);
|
|
275
|
-
var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
|
|
276
|
-
var CONNECT_TIMEOUT_S = 2;
|
|
277
|
-
var EXEC_TIMEOUT_MS = 1e4;
|
|
278
|
-
var SSH_OPTIONS = [
|
|
279
|
-
"-o",
|
|
280
|
-
"BatchMode=yes",
|
|
281
|
-
"-o",
|
|
282
|
-
"ControlMaster=no",
|
|
283
|
-
"-o",
|
|
284
|
-
`ControlPath=${CONTROL_PATH}`,
|
|
285
|
-
"-o",
|
|
286
|
-
`ConnectTimeout=${CONNECT_TIMEOUT_S}`
|
|
287
|
-
];
|
|
288
|
-
var ssh = {
|
|
289
|
-
async exec(target, argv) {
|
|
290
|
-
const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
|
|
291
|
-
encoding: "utf8",
|
|
292
|
-
timeout: EXEC_TIMEOUT_MS
|
|
293
|
-
});
|
|
294
|
-
return stdout;
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
function hasWarmSocket(target) {
|
|
298
|
-
try {
|
|
299
|
-
execFileSync2("ssh", [...SSH_OPTIONS, "-O", "check", target], { stdio: "ignore" });
|
|
300
|
-
return true;
|
|
301
|
-
} catch {
|
|
302
|
-
return false;
|
|
328
|
+
return mux.newWindow(name, command) ? { ok: true } : {
|
|
329
|
+
ok: false,
|
|
330
|
+
reason: "attach_failed",
|
|
331
|
+
message: `could not open a window to attach to ${target}.`
|
|
332
|
+
};
|
|
303
333
|
}
|
|
334
|
+
const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
|
|
335
|
+
return attach.status === 0 && !attach.failed ? { ok: true } : {
|
|
336
|
+
ok: false,
|
|
337
|
+
reason: "attach_failed",
|
|
338
|
+
message: `ssh attach to ${target} failed.`
|
|
339
|
+
};
|
|
304
340
|
}
|
|
305
341
|
|
|
306
342
|
// src/types.ts
|
|
@@ -489,8 +525,30 @@ function exportJsonl(store, since, isAlive, live) {
|
|
|
489
525
|
}
|
|
490
526
|
|
|
491
527
|
// src/collector.ts
|
|
492
|
-
var
|
|
493
|
-
var
|
|
528
|
+
var STALENESS_MS = 6e4;
|
|
529
|
+
var MAX_CONCURRENT_PEERS = 8;
|
|
530
|
+
var COLLECT_DEADLINE_MS = 4e3;
|
|
531
|
+
async function mapSettled(items, limit, task, deadline) {
|
|
532
|
+
const results = new Array(items.length);
|
|
533
|
+
let cursor = 0;
|
|
534
|
+
let expired = false;
|
|
535
|
+
const stop = deadline?.then(() => {
|
|
536
|
+
expired = true;
|
|
537
|
+
});
|
|
538
|
+
const worker = async () => {
|
|
539
|
+
while (cursor < items.length && !expired) {
|
|
540
|
+
const index = cursor++;
|
|
541
|
+
try {
|
|
542
|
+
results[index] = { status: "fulfilled", value: await task(items[index]) };
|
|
543
|
+
} catch (reason) {
|
|
544
|
+
results[index] = { status: "rejected", reason };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
const pool = Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
549
|
+
await (stop ? Promise.race([pool, stop]) : pool);
|
|
550
|
+
return results;
|
|
551
|
+
}
|
|
494
552
|
function parseJsonl(output) {
|
|
495
553
|
const lines = output.trim().split("\n");
|
|
496
554
|
const envelope = JSON.parse(lines.shift() ?? "");
|
|
@@ -504,20 +562,35 @@ function parseJsonl(output) {
|
|
|
504
562
|
events: lines.map((line) => eventFromWire(JSON.parse(line)))
|
|
505
563
|
};
|
|
506
564
|
}
|
|
507
|
-
async function collect(store, channel, now = Date.now()) {
|
|
565
|
+
async function collect(store, channel, now = Date.now(), deadline) {
|
|
508
566
|
const results = [];
|
|
567
|
+
let timer;
|
|
509
568
|
try {
|
|
510
|
-
|
|
569
|
+
const peers = store.peers();
|
|
570
|
+
const bounded = deadline ?? new Promise((resolve) => {
|
|
571
|
+
timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
|
|
572
|
+
timer.unref?.();
|
|
573
|
+
});
|
|
574
|
+
const fetches = await mapSettled(
|
|
575
|
+
peers,
|
|
576
|
+
MAX_CONCURRENT_PEERS,
|
|
577
|
+
async (peer) => parseJsonl(
|
|
578
|
+
await channel.exec(peer.target, ["murmur", "export", "--since", String(peer.watermark)])
|
|
579
|
+
),
|
|
580
|
+
bounded
|
|
581
|
+
);
|
|
582
|
+
for (const [index, peer] of peers.entries()) {
|
|
583
|
+
const fetch = fetches[index];
|
|
511
584
|
try {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
"--since",
|
|
516
|
-
String(peer.watermark)
|
|
517
|
-
]);
|
|
518
|
-
const { envelope, events } = parseJsonl(output);
|
|
585
|
+
if (!fetch) throw new Error("collect deadline passed before this peer answered");
|
|
586
|
+
if (fetch.status === "rejected") throw fetch.reason;
|
|
587
|
+
const { envelope, events } = fetch.value;
|
|
519
588
|
const ingested = store.ingest(events);
|
|
520
|
-
const
|
|
589
|
+
const origin = events.filter((event) => event.host_id === envelope.host_id);
|
|
590
|
+
const watermark = origin.reduce(
|
|
591
|
+
(highest, event) => Math.max(highest, event.seq),
|
|
592
|
+
peer.watermark
|
|
593
|
+
);
|
|
521
594
|
store.upsertPeer({
|
|
522
595
|
name: peer.name,
|
|
523
596
|
target: peer.target,
|
|
@@ -530,9 +603,16 @@ async function collect(store, channel, now = Date.now()) {
|
|
|
530
603
|
// events: an export that returns nothing proves the binary ran, not
|
|
531
604
|
// that tmux is back, which is the distinction that let a dead host
|
|
532
605
|
// look healthy for three hours.
|
|
533
|
-
|
|
606
|
+
//
|
|
607
|
+
// Keyed on the watermark advancing, not on ingest's insert count.
|
|
608
|
+
// Two reasons the count was wrong. Ingest is INSERT OR IGNORE, so a
|
|
609
|
+
// retry after a partial apply re-sees the same events and reports
|
|
610
|
+
// zero -- leaving a recovered host marked down until it happened to
|
|
611
|
+
// author again. And the count includes rows from other origins that
|
|
612
|
+
// this peer merely relayed, which say nothing about whether this
|
|
613
|
+
// peer's tmux is back.
|
|
614
|
+
tmux_down_at: watermark > peer.watermark ? null : peer.tmux_down_at
|
|
534
615
|
});
|
|
535
|
-
store.prune();
|
|
536
616
|
results.push({ peer: peer.name, ok: true, ingested });
|
|
537
617
|
} catch (error) {
|
|
538
618
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -544,6 +624,16 @@ async function collect(store, channel, now = Date.now()) {
|
|
|
544
624
|
} catch (error) {
|
|
545
625
|
process.stderr.write(
|
|
546
626
|
`murmur: collect: ${error instanceof Error ? error.message : String(error)}
|
|
627
|
+
`
|
|
628
|
+
);
|
|
629
|
+
} finally {
|
|
630
|
+
clearTimeout(timer);
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
store.prune();
|
|
634
|
+
} catch (error) {
|
|
635
|
+
process.stderr.write(
|
|
636
|
+
`murmur: collect: prune: ${error instanceof Error ? error.message : String(error)}
|
|
547
637
|
`
|
|
548
638
|
);
|
|
549
639
|
}
|
|
@@ -553,16 +643,6 @@ async function collect(store, channel, now = Date.now()) {
|
|
|
553
643
|
// src/glance.ts
|
|
554
644
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
555
645
|
var GLANCE_LINES = 40;
|
|
556
|
-
var SSH_OPTIONS2 = [
|
|
557
|
-
"-o",
|
|
558
|
-
"BatchMode=yes",
|
|
559
|
-
"-o",
|
|
560
|
-
"ControlMaster=no",
|
|
561
|
-
"-o",
|
|
562
|
-
"ControlPath=~/.ssh/control/%r@%h:%p",
|
|
563
|
-
"-o",
|
|
564
|
-
"ConnectTimeout=2"
|
|
565
|
-
];
|
|
566
646
|
function glance(store, agent, lines = GLANCE_LINES) {
|
|
567
647
|
if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
|
|
568
648
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
@@ -572,7 +652,7 @@ function glance(store, agent, lines = GLANCE_LINES) {
|
|
|
572
652
|
return execFileSync3(
|
|
573
653
|
"ssh",
|
|
574
654
|
[
|
|
575
|
-
...
|
|
655
|
+
...SSH_OPTIONS,
|
|
576
656
|
target,
|
|
577
657
|
"tmux",
|
|
578
658
|
"capture-pane",
|
|
@@ -824,6 +904,14 @@ function openStore() {
|
|
|
824
904
|
const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
|
|
825
905
|
return rows.map(toEvent);
|
|
826
906
|
},
|
|
907
|
+
latestForAgent(hostId, agentId) {
|
|
908
|
+
const row = database.prepare(
|
|
909
|
+
`SELECT * FROM events
|
|
910
|
+
WHERE host_id = ? AND agent_id = ?
|
|
911
|
+
ORDER BY seq DESC LIMIT 1`
|
|
912
|
+
).get(hostId, agentId);
|
|
913
|
+
return row ? toEvent(row) : null;
|
|
914
|
+
},
|
|
827
915
|
maxSeq(hostId) {
|
|
828
916
|
return selectMaxSeq.get(hostId).seq;
|
|
829
917
|
},
|
|
@@ -884,8 +972,8 @@ function openStore() {
|
|
|
884
972
|
var manifest = createRequire(import.meta.url)("../package.json");
|
|
885
973
|
var VERSION = manifest.version;
|
|
886
974
|
export {
|
|
887
|
-
COLLECT_INTERVAL_MS,
|
|
888
975
|
DEFAULT_DRIVER,
|
|
976
|
+
MAX_CONCURRENT_PEERS,
|
|
889
977
|
SCHEMA_VERSION,
|
|
890
978
|
STALENESS_MS,
|
|
891
979
|
STORE_VERSION,
|