@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/ARCHITECTURE.md +13 -5
- package/CHANGELOG.md +75 -0
- package/README.md +1 -1
- package/dist/cli.js +219 -110
- 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 +179 -85
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/ARCHITECTURE.md
CHANGED
|
@@ -223,11 +223,19 @@ latencies. Both are the reader pulling, and OpenSSH `ControlMaster` collapses
|
|
|
223
223
|
them, because the persisted control socket *is* the tunnel. Push needs a
|
|
224
224
|
listener, which is the thing this design does not have.
|
|
225
225
|
|
|
226
|
-
**The collector never
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
**The collector never prompts.** It reuses a warm `ControlMaster` socket when
|
|
227
|
+
there is one and cold-connects when there is not, so with ordinary key auth a
|
|
228
|
+
peer is visible whether or not you have ssh'd there recently — fleet visibility
|
|
229
|
+
is not rationed by a daily chore. `BatchMode=yes` is what makes the cold path
|
|
230
|
+
acceptable: no password, passphrase or host-key prompt, so a peer that cannot
|
|
231
|
+
authenticate silently fails fast and shows stale instead of blocking on a
|
|
232
|
+
human.
|
|
233
|
+
|
|
234
|
+
The unhandled case is a host demanding a hardware-token touch per connection,
|
|
235
|
+
where "cannot authenticate without a human" is not something `BatchMode` can
|
|
236
|
+
detect before the token blinks. `hasWarmSocket` exists for that: gating collect
|
|
237
|
+
on it per peer would restore the strict posture for those hosts only. Not wired
|
|
238
|
+
up, because no peer in use needs it.
|
|
231
239
|
|
|
232
240
|
**Membership is local and asymmetric.** No shared node list, no registry, no
|
|
233
241
|
join protocol. Reachability is not symmetric: a laptop reaches a server, and
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes per release. Written for someone deciding whether to upgrade,
|
|
4
|
+
so it says what changed for a user rather than listing every commit.
|
|
5
|
+
|
|
6
|
+
## 0.1.4
|
|
7
|
+
|
|
8
|
+
A review pass over the peer-collection code, and the bugs it found.
|
|
9
|
+
|
|
10
|
+
Collection is driven by tmux re-running `murmur status` on a tick, and the peer
|
|
11
|
+
loop was serial: every peer paid the ssh timeout of every peer ahead of it, so
|
|
12
|
+
three sleeping laptops froze the status bar for thirty seconds. Peers are now
|
|
13
|
+
fetched concurrently, with a bound on the whole collect rather than only on each
|
|
14
|
+
peer.
|
|
15
|
+
|
|
16
|
+
Fixed, each with a symptom you could have hit:
|
|
17
|
+
|
|
18
|
+
- **A large peer could never sync.** The ssh export ran into Node's default
|
|
19
|
+
1 MiB output limit, about 2,600 events. Past that the collect failed, and it
|
|
20
|
+
failed permanently: the watermark only advances on success, so every retry
|
|
21
|
+
re-requested the same oversized range. A reachable peer sat stale forever.
|
|
22
|
+
- **Jumping to an agent claimed success even when it failed.** A failed
|
|
23
|
+
new-window, ssh attach, or select-window all reported success, so the picker
|
|
24
|
+
closed and nothing moved, with no message. Jump now reports the failure.
|
|
25
|
+
- **A recovered host could stay marked "no tmux".** The recovery check counted
|
|
26
|
+
database inserts, which read zero on a retry after a partial write, so the
|
|
27
|
+
host stayed marked dead until it happened to author a new event.
|
|
28
|
+
- **The pi extension leaked a database handle per failed write**, inside a
|
|
29
|
+
process that can run for days.
|
|
30
|
+
- **ssh timeouts are sized to the status-bar tick** and deliberately
|
|
31
|
+
aggressive. A slow node is now rejected rather than allowed to hold up the
|
|
32
|
+
HUD; it shows stale until the next tick.
|
|
33
|
+
- **Retention ran only when peers were configured.** A single-machine node
|
|
34
|
+
never pruned, so its event log grew without bound.
|
|
35
|
+
|
|
36
|
+
Internal, no behaviour change: one shared ssh option list instead of three
|
|
37
|
+
hand-rolled copies, `clear`'s queries moved behind `Store`, and `STALENESS_MS`
|
|
38
|
+
states its value instead of deriving it from a collect interval nothing
|
|
39
|
+
enforced.
|
|
40
|
+
|
|
41
|
+
Tests went 83 to 103. Four existing tests could not fail and were rewritten;
|
|
42
|
+
every new test was verified by breaking the code it covers.
|
|
43
|
+
|
|
44
|
+
## 0.1.3
|
|
45
|
+
|
|
46
|
+
Both fixes are about the peer columns being unreadable.
|
|
47
|
+
|
|
48
|
+
- `peer list` printed tab-separated fields with no header. Now a header and
|
|
49
|
+
aligned columns.
|
|
50
|
+
- `murmur pick` showed the node's self-reported hostname, which can be a
|
|
51
|
+
container id -- a string that appears nowhere else and cannot be typed at
|
|
52
|
+
`peer remove`. It now shows the peer name you configured.
|
|
53
|
+
|
|
54
|
+
## 0.1.2
|
|
55
|
+
|
|
56
|
+
- **The picker had a doubled border inside a tmux popup.** `display-popup` draws
|
|
57
|
+
its own, so fzf's sat one character inside it. The popup is the normal way to
|
|
58
|
+
run the picker, so this was the common case.
|
|
59
|
+
- **Documented the focus-clear hooks**, which have to be wired by hand per node.
|
|
60
|
+
Without them a finished agent stays marked `done` forever and the picker fills
|
|
61
|
+
with rows that need nothing.
|
|
62
|
+
|
|
63
|
+
## 0.1.1
|
|
64
|
+
|
|
65
|
+
- A stale badge is now reconciled, and a shell pane no longer clears the badge
|
|
66
|
+
of the agent pane next to it.
|
|
67
|
+
- Agents are searchable by tmux session, and typing matches substrings rather
|
|
68
|
+
than scattered characters.
|
|
69
|
+
- The delete key drops a stuck row from the picker.
|
|
70
|
+
- `--version` reads the manifest instead of a hardcoded string.
|
|
71
|
+
- Only `$TMUX_PANE` decides whether we are inside tmux.
|
|
72
|
+
|
|
73
|
+
## 0.1.0
|
|
74
|
+
|
|
75
|
+
First release. Agent state across every machine you work on, in one view.
|
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ session, and its host, as literal substrings rather than scattered characters.
|
|
|
126
126
|
|
|
127
127
|
## Status
|
|
128
128
|
|
|
129
|
-
**0.1.
|
|
129
|
+
**0.1.3.** In daily use on one machine and verified across two over real ssh.
|
|
130
130
|
It is new and not battle-tested. The known gaps are listed at the end of
|
|
131
131
|
[ARCHITECTURE.md](ARCHITECTURE.md#known-gaps); the one most likely to annoy you
|
|
132
132
|
is that jumping to a remote agent nests tmux inside tmux, which every tool in
|
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
|
-
// src/cli/clear.ts
|
|
7
|
-
import Database2 from "better-sqlite3";
|
|
8
|
-
|
|
9
6
|
// src/identity.ts
|
|
10
7
|
import { randomUUID } from "crypto";
|
|
11
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -102,7 +99,7 @@ var tmux = {
|
|
|
102
99
|
},
|
|
103
100
|
attach(session, window) {
|
|
104
101
|
runTmux(["switch-client", "-t", session]);
|
|
105
|
-
runTmux(["select-window", "-t", window]);
|
|
102
|
+
return runTmux(["select-window", "-t", window]) !== null;
|
|
106
103
|
},
|
|
107
104
|
// Window ids are what the log stores, because they are stable; names are
|
|
108
105
|
// what a human recognises in a picker. Names are live tmux state, not
|
|
@@ -134,7 +131,10 @@ var tmux = {
|
|
|
134
131
|
return null;
|
|
135
132
|
},
|
|
136
133
|
selectWindow(window) {
|
|
137
|
-
runTmux(["select-window", "-t", window]);
|
|
134
|
+
return runTmux(["select-window", "-t", window]) !== null;
|
|
135
|
+
},
|
|
136
|
+
newWindow(name, command) {
|
|
137
|
+
return runTmux(["new-window", "-n", name, command]) !== null;
|
|
138
138
|
},
|
|
139
139
|
// The window a pane belongs to, for a pane murmur has no event for. Clearing
|
|
140
140
|
// a badge is a tmux operation and does not require murmur to own the pane.
|
|
@@ -322,6 +322,14 @@ function openStore() {
|
|
|
322
322
|
const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
|
|
323
323
|
return rows.map(toEvent);
|
|
324
324
|
},
|
|
325
|
+
latestForAgent(hostId, agentId) {
|
|
326
|
+
const row = database.prepare(
|
|
327
|
+
`SELECT * FROM events
|
|
328
|
+
WHERE host_id = ? AND agent_id = ?
|
|
329
|
+
ORDER BY seq DESC LIMIT 1`
|
|
330
|
+
).get(hostId, agentId);
|
|
331
|
+
return row ? toEvent(row) : null;
|
|
332
|
+
},
|
|
325
333
|
maxSeq(hostId) {
|
|
326
334
|
return selectMaxSeq.get(hostId).seq;
|
|
327
335
|
},
|
|
@@ -379,23 +387,15 @@ function openStore() {
|
|
|
379
387
|
}
|
|
380
388
|
|
|
381
389
|
// src/cli/clear.ts
|
|
382
|
-
function windowHasAgent(window, focused, hostId, mux) {
|
|
390
|
+
function windowHasAgent(window, focused, hostId, mux, store) {
|
|
383
391
|
if (!hostId) return false;
|
|
384
392
|
const siblings = mux.panesInWindow(window).filter((candidate) => candidate !== focused);
|
|
385
393
|
if (siblings.length === 0) return false;
|
|
394
|
+
if (!store) return false;
|
|
386
395
|
try {
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const row = database.prepare(
|
|
391
|
-
`SELECT state FROM events
|
|
392
|
-
WHERE host_id = ? AND agent_id = ?
|
|
393
|
-
ORDER BY seq DESC LIMIT 1`
|
|
394
|
-
).get(hostId, `${hostId}:${sibling}`);
|
|
395
|
-
if (row && row.state !== "cleared") return true;
|
|
396
|
-
}
|
|
397
|
-
} finally {
|
|
398
|
-
database.close();
|
|
396
|
+
for (const sibling of siblings) {
|
|
397
|
+
const latest = store.latestForAgent(hostId, `${hostId}:${sibling}`);
|
|
398
|
+
if (latest && latest.state !== "cleared") return true;
|
|
399
399
|
}
|
|
400
400
|
return false;
|
|
401
401
|
} catch {
|
|
@@ -403,6 +403,7 @@ function windowHasAgent(window, focused, hostId, mux) {
|
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
405
|
function clearPane(pane, mux = tmux) {
|
|
406
|
+
let store;
|
|
406
407
|
try {
|
|
407
408
|
if (!pane) return;
|
|
408
409
|
const window = mux.windowForPane(pane);
|
|
@@ -410,24 +411,13 @@ function clearPane(pane, mux = tmux) {
|
|
|
410
411
|
let owner;
|
|
411
412
|
if (identity) {
|
|
412
413
|
try {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
owner = database.prepare(
|
|
416
|
-
`SELECT agent_id, session, window, pane, session_name, window_name,
|
|
417
|
-
agent_name, pi_session, workstream, role, cli, driver, state
|
|
418
|
-
FROM events
|
|
419
|
-
WHERE host_id = ? AND agent_id = ?
|
|
420
|
-
ORDER BY seq DESC
|
|
421
|
-
LIMIT 1`
|
|
422
|
-
).get(identity.host_id, `${identity.host_id}:${pane}`);
|
|
423
|
-
} finally {
|
|
424
|
-
database.close();
|
|
425
|
-
}
|
|
414
|
+
store = openStore();
|
|
415
|
+
owner = store.latestForAgent(identity.host_id, `${identity.host_id}:${pane}`) ?? void 0;
|
|
426
416
|
} catch {
|
|
427
417
|
}
|
|
428
418
|
}
|
|
429
419
|
if (!owner) {
|
|
430
|
-
if (window && !windowHasAgent(window, pane, identity?.host_id, mux)) {
|
|
420
|
+
if (window && !windowHasAgent(window, pane, identity?.host_id, mux, store)) {
|
|
431
421
|
mux.setState(window, null);
|
|
432
422
|
}
|
|
433
423
|
return;
|
|
@@ -436,9 +426,8 @@ function clearPane(pane, mux = tmux) {
|
|
|
436
426
|
mux.setState(owner.window, null);
|
|
437
427
|
return;
|
|
438
428
|
}
|
|
439
|
-
const store = openStore();
|
|
440
429
|
try {
|
|
441
|
-
store
|
|
430
|
+
store?.append({
|
|
442
431
|
agent_id: owner.agent_id,
|
|
443
432
|
session: owner.session,
|
|
444
433
|
window: owner.window,
|
|
@@ -461,11 +450,15 @@ function clearPane(pane, mux = tmux) {
|
|
|
461
450
|
reason: "",
|
|
462
451
|
extra: {}
|
|
463
452
|
});
|
|
464
|
-
}
|
|
465
|
-
store.close();
|
|
453
|
+
} catch {
|
|
466
454
|
}
|
|
467
455
|
mux.setState(owner.window, null);
|
|
468
456
|
} catch {
|
|
457
|
+
} finally {
|
|
458
|
+
try {
|
|
459
|
+
store?.close();
|
|
460
|
+
} catch {
|
|
461
|
+
}
|
|
469
462
|
}
|
|
470
463
|
}
|
|
471
464
|
function registerClear(program2) {
|
|
@@ -477,8 +470,8 @@ import { execFile, execFileSync as execFileSync2 } from "child_process";
|
|
|
477
470
|
import { promisify } from "util";
|
|
478
471
|
var execFileAsync = promisify(execFile);
|
|
479
472
|
var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
|
|
480
|
-
var CONNECT_TIMEOUT_S =
|
|
481
|
-
var EXEC_TIMEOUT_MS =
|
|
473
|
+
var CONNECT_TIMEOUT_S = 1;
|
|
474
|
+
var EXEC_TIMEOUT_MS = 3e3;
|
|
482
475
|
var SSH_OPTIONS = [
|
|
483
476
|
"-o",
|
|
484
477
|
"BatchMode=yes",
|
|
@@ -489,11 +482,13 @@ var SSH_OPTIONS = [
|
|
|
489
482
|
"-o",
|
|
490
483
|
`ConnectTimeout=${CONNECT_TIMEOUT_S}`
|
|
491
484
|
];
|
|
485
|
+
var MAX_EXPORT_BYTES = 64 * 1024 * 1024;
|
|
492
486
|
var ssh = {
|
|
493
487
|
async exec(target, argv) {
|
|
494
488
|
const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
|
|
495
489
|
encoding: "utf8",
|
|
496
|
-
timeout: EXEC_TIMEOUT_MS
|
|
490
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
491
|
+
maxBuffer: MAX_EXPORT_BYTES
|
|
497
492
|
});
|
|
498
493
|
return stdout;
|
|
499
494
|
}
|
|
@@ -693,8 +688,30 @@ function exportJsonl(store, since, isAlive, live) {
|
|
|
693
688
|
}
|
|
694
689
|
|
|
695
690
|
// src/collector.ts
|
|
696
|
-
var
|
|
697
|
-
var
|
|
691
|
+
var STALENESS_MS = 6e4;
|
|
692
|
+
var MAX_CONCURRENT_PEERS = 8;
|
|
693
|
+
var COLLECT_DEADLINE_MS = 4e3;
|
|
694
|
+
async function mapSettled(items, limit, task, deadline) {
|
|
695
|
+
const results = new Array(items.length);
|
|
696
|
+
let cursor = 0;
|
|
697
|
+
let expired = false;
|
|
698
|
+
const stop = deadline?.then(() => {
|
|
699
|
+
expired = true;
|
|
700
|
+
});
|
|
701
|
+
const worker = async () => {
|
|
702
|
+
while (cursor < items.length && !expired) {
|
|
703
|
+
const index = cursor++;
|
|
704
|
+
try {
|
|
705
|
+
results[index] = { status: "fulfilled", value: await task(items[index]) };
|
|
706
|
+
} catch (reason) {
|
|
707
|
+
results[index] = { status: "rejected", reason };
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
const pool = Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
712
|
+
await (stop ? Promise.race([pool, stop]) : pool);
|
|
713
|
+
return results;
|
|
714
|
+
}
|
|
698
715
|
function parseJsonl(output) {
|
|
699
716
|
const lines = output.trim().split("\n");
|
|
700
717
|
const envelope = JSON.parse(lines.shift() ?? "");
|
|
@@ -708,20 +725,35 @@ function parseJsonl(output) {
|
|
|
708
725
|
events: lines.map((line) => eventFromWire(JSON.parse(line)))
|
|
709
726
|
};
|
|
710
727
|
}
|
|
711
|
-
async function collect(store, channel, now = Date.now()) {
|
|
728
|
+
async function collect(store, channel, now = Date.now(), deadline) {
|
|
712
729
|
const results = [];
|
|
730
|
+
let timer;
|
|
713
731
|
try {
|
|
714
|
-
|
|
732
|
+
const peers = store.peers();
|
|
733
|
+
const bounded = deadline ?? new Promise((resolve) => {
|
|
734
|
+
timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
|
|
735
|
+
timer.unref?.();
|
|
736
|
+
});
|
|
737
|
+
const fetches = await mapSettled(
|
|
738
|
+
peers,
|
|
739
|
+
MAX_CONCURRENT_PEERS,
|
|
740
|
+
async (peer) => parseJsonl(
|
|
741
|
+
await channel.exec(peer.target, ["murmur", "export", "--since", String(peer.watermark)])
|
|
742
|
+
),
|
|
743
|
+
bounded
|
|
744
|
+
);
|
|
745
|
+
for (const [index, peer] of peers.entries()) {
|
|
746
|
+
const fetch = fetches[index];
|
|
715
747
|
try {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
"--since",
|
|
720
|
-
String(peer.watermark)
|
|
721
|
-
]);
|
|
722
|
-
const { envelope, events } = parseJsonl(output);
|
|
748
|
+
if (!fetch) throw new Error("collect deadline passed before this peer answered");
|
|
749
|
+
if (fetch.status === "rejected") throw fetch.reason;
|
|
750
|
+
const { envelope, events } = fetch.value;
|
|
723
751
|
const ingested = store.ingest(events);
|
|
724
|
-
const
|
|
752
|
+
const origin = events.filter((event) => event.host_id === envelope.host_id);
|
|
753
|
+
const watermark = origin.reduce(
|
|
754
|
+
(highest, event) => Math.max(highest, event.seq),
|
|
755
|
+
peer.watermark
|
|
756
|
+
);
|
|
725
757
|
store.upsertPeer({
|
|
726
758
|
name: peer.name,
|
|
727
759
|
target: peer.target,
|
|
@@ -734,9 +766,16 @@ async function collect(store, channel, now = Date.now()) {
|
|
|
734
766
|
// events: an export that returns nothing proves the binary ran, not
|
|
735
767
|
// that tmux is back, which is the distinction that let a dead host
|
|
736
768
|
// look healthy for three hours.
|
|
737
|
-
|
|
769
|
+
//
|
|
770
|
+
// Keyed on the watermark advancing, not on ingest's insert count.
|
|
771
|
+
// Two reasons the count was wrong. Ingest is INSERT OR IGNORE, so a
|
|
772
|
+
// retry after a partial apply re-sees the same events and reports
|
|
773
|
+
// zero -- leaving a recovered host marked down until it happened to
|
|
774
|
+
// author again. And the count includes rows from other origins that
|
|
775
|
+
// this peer merely relayed, which say nothing about whether this
|
|
776
|
+
// peer's tmux is back.
|
|
777
|
+
tmux_down_at: watermark > peer.watermark ? null : peer.tmux_down_at
|
|
738
778
|
});
|
|
739
|
-
store.prune();
|
|
740
779
|
results.push({ peer: peer.name, ok: true, ingested });
|
|
741
780
|
} catch (error) {
|
|
742
781
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -748,6 +787,16 @@ async function collect(store, channel, now = Date.now()) {
|
|
|
748
787
|
} catch (error) {
|
|
749
788
|
process.stderr.write(
|
|
750
789
|
`murmur: collect: ${error instanceof Error ? error.message : String(error)}
|
|
790
|
+
`
|
|
791
|
+
);
|
|
792
|
+
} finally {
|
|
793
|
+
clearTimeout(timer);
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
store.prune();
|
|
797
|
+
} catch (error) {
|
|
798
|
+
process.stderr.write(
|
|
799
|
+
`murmur: collect: prune: ${error instanceof Error ? error.message : String(error)}
|
|
751
800
|
`
|
|
752
801
|
);
|
|
753
802
|
}
|
|
@@ -842,6 +891,34 @@ function sshHosts() {
|
|
|
842
891
|
return [];
|
|
843
892
|
}
|
|
844
893
|
}
|
|
894
|
+
function formatTable(rows) {
|
|
895
|
+
const widths = [];
|
|
896
|
+
for (const row of rows) {
|
|
897
|
+
row.forEach((cell, index) => {
|
|
898
|
+
widths[index] = Math.max(widths[index] ?? 0, cell.length);
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
return rows.map(
|
|
902
|
+
(row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd()
|
|
903
|
+
).map((line) => `${line}
|
|
904
|
+
`).join("");
|
|
905
|
+
}
|
|
906
|
+
function peerAddDecision(input) {
|
|
907
|
+
const { name, target, envelope, selfHostId, peers } = input;
|
|
908
|
+
if (!envelope) return null;
|
|
909
|
+
if (envelope.host_id === selfHostId) {
|
|
910
|
+
return `${target} is this node; not adding it as a peer
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
const existing = peers.find(
|
|
914
|
+
(candidate) => candidate.host_id === envelope.host_id && candidate.name !== name
|
|
915
|
+
);
|
|
916
|
+
if (existing) {
|
|
917
|
+
return `${target} is already configured as peer "${existing.name}" (${envelope.display_name}); remove it first to rename
|
|
918
|
+
`;
|
|
919
|
+
}
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
845
922
|
function registerPeer(program2) {
|
|
846
923
|
const peer = program2.command("peer").description("Manage peers");
|
|
847
924
|
peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
|
|
@@ -854,22 +931,17 @@ function registerPeer(program2) {
|
|
|
854
931
|
} catch {
|
|
855
932
|
envelope = null;
|
|
856
933
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
`
|
|
869
|
-
);
|
|
870
|
-
process.exitCode = 1;
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
934
|
+
const refusal = peerAddDecision({
|
|
935
|
+
name,
|
|
936
|
+
target,
|
|
937
|
+
envelope,
|
|
938
|
+
selfHostId: loadIdentity()?.host_id ?? null,
|
|
939
|
+
peers: store.peers()
|
|
940
|
+
});
|
|
941
|
+
if (refusal) {
|
|
942
|
+
process.stderr.write(refusal);
|
|
943
|
+
process.exitCode = 1;
|
|
944
|
+
return;
|
|
873
945
|
}
|
|
874
946
|
store.upsertPeer({
|
|
875
947
|
name,
|
|
@@ -904,16 +976,26 @@ function registerPeer(program2) {
|
|
|
904
976
|
const store = openStore();
|
|
905
977
|
try {
|
|
906
978
|
const peers = store.peers();
|
|
907
|
-
if (options.json)
|
|
979
|
+
if (options.json) {
|
|
980
|
+
process.stdout.write(`${JSON.stringify(peers)}
|
|
908
981
|
`);
|
|
909
|
-
|
|
910
|
-
for (const configured of peers) {
|
|
911
|
-
process.stdout.write(
|
|
912
|
-
`${configured.name} ${configured.target} ${configured.display_name ?? "unknown"}
|
|
913
|
-
`
|
|
914
|
-
);
|
|
915
|
-
}
|
|
982
|
+
return;
|
|
916
983
|
}
|
|
984
|
+
if (peers.length === 0) {
|
|
985
|
+
process.stdout.write("no peers configured\n");
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
const rows = [
|
|
989
|
+
// HOSTNAME, not HOST: this is what the node reported about itself,
|
|
990
|
+
// which is not the handle any other command takes. NAME is.
|
|
991
|
+
["NAME", "TARGET", "HOSTNAME"],
|
|
992
|
+
...peers.map((configured) => [
|
|
993
|
+
configured.name,
|
|
994
|
+
configured.target,
|
|
995
|
+
configured.display_name ?? "unknown"
|
|
996
|
+
])
|
|
997
|
+
];
|
|
998
|
+
process.stdout.write(formatTable(rows));
|
|
917
999
|
} finally {
|
|
918
1000
|
store.close();
|
|
919
1001
|
}
|
|
@@ -949,6 +1031,21 @@ function terminalText(value) {
|
|
|
949
1031
|
function shellQuote(value) {
|
|
950
1032
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
951
1033
|
}
|
|
1034
|
+
var spawnRunner = (file, args, inherit = false) => {
|
|
1035
|
+
const result = spawnSync(file, args, {
|
|
1036
|
+
encoding: "utf8",
|
|
1037
|
+
timeout: 1e4,
|
|
1038
|
+
...inherit ? { stdio: "inherit" } : {}
|
|
1039
|
+
});
|
|
1040
|
+
return {
|
|
1041
|
+
status: result.status,
|
|
1042
|
+
stdout: result.stdout ?? "",
|
|
1043
|
+
// spawnSync reports a failure to even start the child in `error`, leaving
|
|
1044
|
+
// status null. Collapsing both here keeps the decision table below reading
|
|
1045
|
+
// as one question rather than two.
|
|
1046
|
+
failed: result.error !== void 0
|
|
1047
|
+
};
|
|
1048
|
+
};
|
|
952
1049
|
function forgetHostReplica(store, hostId) {
|
|
953
1050
|
try {
|
|
954
1051
|
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
@@ -981,10 +1078,10 @@ function forgetOneAgent(store, agent, mux = tmux) {
|
|
|
981
1078
|
}
|
|
982
1079
|
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
983
1080
|
}
|
|
984
|
-
function jumpToAgent(store, agent) {
|
|
1081
|
+
function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
985
1082
|
const identity = loadIdentity();
|
|
986
1083
|
if (agent.host_id === identity?.host_id) {
|
|
987
|
-
const live =
|
|
1084
|
+
const live = mux.liveWindows();
|
|
988
1085
|
if (live && !live.has(agent.window)) {
|
|
989
1086
|
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
990
1087
|
return {
|
|
@@ -993,7 +1090,13 @@ function jumpToAgent(store, agent) {
|
|
|
993
1090
|
message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
|
|
994
1091
|
};
|
|
995
1092
|
}
|
|
996
|
-
|
|
1093
|
+
if (!mux.attach(agent.session, agent.window)) {
|
|
1094
|
+
return {
|
|
1095
|
+
ok: false,
|
|
1096
|
+
reason: "attach_failed",
|
|
1097
|
+
message: `could not attach to ${agentLabel(agent)} (tmux select-window failed).`
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
997
1100
|
return { ok: true };
|
|
998
1101
|
}
|
|
999
1102
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
@@ -1005,18 +1108,18 @@ function jumpToAgent(store, agent) {
|
|
|
1005
1108
|
message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
|
|
1006
1109
|
};
|
|
1007
1110
|
}
|
|
1008
|
-
const probe =
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
);
|
|
1111
|
+
const probe = run("ssh", [
|
|
1112
|
+
...SSH_OPTIONS,
|
|
1113
|
+
target,
|
|
1114
|
+
`tmux list-windows -a -F ${shellQuote("#{window_id}")}`
|
|
1115
|
+
]);
|
|
1013
1116
|
if (probe.status !== 0) {
|
|
1014
|
-
const sshFailed = probe.status === 255 || probe.
|
|
1117
|
+
const sshFailed = probe.status === 255 || probe.failed;
|
|
1015
1118
|
if (sshFailed) {
|
|
1016
1119
|
return {
|
|
1017
1120
|
ok: false,
|
|
1018
1121
|
reason: "unreachable",
|
|
1019
|
-
message: `cannot reach ${target} over ssh.
|
|
1122
|
+
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.`
|
|
1020
1123
|
};
|
|
1021
1124
|
}
|
|
1022
1125
|
forgetHostReplica(store, agent.host_id);
|
|
@@ -1026,7 +1129,7 @@ function jumpToAgent(store, agent) {
|
|
|
1026
1129
|
message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
|
|
1027
1130
|
};
|
|
1028
1131
|
}
|
|
1029
|
-
const remoteWindows = new Set(
|
|
1132
|
+
const remoteWindows = new Set(probe.stdout.split("\n").filter(Boolean));
|
|
1030
1133
|
if (!remoteWindows.has(agent.window)) {
|
|
1031
1134
|
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
1032
1135
|
return {
|
|
@@ -1038,32 +1141,32 @@ function jumpToAgent(store, agent) {
|
|
|
1038
1141
|
const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
|
|
1039
1142
|
if (process.env.TMUX) {
|
|
1040
1143
|
const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
|
|
1041
|
-
const name = `@${peer?.
|
|
1042
|
-
const existing =
|
|
1144
|
+
const name = `@${peer?.name ?? target}`;
|
|
1145
|
+
const existing = mux.windowNamed(name);
|
|
1043
1146
|
if (existing) {
|
|
1044
|
-
|
|
1045
|
-
|
|
1147
|
+
return mux.selectWindow(existing) ? { ok: true } : {
|
|
1148
|
+
ok: false,
|
|
1149
|
+
reason: "attach_failed",
|
|
1150
|
+
message: `could not switch to the existing ${name} window.`
|
|
1151
|
+
};
|
|
1046
1152
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1153
|
+
return mux.newWindow(name, command) ? { ok: true } : {
|
|
1154
|
+
ok: false,
|
|
1155
|
+
reason: "attach_failed",
|
|
1156
|
+
message: `could not open a window to attach to ${target}.`
|
|
1157
|
+
};
|
|
1049
1158
|
}
|
|
1050
|
-
|
|
1051
|
-
return { ok: true }
|
|
1159
|
+
const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
|
|
1160
|
+
return attach.status === 0 && !attach.failed ? { ok: true } : {
|
|
1161
|
+
ok: false,
|
|
1162
|
+
reason: "attach_failed",
|
|
1163
|
+
message: `ssh attach to ${target} failed.`
|
|
1164
|
+
};
|
|
1052
1165
|
}
|
|
1053
1166
|
|
|
1054
1167
|
// src/glance.ts
|
|
1055
1168
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
1056
1169
|
var GLANCE_LINES = 40;
|
|
1057
|
-
var SSH_OPTIONS2 = [
|
|
1058
|
-
"-o",
|
|
1059
|
-
"BatchMode=yes",
|
|
1060
|
-
"-o",
|
|
1061
|
-
"ControlMaster=no",
|
|
1062
|
-
"-o",
|
|
1063
|
-
"ControlPath=~/.ssh/control/%r@%h:%p",
|
|
1064
|
-
"-o",
|
|
1065
|
-
"ConnectTimeout=2"
|
|
1066
|
-
];
|
|
1067
1170
|
function glance(store, agent, lines = GLANCE_LINES) {
|
|
1068
1171
|
if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
|
|
1069
1172
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
@@ -1073,7 +1176,7 @@ function glance(store, agent, lines = GLANCE_LINES) {
|
|
|
1073
1176
|
return execFileSync3(
|
|
1074
1177
|
"ssh",
|
|
1075
1178
|
[
|
|
1076
|
-
...
|
|
1179
|
+
...SSH_OPTIONS,
|
|
1077
1180
|
target,
|
|
1078
1181
|
"tmux",
|
|
1079
1182
|
"capture-pane",
|
|
@@ -1137,7 +1240,13 @@ function status(store, now = Date.now()) {
|
|
|
1137
1240
|
// A jump proved this host's tmux was down and nothing has authored since.
|
|
1138
1241
|
// Stronger than staleness: the host answers, its agents are just gone.
|
|
1139
1242
|
tmux_down: peer?.tmux_down_at != null,
|
|
1140
|
-
|
|
1243
|
+
// The name the human typed, not the machine's self-reported hostname. A
|
|
1244
|
+
// peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
|
|
1245
|
+
// and that is what the picker showed — a string that appears nowhere
|
|
1246
|
+
// else in the tool and cannot be typed at `peer remove` or searched for.
|
|
1247
|
+
// Only the local node, which has no peer row, falls back to its own
|
|
1248
|
+
// discovered display_name.
|
|
1249
|
+
host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
|
|
1141
1250
|
};
|
|
1142
1251
|
});
|
|
1143
1252
|
return {
|
|
@@ -1157,9 +1266,9 @@ function status(store, now = Date.now()) {
|
|
|
1157
1266
|
}))
|
|
1158
1267
|
};
|
|
1159
1268
|
}
|
|
1160
|
-
async function statusWithCollect(store, now = Date.now()) {
|
|
1269
|
+
async function statusWithCollect(store, now = Date.now(), channel = ssh) {
|
|
1161
1270
|
try {
|
|
1162
|
-
await collect(store,
|
|
1271
|
+
await collect(store, channel, now);
|
|
1163
1272
|
} catch (error) {
|
|
1164
1273
|
process.stderr.write(
|
|
1165
1274
|
`murmur: status: collect: ${error instanceof Error ? error.message : String(error)}
|