@martintrojer/murmur 0.1.2 → 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/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 execFileSync("tmux", args, {
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 = tmux.liveWindows();
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
- tmux.attach(agent.session, agent.window);
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 = spawnSync(
226
- "ssh",
227
- ["-o", "BatchMode=yes", target, `tmux list-windows -a -F ${shellQuote("#{window_id}")}`],
228
- { encoding: "utf8", timeout: 1e4 }
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.error !== void 0;
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. The collector never prompts for auth, so connect once by hand to warm the connection, then retry.`
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((probe.stdout ?? "").split("\n").filter(Boolean));
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 {
@@ -255,52 +316,27 @@ function jumpToAgent(store, agent) {
255
316
  const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
256
317
  if (process.env.TMUX) {
257
318
  const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
258
- const name = `@${peer?.display_name ?? target}`;
259
- const existing = tmux.windowNamed(name);
319
+ const name = `@${peer?.name ?? target}`;
320
+ const existing = mux.windowNamed(name);
260
321
  if (existing) {
261
- tmux.selectWindow(existing);
262
- return { ok: true };
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
- spawnSync("tmux", ["new-window", "-n", name, command], { stdio: "ignore" });
265
- return { ok: true };
266
- }
267
- spawnSync("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], { stdio: "inherit" });
268
- return { ok: true };
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 COLLECT_INTERVAL_MS = 3e4;
493
- var STALENESS_MS = 2 * COLLECT_INTERVAL_MS;
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
- for (const peer of store.peers()) {
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
- const output = await channel.exec(peer.target, [
513
- "murmur",
514
- "export",
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 watermark = events.filter((event) => event.host_id === envelope.host_id).reduce((highest, event) => Math.max(highest, event.seq), peer.watermark);
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
- tmux_down_at: ingested > 0 ? null : peer.tmux_down_at
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
- ...SSH_OPTIONS2,
655
+ ...SSH_OPTIONS,
576
656
  target,
577
657
  "tmux",
578
658
  "capture-pane",
@@ -631,7 +711,13 @@ function status(store, now = Date.now()) {
631
711
  // A jump proved this host's tmux was down and nothing has authored since.
632
712
  // Stronger than staleness: the host answers, its agents are just gone.
633
713
  tmux_down: peer?.tmux_down_at != null,
634
- host: peer?.display_name ?? peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
714
+ // The name the human typed, not the machine's self-reported hostname. A
715
+ // peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
716
+ // and that is what the picker showed — a string that appears nowhere
717
+ // else in the tool and cannot be typed at `peer remove` or searched for.
718
+ // Only the local node, which has no peer row, falls back to its own
719
+ // discovered display_name.
720
+ host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
635
721
  };
636
722
  });
637
723
  return {
@@ -818,6 +904,14 @@ function openStore() {
818
904
  const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
819
905
  return rows.map(toEvent);
820
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
+ },
821
915
  maxSeq(hostId) {
822
916
  return selectMaxSeq.get(hostId).seq;
823
917
  },
@@ -878,8 +972,8 @@ function openStore() {
878
972
  var manifest = createRequire(import.meta.url)("../package.json");
879
973
  var VERSION = manifest.version;
880
974
  export {
881
- COLLECT_INTERVAL_MS,
882
975
  DEFAULT_DRIVER,
976
+ MAX_CONCURRENT_PEERS,
883
977
  SCHEMA_VERSION,
884
978
  STALENESS_MS,
885
979
  STORE_VERSION,