@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/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/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
|
}
|
|
@@ -854,6 +903,22 @@ function formatTable(rows) {
|
|
|
854
903
|
).map((line) => `${line}
|
|
855
904
|
`).join("");
|
|
856
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
|
+
}
|
|
857
922
|
function registerPeer(program2) {
|
|
858
923
|
const peer = program2.command("peer").description("Manage peers");
|
|
859
924
|
peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
|
|
@@ -866,22 +931,17 @@ function registerPeer(program2) {
|
|
|
866
931
|
} catch {
|
|
867
932
|
envelope = null;
|
|
868
933
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
`
|
|
881
|
-
);
|
|
882
|
-
process.exitCode = 1;
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
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;
|
|
885
945
|
}
|
|
886
946
|
store.upsertPeer({
|
|
887
947
|
name,
|
|
@@ -971,6 +1031,21 @@ function terminalText(value) {
|
|
|
971
1031
|
function shellQuote(value) {
|
|
972
1032
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
973
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
|
+
};
|
|
974
1049
|
function forgetHostReplica(store, hostId) {
|
|
975
1050
|
try {
|
|
976
1051
|
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
@@ -1003,10 +1078,10 @@ function forgetOneAgent(store, agent, mux = tmux) {
|
|
|
1003
1078
|
}
|
|
1004
1079
|
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
1005
1080
|
}
|
|
1006
|
-
function jumpToAgent(store, agent) {
|
|
1081
|
+
function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
1007
1082
|
const identity = loadIdentity();
|
|
1008
1083
|
if (agent.host_id === identity?.host_id) {
|
|
1009
|
-
const live =
|
|
1084
|
+
const live = mux.liveWindows();
|
|
1010
1085
|
if (live && !live.has(agent.window)) {
|
|
1011
1086
|
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
1012
1087
|
return {
|
|
@@ -1015,7 +1090,13 @@ function jumpToAgent(store, agent) {
|
|
|
1015
1090
|
message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
|
|
1016
1091
|
};
|
|
1017
1092
|
}
|
|
1018
|
-
|
|
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
|
+
}
|
|
1019
1100
|
return { ok: true };
|
|
1020
1101
|
}
|
|
1021
1102
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
@@ -1027,18 +1108,18 @@ function jumpToAgent(store, agent) {
|
|
|
1027
1108
|
message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
|
|
1028
1109
|
};
|
|
1029
1110
|
}
|
|
1030
|
-
const probe =
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
);
|
|
1111
|
+
const probe = run("ssh", [
|
|
1112
|
+
...SSH_OPTIONS,
|
|
1113
|
+
target,
|
|
1114
|
+
`tmux list-windows -a -F ${shellQuote("#{window_id}")}`
|
|
1115
|
+
]);
|
|
1035
1116
|
if (probe.status !== 0) {
|
|
1036
|
-
const sshFailed = probe.status === 255 || probe.
|
|
1117
|
+
const sshFailed = probe.status === 255 || probe.failed;
|
|
1037
1118
|
if (sshFailed) {
|
|
1038
1119
|
return {
|
|
1039
1120
|
ok: false,
|
|
1040
1121
|
reason: "unreachable",
|
|
1041
|
-
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.`
|
|
1042
1123
|
};
|
|
1043
1124
|
}
|
|
1044
1125
|
forgetHostReplica(store, agent.host_id);
|
|
@@ -1048,7 +1129,7 @@ function jumpToAgent(store, agent) {
|
|
|
1048
1129
|
message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
|
|
1049
1130
|
};
|
|
1050
1131
|
}
|
|
1051
|
-
const remoteWindows = new Set(
|
|
1132
|
+
const remoteWindows = new Set(probe.stdout.split("\n").filter(Boolean));
|
|
1052
1133
|
if (!remoteWindows.has(agent.window)) {
|
|
1053
1134
|
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
1054
1135
|
return {
|
|
@@ -1061,31 +1142,31 @@ function jumpToAgent(store, agent) {
|
|
|
1061
1142
|
if (process.env.TMUX) {
|
|
1062
1143
|
const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
|
|
1063
1144
|
const name = `@${peer?.name ?? target}`;
|
|
1064
|
-
const existing =
|
|
1145
|
+
const existing = mux.windowNamed(name);
|
|
1065
1146
|
if (existing) {
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
+
};
|
|
1068
1152
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
+
};
|
|
1071
1158
|
}
|
|
1072
|
-
|
|
1073
|
-
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
|
+
};
|
|
1074
1165
|
}
|
|
1075
1166
|
|
|
1076
1167
|
// src/glance.ts
|
|
1077
1168
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
1078
1169
|
var GLANCE_LINES = 40;
|
|
1079
|
-
var SSH_OPTIONS2 = [
|
|
1080
|
-
"-o",
|
|
1081
|
-
"BatchMode=yes",
|
|
1082
|
-
"-o",
|
|
1083
|
-
"ControlMaster=no",
|
|
1084
|
-
"-o",
|
|
1085
|
-
"ControlPath=~/.ssh/control/%r@%h:%p",
|
|
1086
|
-
"-o",
|
|
1087
|
-
"ConnectTimeout=2"
|
|
1088
|
-
];
|
|
1089
1170
|
function glance(store, agent, lines = GLANCE_LINES) {
|
|
1090
1171
|
if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
|
|
1091
1172
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
@@ -1095,7 +1176,7 @@ function glance(store, agent, lines = GLANCE_LINES) {
|
|
|
1095
1176
|
return execFileSync3(
|
|
1096
1177
|
"ssh",
|
|
1097
1178
|
[
|
|
1098
|
-
...
|
|
1179
|
+
...SSH_OPTIONS,
|
|
1099
1180
|
target,
|
|
1100
1181
|
"tmux",
|
|
1101
1182
|
"capture-pane",
|
|
@@ -1185,9 +1266,9 @@ function status(store, now = Date.now()) {
|
|
|
1185
1266
|
}))
|
|
1186
1267
|
};
|
|
1187
1268
|
}
|
|
1188
|
-
async function statusWithCollect(store, now = Date.now()) {
|
|
1269
|
+
async function statusWithCollect(store, now = Date.now(), channel = ssh) {
|
|
1189
1270
|
try {
|
|
1190
|
-
await collect(store,
|
|
1271
|
+
await collect(store, channel, now);
|
|
1191
1272
|
} catch (error) {
|
|
1192
1273
|
process.stderr.write(
|
|
1193
1274
|
`murmur: status: collect: ${error instanceof Error ? error.message : String(error)}
|