groove-dev 0.27.213 → 0.27.215
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/CLAUDE.md +2 -0
- package/node_modules/@groove-dev/cli/bin/groove.js +8 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/handoff.js +22 -0
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/index.js +18 -1
- package/node_modules/@groove-dev/daemon/src/journalist.js +28 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +179 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +25 -0
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +217 -30
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +86 -0
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +143 -2
- package/node_modules/@groove-dev/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/bin/groove.js +8 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/handoff.js +22 -0
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/index.js +18 -1
- package/packages/daemon/src/journalist.js +28 -0
- package/packages/daemon/src/rotator.js +179 -0
- package/packages/daemon/src/routes/agents.js +25 -0
- package/packages/daemon/src/tunnel-manager.js +217 -30
- package/packages/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
- package/packages/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { execFileSync, spawn } from 'child_process';
|
|
5
5
|
import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
|
|
6
6
|
import { resolve } from 'path';
|
|
7
|
-
import { createConnection } from 'net';
|
|
7
|
+
import { createConnection, isIP } from 'net';
|
|
8
|
+
import { lookup } from 'dns/promises';
|
|
8
9
|
import crypto from 'crypto';
|
|
9
10
|
|
|
10
11
|
function getLocalVersion() {
|
|
@@ -15,6 +16,13 @@ function getLocalVersion() {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const REMOTE_PORT = 31415;
|
|
19
|
+
|
|
20
|
+
// Every remote `groove start` must run from the directory of the daemon's
|
|
21
|
+
// EXISTING world (grooveDir is derived from cwd). The remote daemon records
|
|
22
|
+
// that directory in ~/.groove/last-run-dir on each boot; starting anywhere
|
|
23
|
+
// else boots a fresh empty .groove — which reads as "all my teams are gone".
|
|
24
|
+
// Falls back to $HOME (matching old behavior) when no anchor exists yet.
|
|
25
|
+
const ANCHOR_CD = `cd "$(cat "$HOME/.groove/last-run-dir" 2>/dev/null || echo "$HOME")" 2>/dev/null || cd "$HOME"; `;
|
|
18
26
|
const DEFAULT_LOCAL_PORT = 31416;
|
|
19
27
|
const MAX_PORT_ATTEMPTS = 10;
|
|
20
28
|
const HEALTH_INTERVAL = 30000;
|
|
@@ -26,6 +34,10 @@ const CONFIRM_TIMEOUT = 15000;
|
|
|
26
34
|
// At most one automatic rebuild per tunnel per window; beyond that it stays
|
|
27
35
|
// disconnected rather than thrashing against a host that keeps dying.
|
|
28
36
|
const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
|
|
37
|
+
// Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
|
|
38
|
+
// its cooldown is shorter — but still bounded: a daemon that dies right after
|
|
39
|
+
// every start has a real problem more starts won't fix.
|
|
40
|
+
const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
|
|
29
41
|
|
|
30
42
|
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
31
43
|
|
|
@@ -56,10 +68,57 @@ function isPermissionError(output) {
|
|
|
56
68
|
|
|
57
69
|
const PERMISSION_HINT = 'npm global install requires write access. Either install Node via nvm (recommended) or configure passwordless sudo for npm on the remote server.';
|
|
58
70
|
|
|
71
|
+
// A hostname can resolve to several addresses on different interfaces — a
|
|
72
|
+
// dual-homed LAN box (wired + Wi-Fi) advertises all of them over mDNS, and ssh
|
|
73
|
+
// just takes the resolver's first pick. Landing on a weak Wi-Fi address gives
|
|
74
|
+
// a tunnel that dies of keepalive timeout minutes later, and every reconnect
|
|
75
|
+
// re-rolls the dice. Probe all candidates with a TCP handshake to the ssh port
|
|
76
|
+
// and take the fastest responder — on a LAN that reliably picks wired over
|
|
77
|
+
// Wi-Fi. Falls back to the original hostname if resolution or every probe
|
|
78
|
+
// fails, so behavior is unchanged for the cases that already worked.
|
|
79
|
+
export async function resolveBestAddress(host, port = 22, probeTimeoutMs = 2500) {
|
|
80
|
+
if (isIP(host)) return host; // literal IP — nothing to choose
|
|
81
|
+
let addrs;
|
|
82
|
+
try {
|
|
83
|
+
addrs = await lookup(host, { all: true, verbatim: true });
|
|
84
|
+
} catch { return host; }
|
|
85
|
+
if (!Array.isArray(addrs) || addrs.length <= 1) return host;
|
|
86
|
+
|
|
87
|
+
const handshake = (address) => new Promise((res) => {
|
|
88
|
+
const started = Date.now();
|
|
89
|
+
let sock;
|
|
90
|
+
try { sock = createConnection({ host: address, port }); } catch { return res(null); }
|
|
91
|
+
sock.setTimeout(probeTimeoutMs);
|
|
92
|
+
sock.on('connect', () => { sock.destroy(); res(Date.now() - started); });
|
|
93
|
+
sock.on('error', () => res(null));
|
|
94
|
+
sock.on('timeout', () => { sock.destroy(); res(null); });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Median of three handshakes per address: one lucky round-trip can make a
|
|
98
|
+
// weak link look fine, but a flaky link rarely wins three in a row — a
|
|
99
|
+
// single retransmit (or drop, scored as the timeout) sinks its median.
|
|
100
|
+
const probe = async (address) => {
|
|
101
|
+
const times = [];
|
|
102
|
+
for (let i = 0; i < 3; i++) times.push(await handshake(address));
|
|
103
|
+
const scored = times.map((t) => (t === null ? probeTimeoutMs : t)).sort((a, b) => a - b);
|
|
104
|
+
if (times.every((t) => t === null)) return null; // never connected at all
|
|
105
|
+
return { address, ms: scored[1] };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const results = (await Promise.all(addrs.map((a) => probe(a.address)))).filter(Boolean);
|
|
109
|
+
if (results.length === 0) return host;
|
|
110
|
+
results.sort((a, b) => a.ms - b.ms);
|
|
111
|
+
return results[0].address;
|
|
112
|
+
}
|
|
113
|
+
|
|
59
114
|
export class TunnelManager {
|
|
60
115
|
constructor(daemon) {
|
|
61
116
|
this.daemon = daemon;
|
|
62
117
|
this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
|
|
118
|
+
// Live tunnel state, persisted separately from the configs so a daemon
|
|
119
|
+
// restart can re-adopt still-running ssh processes instead of forgetting
|
|
120
|
+
// them (the configs file is user data; this is runtime state).
|
|
121
|
+
this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
|
|
63
122
|
this.saved = new Map();
|
|
64
123
|
this.active = new Map();
|
|
65
124
|
this._healthInterval = null;
|
|
@@ -88,8 +147,9 @@ export class TunnelManager {
|
|
|
88
147
|
}
|
|
89
148
|
|
|
90
149
|
async init() {
|
|
150
|
+
await this._readopt();
|
|
91
151
|
for (const [id, config] of this.saved) {
|
|
92
|
-
if (config.autoConnect) {
|
|
152
|
+
if (config.autoConnect && !this.active.has(id)) {
|
|
93
153
|
try {
|
|
94
154
|
await this.connect(id);
|
|
95
155
|
} catch (err) {
|
|
@@ -99,6 +159,71 @@ export class TunnelManager {
|
|
|
99
159
|
}
|
|
100
160
|
}
|
|
101
161
|
|
|
162
|
+
// Re-adopt tunnels whose detached ssh processes survived a daemon restart.
|
|
163
|
+
// Without this, every daemon/app restart orphaned the ssh (or shutdown killed
|
|
164
|
+
// it) and the new daemon started amnesiac — remote windows died mid-session
|
|
165
|
+
// and the user had to reconnect everything by hand.
|
|
166
|
+
async _readopt() {
|
|
167
|
+
let entries = [];
|
|
168
|
+
try {
|
|
169
|
+
if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
|
|
170
|
+
} catch { /* corrupt — treat as none */ }
|
|
171
|
+
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
172
|
+
|
|
173
|
+
let adopted = 0;
|
|
174
|
+
for (const e of entries) {
|
|
175
|
+
if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
|
|
176
|
+
// Only re-adopt what is provably OUR ssh still doing THIS job: the pid
|
|
177
|
+
// must be alive, be an ssh process forwarding this port, and the port
|
|
178
|
+
// must serve HTTP.
|
|
179
|
+
let alive = false;
|
|
180
|
+
try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
|
|
181
|
+
if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
|
|
182
|
+
if (alive && await this._tunnelResponds(e.localPort)) {
|
|
183
|
+
this.active.set(e.id, {
|
|
184
|
+
pid: e.pid,
|
|
185
|
+
localPort: e.localPort,
|
|
186
|
+
startedAt: e.startedAt || new Date().toISOString(),
|
|
187
|
+
lastPing: Date.now(),
|
|
188
|
+
latencyMs: null,
|
|
189
|
+
healthy: true,
|
|
190
|
+
failCount: 0,
|
|
191
|
+
});
|
|
192
|
+
adopted++;
|
|
193
|
+
const name = this.saved.get(e.id)?.name || e.id;
|
|
194
|
+
console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
|
|
195
|
+
this.daemon.broadcast({ type: 'tunnel.connected', data: { id: e.id, name, localPort: e.localPort, host: this.saved.get(e.id)?.host, url: `http://localhost:${e.localPort}?instance=${encodeURIComponent(name)}` } });
|
|
196
|
+
} else if (alive) {
|
|
197
|
+
// ssh survives but doesn't serve — a corpse from before the restart.
|
|
198
|
+
try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (adopted > 0 && !this._healthInterval) {
|
|
202
|
+
this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
|
|
203
|
+
}
|
|
204
|
+
this._saveActive();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Identity check for re-adoption: is this pid an ssh forwarding this port?
|
|
208
|
+
// Guards against pid recycling handing us an unrelated process.
|
|
209
|
+
_looksLikeOurSsh(pid, localPort) {
|
|
210
|
+
try {
|
|
211
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
212
|
+
encoding: 'utf8', timeout: 3000,
|
|
213
|
+
}).trim();
|
|
214
|
+
return cmd.includes('ssh') && cmd.includes(String(localPort));
|
|
215
|
+
} catch { return false; }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_saveActive() {
|
|
219
|
+
try {
|
|
220
|
+
const entries = [...this.active.entries()].map(([id, c]) => ({
|
|
221
|
+
id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
|
|
222
|
+
}));
|
|
223
|
+
writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
224
|
+
} catch { /* best effort */ }
|
|
225
|
+
}
|
|
226
|
+
|
|
102
227
|
getSaved() {
|
|
103
228
|
return Array.from(this.saved.values()).map(s => ({
|
|
104
229
|
...this._sanitize(s),
|
|
@@ -228,7 +353,7 @@ export class TunnelManager {
|
|
|
228
353
|
const config = this.saved.get(id);
|
|
229
354
|
if (!config) throw new Error(`Remote ${id} not found`);
|
|
230
355
|
|
|
231
|
-
const target = `${config.user}@${config.host}`;
|
|
356
|
+
const target = `${config.user}@${await resolveBestAddress(config.host, config.port || 22)}`;
|
|
232
357
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
233
358
|
|
|
234
359
|
try {
|
|
@@ -344,18 +469,28 @@ export class TunnelManager {
|
|
|
344
469
|
} else {
|
|
345
470
|
localPort = await this._findAvailablePort();
|
|
346
471
|
}
|
|
347
|
-
|
|
472
|
+
// Multi-homed hosts (mDNS names especially): pick the address that actually
|
|
473
|
+
// answers fastest instead of letting the resolver gamble on an interface.
|
|
474
|
+
const connectHost = await resolveBestAddress(config.host, config.port || 22);
|
|
475
|
+
if (connectHost !== config.host) {
|
|
476
|
+
console.log(`[Groove:Tunnel] ${config.name}: ${config.host} → ${connectHost} (fastest responding address)`);
|
|
477
|
+
}
|
|
478
|
+
const target = `${config.user}@${connectHost}`;
|
|
348
479
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
480
|
+
// Keep the host key pinned to the NAME when we connect by address, so every
|
|
481
|
+
// address of the same box shares one known_hosts entry.
|
|
482
|
+
const aliasArgs = connectHost !== config.host ? ['-o', `HostKeyAlias=${config.host}`] : [];
|
|
349
483
|
|
|
350
484
|
const sshArgs = [
|
|
351
485
|
'-N',
|
|
352
486
|
'-L', `127.0.0.1:${localPort}:localhost:${REMOTE_PORT}`,
|
|
353
487
|
'-p', String(config.port || 22),
|
|
354
|
-
'-o', 'ServerAliveInterval=
|
|
355
|
-
'-o', 'ServerAliveCountMax=
|
|
488
|
+
'-o', 'ServerAliveInterval=15',
|
|
489
|
+
'-o', 'ServerAliveCountMax=4',
|
|
356
490
|
'-o', 'ExitOnForwardFailure=yes',
|
|
357
491
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
358
492
|
'-o', 'GSSAPIAuthentication=no',
|
|
493
|
+
...aliasArgs,
|
|
359
494
|
...keyArgs,
|
|
360
495
|
target,
|
|
361
496
|
];
|
|
@@ -406,6 +541,7 @@ export class TunnelManager {
|
|
|
406
541
|
healthy: true,
|
|
407
542
|
failCount: 0,
|
|
408
543
|
});
|
|
544
|
+
this._saveActive();
|
|
409
545
|
|
|
410
546
|
// Verify daemon is reachable through tunnel, start if needed
|
|
411
547
|
let remoteAlive = false;
|
|
@@ -496,6 +632,7 @@ export class TunnelManager {
|
|
|
496
632
|
if (localPort) await this._waitForPortFree(localPort, 3000);
|
|
497
633
|
|
|
498
634
|
this.active.delete(id);
|
|
635
|
+
this._saveActive();
|
|
499
636
|
|
|
500
637
|
const config = this.saved.get(id);
|
|
501
638
|
this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
|
|
@@ -559,7 +696,7 @@ export class TunnelManager {
|
|
|
559
696
|
}
|
|
560
697
|
|
|
561
698
|
// Restart remote daemon — fire and forget the SSH, verify through the tunnel
|
|
562
|
-
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` :
|
|
699
|
+
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : ANCHOR_CD;
|
|
563
700
|
try {
|
|
564
701
|
execFileSync('ssh', [...sshBase, sshCmd(`kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 1; ${cdPrefix}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown`)], {
|
|
565
702
|
encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -666,7 +803,7 @@ export class TunnelManager {
|
|
|
666
803
|
const config = this.saved.get(id);
|
|
667
804
|
if (!config) throw new Error(`Remote ${id} not found`);
|
|
668
805
|
|
|
669
|
-
const target = `${config.user}@${config.host}`;
|
|
806
|
+
const target = `${config.user}@${await resolveBestAddress(config.host, config.port || 22)}`;
|
|
670
807
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
671
808
|
|
|
672
809
|
// Build the remote bash command:
|
|
@@ -676,7 +813,7 @@ export class TunnelManager {
|
|
|
676
813
|
// 4. explicitly POST /api/project-dir so the daemon's projectDir matches
|
|
677
814
|
// config.projectDir even if the backgrounded cwd didn't stick (this
|
|
678
815
|
// also updates the editor root used for /api/browse, /api/files/*)
|
|
679
|
-
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` :
|
|
816
|
+
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : ANCHOR_CD;
|
|
680
817
|
const setProjectDir = config.projectDir
|
|
681
818
|
? `curl -sf -X POST -H 'Content-Type: application/json' --data '{"path":"${config.projectDir}"}' http://localhost:${REMOTE_PORT}/api/project-dir > /dev/null 2>&1 || true; `
|
|
682
819
|
: '';
|
|
@@ -798,7 +935,7 @@ export class TunnelManager {
|
|
|
798
935
|
try {
|
|
799
936
|
const result = execFileSync('ssh', [
|
|
800
937
|
...sshBase,
|
|
801
|
-
remoteCmd(
|
|
938
|
+
remoteCmd(`${ANCHOR_CD}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)`),
|
|
802
939
|
], {
|
|
803
940
|
encoding: 'utf8',
|
|
804
941
|
timeout: 45000,
|
|
@@ -868,7 +1005,7 @@ export class TunnelManager {
|
|
|
868
1005
|
}).trim();
|
|
869
1006
|
const installedVer = verOutput.replace(/[^0-9.]/g, '') || verOutput.trim();
|
|
870
1007
|
|
|
871
|
-
const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
|
|
1008
|
+
const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; ${ANCHOR_CD}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
|
|
872
1009
|
const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
|
|
873
1010
|
encoding: 'utf8',
|
|
874
1011
|
timeout: 60000,
|
|
@@ -972,6 +1109,12 @@ export class TunnelManager {
|
|
|
972
1109
|
conn.failCount = 0;
|
|
973
1110
|
conn.healthy = true;
|
|
974
1111
|
conn._wedgedStreak = 0;
|
|
1112
|
+
} else if (verdict === 'remote-down') {
|
|
1113
|
+
// The tunnel is carrying traffic correctly — the far daemon is what
|
|
1114
|
+
// died (typically mid-upgrade). Rebuild would be useless; start it.
|
|
1115
|
+
conn.healthy = false;
|
|
1116
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
1117
|
+
await this._startRemoteDaemon(id, conn);
|
|
975
1118
|
} else {
|
|
976
1119
|
conn.healthy = false;
|
|
977
1120
|
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
@@ -995,19 +1138,71 @@ export class TunnelManager {
|
|
|
995
1138
|
}
|
|
996
1139
|
|
|
997
1140
|
// Escalating evidence that a tunnel is actually dead, not merely slow:
|
|
998
|
-
// 'alive'
|
|
999
|
-
// 'proc-dead'
|
|
1000
|
-
// 'port-dead'
|
|
1001
|
-
// '
|
|
1141
|
+
// 'alive' — answered a long-timeout HTTP probe; leave it alone
|
|
1142
|
+
// 'proc-dead' — the ssh client process is gone
|
|
1143
|
+
// 'port-dead' — nothing is listening on the local port
|
|
1144
|
+
// 'remote-down' — the tunnel forwards fine but the REMOTE end refuses:
|
|
1145
|
+
// the probe fails fast with a connection error, not a
|
|
1146
|
+
// timeout. Killing the tunnel won't fix that — the remote
|
|
1147
|
+
// daemon needs starting (e.g. it died during an upgrade).
|
|
1148
|
+
// 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
|
|
1002
1149
|
async _confirmDead(conn) {
|
|
1003
|
-
|
|
1150
|
+
const started = Date.now();
|
|
1151
|
+
let probeErr = null;
|
|
1152
|
+
try {
|
|
1153
|
+
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
1154
|
+
signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
|
|
1155
|
+
});
|
|
1156
|
+
if (res.ok) return 'alive';
|
|
1157
|
+
} catch (err) { probeErr = err; }
|
|
1158
|
+
|
|
1004
1159
|
if (conn.pid) {
|
|
1005
1160
|
try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
|
|
1006
1161
|
}
|
|
1007
1162
|
if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
|
|
1163
|
+
|
|
1164
|
+
// ssh is alive and its port listens. A hang (timeout) means the forward is
|
|
1165
|
+
// dead; a FAST connection-level error means ssh relayed the remote side's
|
|
1166
|
+
// refusal — the tunnel works, the far daemon doesn't.
|
|
1167
|
+
const failedFast = Date.now() - started < 2000;
|
|
1168
|
+
const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
|
|
1169
|
+
if (failedFast && !timedOut) return 'remote-down';
|
|
1008
1170
|
return 'wedged';
|
|
1009
1171
|
}
|
|
1010
1172
|
|
|
1173
|
+
// The tunnel is healthy; the daemon on the far side is what's down. Start it
|
|
1174
|
+
// over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
|
|
1175
|
+
// the remote daemon won't stay up, repeated starts won't save it.
|
|
1176
|
+
async _startRemoteDaemon(id, conn) {
|
|
1177
|
+
this._remoteStartAt = this._remoteStartAt || new Map();
|
|
1178
|
+
const last = this._remoteStartAt.get(id) || 0;
|
|
1179
|
+
if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
|
|
1180
|
+
this._remoteStartAt.set(id, Date.now());
|
|
1181
|
+
|
|
1182
|
+
console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
|
|
1183
|
+
this.daemon.audit.log('tunnel.remote-daemon-start', { id });
|
|
1184
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
1185
|
+
try {
|
|
1186
|
+
await this.autoStart(id);
|
|
1187
|
+
// Confirm it came up; a success resets the failure counters immediately
|
|
1188
|
+
// instead of waiting out another health cycle.
|
|
1189
|
+
for (let i = 0; i < 10; i++) {
|
|
1190
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1191
|
+
if (await this._tunnelResponds(conn.localPort)) {
|
|
1192
|
+
conn.failCount = 0;
|
|
1193
|
+
conn.healthy = true;
|
|
1194
|
+
conn._wedgedStreak = 0;
|
|
1195
|
+
console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
|
|
1196
|
+
this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1011
1206
|
// Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
|
|
1012
1207
|
// local port. The remote GUI window points at that port and its WebSocket
|
|
1013
1208
|
// retries every 2s, so a same-port rebuild heals an open window without the
|
|
@@ -1075,25 +1270,17 @@ export class TunnelManager {
|
|
|
1075
1270
|
throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
|
|
1076
1271
|
}
|
|
1077
1272
|
|
|
1273
|
+
// Deliberately does NOT kill the ssh processes. They are spawned detached and
|
|
1274
|
+
// are the user's live sessions: killing them on every daemon restart (app
|
|
1275
|
+
// upgrade, promote, crash) is what nuked remote windows mid-session. State is
|
|
1276
|
+
// persisted; the next daemon re-adopts whatever is still alive and serving.
|
|
1277
|
+
// Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
|
|
1078
1278
|
shutdown() {
|
|
1079
1279
|
if (this._healthInterval) {
|
|
1080
1280
|
clearInterval(this._healthInterval);
|
|
1081
1281
|
this._healthInterval = null;
|
|
1082
1282
|
}
|
|
1083
|
-
|
|
1084
|
-
try {
|
|
1085
|
-
const conn = this.active.get(id);
|
|
1086
|
-
if (conn?.pid) {
|
|
1087
|
-
const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
|
|
1088
|
-
encoding: 'utf8',
|
|
1089
|
-
timeout: 3000,
|
|
1090
|
-
}).trim();
|
|
1091
|
-
if (cmd.includes('ssh')) {
|
|
1092
|
-
process.kill(conn.pid, 'SIGTERM');
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
} catch { /* ignore */ }
|
|
1096
|
-
}
|
|
1283
|
+
this._saveActive();
|
|
1097
1284
|
this.active.clear();
|
|
1098
1285
|
}
|
|
1099
1286
|
}
|
|
@@ -601,4 +601,90 @@ describe('Rotator', () => {
|
|
|
601
601
|
|
|
602
602
|
assert.equal(rotator.getHistory().length, 0);
|
|
603
603
|
});
|
|
604
|
+
|
|
605
|
+
// ── Succession handoff ─────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
function seedForHandoff() {
|
|
608
|
+
const agent = {
|
|
609
|
+
id: 'old1', name: 'veteran', role: 'fullstack',
|
|
610
|
+
provider: 'claude-code', scope: null, model: null,
|
|
611
|
+
tokensUsed: 900_000, contextUsage: 0.7,
|
|
612
|
+
workingDir: '/tmp', teamId: 't1', prompt: 'Long-running work',
|
|
613
|
+
};
|
|
614
|
+
mockDaemon.registry.agents = [agent];
|
|
615
|
+
mockDaemon.journalist.generateSuccessionDossier = async () =>
|
|
616
|
+
'## Dossier\nEverything the veteran knows, in depth.';
|
|
617
|
+
let spawnCount = 0;
|
|
618
|
+
mockDaemon.processes.spawn = async (config) => {
|
|
619
|
+
spawnCount++;
|
|
620
|
+
const spawned = { id: 'succ' + spawnCount, ...config };
|
|
621
|
+
mockDaemon.registry.agents.push(spawned);
|
|
622
|
+
return spawned;
|
|
623
|
+
};
|
|
624
|
+
return agent;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
it('succession spawns the successor ALONGSIDE the predecessor', async () => {
|
|
628
|
+
seedForHandoff();
|
|
629
|
+
const record = await rotator.successionHandoff('old1');
|
|
630
|
+
|
|
631
|
+
assert.equal(record.status, 'interviewing');
|
|
632
|
+
const ids = mockDaemon.registry.agents.map((a) => a.id);
|
|
633
|
+
assert.ok(ids.includes('old1'), 'predecessor still alive during the interview');
|
|
634
|
+
assert.ok(ids.includes(record.successorId), 'successor exists');
|
|
635
|
+
|
|
636
|
+
const successor = mockDaemon.registry.agents.find((a) => a.id === record.successorId);
|
|
637
|
+
assert.equal(successor.name, 'veteran-successor');
|
|
638
|
+
assert.ok(successor.prompt.includes('Dossier'), 'successor got the dossier');
|
|
639
|
+
assert.ok(successor.prompt.includes('innerchat/ask'), 'successor told to interview');
|
|
640
|
+
assert.ok(successor.prompt.includes(`/api/handoff/${record.id}/complete`), 'successor told how to declare takeover');
|
|
641
|
+
assert.equal(successor.metadata.handoff.predecessorId, 'old1');
|
|
642
|
+
assert.ok(broadcasts.some((b) => b.type === 'handoff:started'));
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('complete retires the predecessor and hands over the name', async () => {
|
|
646
|
+
seedForHandoff();
|
|
647
|
+
const record = await rotator.successionHandoff('old1');
|
|
648
|
+
const done = await rotator.completeHandoff(record.id);
|
|
649
|
+
|
|
650
|
+
assert.equal(done.status, 'complete');
|
|
651
|
+
const ids = mockDaemon.registry.agents.map((a) => a.id);
|
|
652
|
+
assert.ok(!ids.includes('old1'), 'predecessor retired');
|
|
653
|
+
const successor = mockDaemon.registry.agents.find((a) => a.id === record.successorId);
|
|
654
|
+
assert.equal(successor.name, 'veteran', 'successor inherited the name');
|
|
655
|
+
assert.equal(successor.metadata.handoff, undefined, 'handoff marker cleared');
|
|
656
|
+
assert.ok(broadcasts.some((b) => b.type === 'handoff:completed'));
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('inheritName:false keeps the successor name', async () => {
|
|
660
|
+
seedForHandoff();
|
|
661
|
+
const record = await rotator.successionHandoff('old1', { name: 'fresh-eyes', inheritName: false });
|
|
662
|
+
await rotator.completeHandoff(record.id);
|
|
663
|
+
const successor = mockDaemon.registry.agents.find((a) => a.id === record.successorId);
|
|
664
|
+
assert.equal(successor.name, 'fresh-eyes');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('complete survives a daemon restart (reconstructs from successor metadata)', async () => {
|
|
668
|
+
seedForHandoff();
|
|
669
|
+
const record = await rotator.successionHandoff('old1');
|
|
670
|
+
rotator.handoffs.clear(); // simulate restart wiping in-memory state
|
|
671
|
+
|
|
672
|
+
const done = await rotator.completeHandoff(record.id);
|
|
673
|
+
assert.equal(done.status, 'complete');
|
|
674
|
+
assert.ok(!mockDaemon.registry.agents.some((a) => a.id === 'old1'), 'predecessor still retired');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('blocks a second handoff while one is interviewing', async () => {
|
|
678
|
+
seedForHandoff();
|
|
679
|
+
await rotator.successionHandoff('old1');
|
|
680
|
+
await assert.rejects(() => rotator.successionHandoff('old1'), /already in progress/);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('completing twice is idempotent', async () => {
|
|
684
|
+
seedForHandoff();
|
|
685
|
+
const record = await rotator.successionHandoff('old1');
|
|
686
|
+
await rotator.completeHandoff(record.id);
|
|
687
|
+
const again = await rotator.completeHandoff(record.id);
|
|
688
|
+
assert.equal(again.status, 'complete');
|
|
689
|
+
});
|
|
604
690
|
});
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
12
12
|
import assert from 'node:assert/strict';
|
|
13
|
-
import { mkdtempSync, rmSync } from 'fs';
|
|
13
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs';
|
|
14
14
|
import { tmpdir } from 'os';
|
|
15
15
|
import { resolve } from 'path';
|
|
16
16
|
import { createServer } from 'net';
|
|
17
|
-
import { TunnelManager } from '../src/tunnel-manager.js';
|
|
17
|
+
import { TunnelManager, resolveBestAddress } from '../src/tunnel-manager.js';
|
|
18
18
|
|
|
19
19
|
function makeDaemon(grooveDir) {
|
|
20
20
|
const broadcasts = [];
|
|
@@ -240,6 +240,104 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
|
240
240
|
} finally { live.close(); }
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
it('remote-down (fast refusal through a live tunnel) starts the daemon, not a rebuild', async () => {
|
|
244
|
+
// ssh alive + port listens + connections REFUSED at the far end: ssh relays
|
|
245
|
+
// the refusal as an immediate close, so the probe fails fast rather than
|
|
246
|
+
// hanging. The tunnel is fine; only the remote daemon needs starting.
|
|
247
|
+
const refusing = createServer((sock) => sock.destroy());
|
|
248
|
+
await new Promise((r) => refusing.listen(0, '127.0.0.1', r));
|
|
249
|
+
const port = refusing.address().port;
|
|
250
|
+
try {
|
|
251
|
+
mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'axom', port: 22 });
|
|
252
|
+
mgr.active.set('dgx', {
|
|
253
|
+
pid: process.pid, localPort: port, healthy: true, failCount: 99,
|
|
254
|
+
startedAt: new Date().toISOString(),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const started = [];
|
|
258
|
+
mgr.autoStart = async (id) => { started.push(id); };
|
|
259
|
+
mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
|
|
260
|
+
|
|
261
|
+
await mgr._healthCheckAll();
|
|
262
|
+
|
|
263
|
+
assert.deepEqual(started, ['dgx'], 'the remote daemon was started over ssh');
|
|
264
|
+
assert.equal(mgr.active.has('dgx'), true, 'the healthy tunnel was NOT torn down');
|
|
265
|
+
} finally { refusing.close(); }
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('re-adopts a surviving tunnel after a daemon restart instead of forgetting it', async () => {
|
|
269
|
+
const live = await startHealthyListener();
|
|
270
|
+
try {
|
|
271
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
|
|
272
|
+
mgr._save(); // the new daemon loads configs from disk
|
|
273
|
+
mgr.active.set('s19', {
|
|
274
|
+
pid: process.pid, localPort: live.port,
|
|
275
|
+
startedAt: new Date().toISOString(), healthy: true, failCount: 0,
|
|
276
|
+
});
|
|
277
|
+
mgr._saveActive();
|
|
278
|
+
mgr.shutdown(); // daemon going down — must NOT kill the tunnel
|
|
279
|
+
|
|
280
|
+
// "New daemon" after restart. The stand-in pid is node, not ssh, so the
|
|
281
|
+
// identity gate is stubbed — everything else runs for real.
|
|
282
|
+
const daemon2 = makeDaemon(grooveDir);
|
|
283
|
+
const mgr2 = new TunnelManager(daemon2);
|
|
284
|
+
mgr2.healthTimeout = 400;
|
|
285
|
+
mgr2._looksLikeOurSsh = (pid, port) => pid === process.pid && port === live.port;
|
|
286
|
+
|
|
287
|
+
await mgr2._readopt();
|
|
288
|
+
|
|
289
|
+
assert.equal(mgr2.active.has('s19'), true, 'the surviving tunnel was re-adopted');
|
|
290
|
+
assert.equal(mgr2.active.get('s19').localPort, live.port, 'on its original port');
|
|
291
|
+
assert.ok(
|
|
292
|
+
daemon2.broadcasts.some((b) => b.type === 'tunnel.connected'),
|
|
293
|
+
'the GUI is told the tunnel is (still) connected',
|
|
294
|
+
);
|
|
295
|
+
mgr2.shutdown();
|
|
296
|
+
} finally { live.close(); }
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('does not re-adopt a dead or hijacked pid', async () => {
|
|
300
|
+
const live = await startHealthyListener();
|
|
301
|
+
try {
|
|
302
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
|
|
303
|
+
mgr._save();
|
|
304
|
+
mgr.active.set('s19', {
|
|
305
|
+
pid: 999999, localPort: live.port, // no such process
|
|
306
|
+
startedAt: new Date().toISOString(), healthy: true, failCount: 0,
|
|
307
|
+
});
|
|
308
|
+
mgr._saveActive();
|
|
309
|
+
mgr.shutdown();
|
|
310
|
+
|
|
311
|
+
const daemon2 = makeDaemon(grooveDir);
|
|
312
|
+
const mgr2 = new TunnelManager(daemon2);
|
|
313
|
+
mgr2.healthTimeout = 400;
|
|
314
|
+
await mgr2._readopt();
|
|
315
|
+
|
|
316
|
+
assert.equal(mgr2.active.has('s19'), false, 'a dead pid is not adopted');
|
|
317
|
+
mgr2.shutdown();
|
|
318
|
+
} finally { live.close(); }
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('shutdown persists state and does not kill the tunnel process', async () => {
|
|
322
|
+
const live = await startHealthyListener();
|
|
323
|
+
try {
|
|
324
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19', host: 'x', user: 'u', port: 22 });
|
|
325
|
+
mgr.active.set('s19', {
|
|
326
|
+
pid: process.pid, localPort: live.port,
|
|
327
|
+
startedAt: new Date().toISOString(), healthy: true, failCount: 0,
|
|
328
|
+
});
|
|
329
|
+
mgr.shutdown();
|
|
330
|
+
|
|
331
|
+
// Our stand-in "tunnel process" (this test runner) must still be alive —
|
|
332
|
+
// shutdown killing it would have killed the test.
|
|
333
|
+
assert.doesNotThrow(() => process.kill(process.pid, 0));
|
|
334
|
+
const persisted = JSON.parse(readFileSync(resolve(grooveDir, 'tunnels-active.json'), 'utf8'));
|
|
335
|
+
assert.equal(persisted.length, 1);
|
|
336
|
+
assert.equal(persisted[0].id, 's19');
|
|
337
|
+
assert.equal(persisted[0].localPort, live.port);
|
|
338
|
+
} finally { live.close(); }
|
|
339
|
+
});
|
|
340
|
+
|
|
243
341
|
it('_waitForPortFree reports a released port', async () => {
|
|
244
342
|
const live = await startHealthyListener();
|
|
245
343
|
assert.equal(await mgr._waitForPortFree(live.port, 600), false, 'still held while listening');
|
|
@@ -251,3 +349,46 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
|
251
349
|
assert.equal(await mgr._waitForExit(999999, 500), true);
|
|
252
350
|
});
|
|
253
351
|
});
|
|
352
|
+
|
|
353
|
+
describe('resolveBestAddress — multi-homed host selection', () => {
|
|
354
|
+
// The failure this guards: an mDNS name advertising both a wired and a weak
|
|
355
|
+
// Wi-Fi interface. ssh took whichever address the resolver listed first, so
|
|
356
|
+
// tunnels randomly landed on the bad link and died of keepalive timeout.
|
|
357
|
+
|
|
358
|
+
it('passes literal IPs through untouched', async () => {
|
|
359
|
+
assert.equal(await resolveBestAddress('10.0.0.205', 22), '10.0.0.205');
|
|
360
|
+
assert.equal(await resolveBestAddress('::1', 22), '::1');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('falls back to the name when it does not resolve', async () => {
|
|
364
|
+
assert.equal(
|
|
365
|
+
await resolveBestAddress('no-such-host-zzz.invalid', 22, 500),
|
|
366
|
+
'no-such-host-zzz.invalid',
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('picks the address that actually answers when others are dead', async () => {
|
|
371
|
+
// 'localhost' resolves to both ::1 and 127.0.0.1 on typical systems. Bind
|
|
372
|
+
// only the IPv4 side: the resolver must pick 127.0.0.1, not the dead ::1.
|
|
373
|
+
const srv = createServer(() => {});
|
|
374
|
+
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
|
375
|
+
const port = srv.address().port;
|
|
376
|
+
try {
|
|
377
|
+
const best = await resolveBestAddress('localhost', port, 800);
|
|
378
|
+
// On single-address systems resolution is a passthrough — accept that too.
|
|
379
|
+
assert.ok(
|
|
380
|
+
best === '127.0.0.1' || best === 'localhost',
|
|
381
|
+
`picked ${best} — expected the listening 127.0.0.1 (or passthrough)`,
|
|
382
|
+
);
|
|
383
|
+
} finally { srv.close(); }
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('falls back to the name when no candidate answers', async () => {
|
|
387
|
+
// Nobody listens on this port on any of localhost's addresses.
|
|
388
|
+
const srv = createServer(() => {});
|
|
389
|
+
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
|
390
|
+
const deadPort = srv.address().port;
|
|
391
|
+
await new Promise((r) => srv.close(r)); // free it — now guaranteed dead
|
|
392
|
+
assert.equal(await resolveBestAddress('localhost', deadPort, 500), 'localhost');
|
|
393
|
+
});
|
|
394
|
+
});
|