groove-dev 0.27.210 → 0.27.212
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/node_modules/@groove-dev/cli/bin/groove.js +20 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/client.js +9 -2
- package/node_modules/@groove-dev/cli/src/commands/ask.js +99 -0
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +20 -6
- package/node_modules/@groove-dev/daemon/src/chatstore.js +57 -13
- package/node_modules/@groove-dev/daemon/src/deliver.js +101 -1
- package/node_modules/@groove-dev/daemon/src/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +25 -7
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +89 -3
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +41 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +67 -2
- package/node_modules/@groove-dev/daemon/test/reach-hint.test.js +100 -0
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +183 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-YHDeARYl.js → index-BP4oE2UL.js} +228 -228
- package/node_modules/@groove-dev/gui/dist/assets/index-DPsim83z.css +1 -0
- 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 +20 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/client.js +9 -2
- package/packages/cli/src/commands/ask.js +99 -0
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/axom-connector.js +20 -6
- package/packages/daemon/src/chatstore.js +57 -13
- package/packages/daemon/src/deliver.js +101 -1
- package/packages/daemon/src/index.js +2 -0
- package/packages/daemon/src/innerchat-docs.js +25 -7
- package/packages/daemon/src/tunnel-manager.js +89 -3
- package/packages/gui/dist/assets/{index-YHDeARYl.js → index-BP4oE2UL.js} +228 -228
- package/packages/gui/dist/assets/index-DPsim83z.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-DdCadtGL.css +0 -1
- package/packages/gui/dist/assets/index-DdCadtGL.css +0 -1
|
@@ -463,6 +463,47 @@ describe('AxomConnector', () => {
|
|
|
463
463
|
assert.ok(bridge.epochsSeen.includes('epoch-A')); // we did present the old epoch
|
|
464
464
|
});
|
|
465
465
|
|
|
466
|
+
// Streaming answers: deltas are cosmetic chunks superseded by the terminal
|
|
467
|
+
// `resolution`. They must reach the GUI live but never enter the ring — the
|
|
468
|
+
// runtime omits them from its own replay, so a backfilled tab and a
|
|
469
|
+
// reconnecting one must see identical history.
|
|
470
|
+
it('delivers resolution deltas live but keeps them out of the replay ring', async () => {
|
|
471
|
+
connect();
|
|
472
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
473
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
474
|
+
|
|
475
|
+
bridge.emit('s-test0001', envelope(1, 'pipeline_start'));
|
|
476
|
+
bridge.emit('s-test0001', envelope(2, 'resolution_delta', { firing_id: 'f1', content: 'Hello ', index: 0 }));
|
|
477
|
+
bridge.emit('s-test0001', envelope(3, 'resolution_delta', { firing_id: 'f1', content: 'world', index: 1 }));
|
|
478
|
+
bridge.emit('s-test0001', envelope(4, 'resolution', { content: 'Hello world.' }));
|
|
479
|
+
|
|
480
|
+
// Every delta still BROADCASTS — delivery is untouched.
|
|
481
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 4);
|
|
482
|
+
const kinds = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.kind);
|
|
483
|
+
assert.deepEqual(kinds, ['pipeline_start', 'resolution_delta', 'resolution_delta', 'resolution']);
|
|
484
|
+
|
|
485
|
+
// ...but the ring holds only the durable history.
|
|
486
|
+
const backfill = connector.events('local', 's-test0001');
|
|
487
|
+
assert.deepEqual(backfill.events.map((e) => e.kind), ['pipeline_start', 'resolution']);
|
|
488
|
+
// Excluded, not overflowed — dropping them is by design, not pressure.
|
|
489
|
+
assert.equal(connector.status().endpoints[0].sessions[0].overflow, 0);
|
|
490
|
+
// A known kind now, so it must not read as schema drift.
|
|
491
|
+
assert.equal(connector.status().endpoints[0].sessions[0].unknownKinds.resolution_delta, undefined);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('a delta never breaks dedup for the events that follow it', async () => {
|
|
495
|
+
connect();
|
|
496
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
497
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
498
|
+
bridge.emit('s-test0001', envelope(1, 'resolution_delta', { firing_id: 'f1', content: 'x', index: 0 }));
|
|
499
|
+
bridge.emit('s-test0001', envelope(2, 'resolution', { content: 'x!' }));
|
|
500
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 2);
|
|
501
|
+
// Replaying an id at or below the delta's must still be suppressed.
|
|
502
|
+
bridge.emit('s-test0001', envelope(1, 'resolution_delta', { firing_id: 'f1', content: 'x', index: 0 }));
|
|
503
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
504
|
+
assert.equal(daemon.broadcasts.filter((b) => b.type === 'axom:event').length, 2);
|
|
505
|
+
});
|
|
506
|
+
|
|
466
507
|
it('recheck collapses a stale connected state the moment the runtime is gone', async () => {
|
|
467
508
|
connect();
|
|
468
509
|
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
@@ -81,13 +81,16 @@ describe('ChatStore', () => {
|
|
|
81
81
|
assert.equal(store.getAll()['ghost-id'][0].text, 'unresolvable');
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
it('view() keys live agents by CURRENT id and
|
|
84
|
+
it('view() keys live agents by CURRENT id and omits departed ones', () => {
|
|
85
85
|
store.append('a1', { from: 'user', text: 'live', timestamp: 1 });
|
|
86
86
|
store.history['departed-agent'] = [{ from: 'user', text: 'old', timestamp: 2 }];
|
|
87
87
|
const v = store.view();
|
|
88
88
|
assert.equal(v.a1[0].text, 'live'); // fullstack-1 → its live id
|
|
89
89
|
assert.equal(v['fullstack-1'], undefined);
|
|
90
|
-
|
|
90
|
+
// Departed agents stay on disk but are not shipped — the payload used to
|
|
91
|
+
// be 97% dead agents, which blew the browser's localStorage quota.
|
|
92
|
+
assert.equal(v['departed-agent'], undefined);
|
|
93
|
+
assert.equal(store.getAll()['departed-agent'][0].text, 'old');
|
|
91
94
|
});
|
|
92
95
|
|
|
93
96
|
it('merge() is a union — a sparse client can never truncate server history', () => {
|
|
@@ -176,3 +179,65 @@ describe('mergeMessages', () => {
|
|
|
176
179
|
assert.equal(merged.length, 2);
|
|
177
180
|
});
|
|
178
181
|
});
|
|
182
|
+
|
|
183
|
+
describe('ChatStore — agent-reply persistence regression', () => {
|
|
184
|
+
let dir, daemon, store;
|
|
185
|
+
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
dir = mkdtempSync(resolve(tmpdir(), 'groove-chat-reg-'));
|
|
188
|
+
daemon = makeDaemon([{ id: 'live1', name: 'fullstack-1' }]);
|
|
189
|
+
daemon.grooveDir = dir;
|
|
190
|
+
store = new ChatStore(daemon);
|
|
191
|
+
});
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
store.stop();
|
|
194
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('upserts a coalescing streamed reply instead of accumulating fragments', () => {
|
|
198
|
+
// The GUI grows one message as chunks stream in, keeping its id.
|
|
199
|
+
store.merge('live1', [{ id: 'm1', from: 'agent', text: 'Part one', timestamp: 10 }]);
|
|
200
|
+
store.merge('live1', [{ id: 'm1', from: 'agent', text: 'Part one\n\nPart two', timestamp: 11 }]);
|
|
201
|
+
store.merge('live1', [{ id: 'm1', from: 'agent', text: 'Part one\n\nPart two\n\nPart three', timestamp: 12 }]);
|
|
202
|
+
|
|
203
|
+
const h = store.get('live1');
|
|
204
|
+
assert.equal(h.length, 1, 'one message, not one per chunk');
|
|
205
|
+
assert.equal(h[0].text, 'Part one\n\nPart two\n\nPart three');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('never regresses a message to an earlier, shorter state', () => {
|
|
209
|
+
store.merge('live1', [{ id: 'm1', from: 'agent', text: 'full long reply', timestamp: 20 }]);
|
|
210
|
+
store.merge('live1', [{ id: 'm1', from: 'agent', text: 'full', timestamp: 19 }]); // stale client
|
|
211
|
+
assert.equal(store.get('live1')[0].text, 'full long reply');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('view() ships only live agents — dead buckets stay on disk but off the wire', () => {
|
|
215
|
+
store.append('live1', { id: 'a', from: 'agent', text: 'hi', timestamp: 1 });
|
|
216
|
+
store.history['long-gone-agent'] = [{ id: 'b', from: 'agent', text: 'old', timestamp: 2 }];
|
|
217
|
+
|
|
218
|
+
const v = store.view();
|
|
219
|
+
assert.deepEqual(Object.keys(v), ['live1'], 'payload carries live agents only');
|
|
220
|
+
assert.ok(store.getAll()['long-gone-agent'], 'but the data is retained on disk');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('a respawned agent with the same name picks its history back up', () => {
|
|
224
|
+
store.append('live1', { id: 'a', from: 'agent', text: 'earlier work', timestamp: 1 });
|
|
225
|
+
// Agent dies and is later recreated with a different id, same name.
|
|
226
|
+
daemon.registry.delete('live1');
|
|
227
|
+
daemon.registry.set({ id: 'live1-new', name: 'fullstack-1' });
|
|
228
|
+
assert.equal(store.view()['live1-new'][0].text, 'earlier work');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('prune() bounds dead buckets and never touches live agents', () => {
|
|
232
|
+
store.append('live1', { id: 'a', from: 'agent', text: 'keep me', timestamp: 999 });
|
|
233
|
+
for (let i = 0; i < 10; i++) {
|
|
234
|
+
store.history[`dead-${i}`] = [{ id: `d${i}`, from: 'agent', text: 'x', timestamp: i }];
|
|
235
|
+
}
|
|
236
|
+
const removed = store.prune(4);
|
|
237
|
+
assert.equal(removed, 6);
|
|
238
|
+
assert.equal(store.get('live1')[0].text, 'keep me');
|
|
239
|
+
assert.equal(Object.keys(store.getAll()).filter((k) => k.startsWith('dead-')).length, 4);
|
|
240
|
+
// The survivors are the most recently active dead buckets.
|
|
241
|
+
assert.ok(store.getAll()['dead-9'] && !store.getAll()['dead-0']);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// GROOVE — Agent reach hint (InnerChat discoverability)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Spawn-prompt capabilities decay out of a long session, after which agents
|
|
5
|
+
// deny they can contact anyone. These pin the per-turn hint that replaces it.
|
|
6
|
+
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { agentReachHint } from '../src/deliver.js';
|
|
10
|
+
|
|
11
|
+
function daemonWith(agents, peers = []) {
|
|
12
|
+
return {
|
|
13
|
+
registry: { getAll: () => agents },
|
|
14
|
+
config: { innerchatPeers: peers },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const me = { name: 'fullstack-3', role: 'fullstack' };
|
|
19
|
+
const roster = [me, { name: 'Integration-Manager', role: 'fullstack' }, { name: 'Axom-UX', role: 'frontend' }];
|
|
20
|
+
|
|
21
|
+
describe('agentReachHint', () => {
|
|
22
|
+
it('always names the CLI verbs, even on an unrelated turn', () => {
|
|
23
|
+
const hint = agentReachHint(daemonWith(roster), me, 'refactor the token parser');
|
|
24
|
+
assert.match(hint, /groove ask/);
|
|
25
|
+
assert.match(hint, /groove who/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rules out the built-in sub-agent tools that cannot reach GROOVE agents', () => {
|
|
29
|
+
const hint = agentReachHint(daemonWith(roster), me, 'anything');
|
|
30
|
+
assert.match(hint, /CANNOT reach them/i);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('expands to a ready-to-run command naming the agent the user meant', () => {
|
|
34
|
+
const hint = agentReachHint(daemonWith(roster), me, 'ask Integration-Manager about the relay shape');
|
|
35
|
+
assert.match(hint, /groove ask Integration-Manager "your question here"/);
|
|
36
|
+
assert.doesNotMatch(hint, /<name>/, 'should not leave a placeholder to fill in');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to the roster when intent is clear but the target is not', () => {
|
|
40
|
+
const hint = agentReachHint(daemonWith(roster), me, 'coordinate with the other agents on this');
|
|
41
|
+
assert.match(hint, /groove who/);
|
|
42
|
+
assert.match(hint, /Integration-Manager, Axom-UX/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not resolve a target from an ambiguous partial mention', () => {
|
|
46
|
+
const two = [me, { name: 'fullstack-1', role: 'x' }, { name: 'fullstack-2', role: 'x' }];
|
|
47
|
+
const hint = agentReachHint(daemonWith(two), me, 'ask fullstack about it');
|
|
48
|
+
assert.doesNotMatch(hint, /groove ask fullstack-1 "/);
|
|
49
|
+
assert.match(hint, /groove who/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('never suggests messaging yourself', () => {
|
|
53
|
+
const hint = agentReachHint(daemonWith(roster), me, 'ask fullstack-3 what it thinks');
|
|
54
|
+
assert.doesNotMatch(hint, /groove ask fullstack-3 "/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('mentions peer machines and the name@peer form when peers exist', () => {
|
|
58
|
+
const hint = agentReachHint(daemonWith(roster, [{ alias: 'spark' }]), me, 'ask someone about it');
|
|
59
|
+
assert.match(hint, /name@peer/);
|
|
60
|
+
assert.match(hint, /spark/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('stays silent when there is genuinely nobody to talk to', () => {
|
|
64
|
+
assert.equal(agentReachHint(daemonWith([me]), me, 'ask someone'), null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Naming the feature is an explicit request — the tier that must never rely
|
|
68
|
+
// on heuristics, because it is typically typed AFTER the agent has already
|
|
69
|
+
// claimed the capability does not exist.
|
|
70
|
+
it('re-sends the full reference when the user names InnerChat', () => {
|
|
71
|
+
for (const phrase of ['use innerChat', 'inner chat', 'inner-chat', 'InnerChat please', 'run groove ask']) {
|
|
72
|
+
const hint = agentReachHint({ port: 31415, ...daemonWith(roster) }, me, `${phrase} with the team`);
|
|
73
|
+
assert.match(hint, /Full reference/, `"${phrase}" should trigger the full block`);
|
|
74
|
+
assert.match(hint, /groove ask/);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('tells the agent the feature is real when explicitly asked for it', () => {
|
|
79
|
+
const hint = agentReachHint({ port: 31415, ...daemonWith(roster) }, me, 'use innerchat');
|
|
80
|
+
assert.match(hint, /It exists, it is wired up/);
|
|
81
|
+
assert.match(hint, /Do not tell the user the feature is/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('still resolves the named target alongside the full reference', () => {
|
|
85
|
+
const hint = agentReachHint({ port: 31415, ...daemonWith(roster) }, me, 'use innerchat with Axom-UX');
|
|
86
|
+
assert.match(hint, /groove ask Axom-UX "your question here"/);
|
|
87
|
+
assert.match(hint, /Full reference/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not pay for the full reference on an ordinary turn', () => {
|
|
91
|
+
const hint = agentReachHint(daemonWith(roster), me, 'refactor the parser');
|
|
92
|
+
assert.doesNotMatch(hint, /Full reference/);
|
|
93
|
+
assert.ok(hint.length < 400, 'the always-on tier must stay cheap');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('survives a broken registry rather than blocking delivery', () => {
|
|
97
|
+
const broken = { registry: { getAll() { throw new Error('boom'); } } };
|
|
98
|
+
assert.equal(agentReachHint(broken, me, 'ask someone'), null);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// GROOVE — TunnelManager Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Regression coverage for wake-from-sleep recovery. The failure these guard
|
|
5
|
+
// against: an SSH tunnel cut by a laptop sleep leaves a local listener that
|
|
6
|
+
// still ACCEPTS TCP connections but never forwards them. A TCP-connect probe
|
|
7
|
+
// calls that healthy, so the dead tunnel was handed out forever and the remote
|
|
8
|
+
// GUI loaded a port that hung instead of failing — a black window that only a
|
|
9
|
+
// full app restart cleared.
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
14
|
+
import { tmpdir } from 'os';
|
|
15
|
+
import { resolve } from 'path';
|
|
16
|
+
import { createServer } from 'net';
|
|
17
|
+
import { TunnelManager } from '../src/tunnel-manager.js';
|
|
18
|
+
|
|
19
|
+
function makeDaemon(grooveDir) {
|
|
20
|
+
const broadcasts = [];
|
|
21
|
+
return {
|
|
22
|
+
broadcasts,
|
|
23
|
+
grooveDir,
|
|
24
|
+
projectDir: process.cwd(),
|
|
25
|
+
audit: { log() {} },
|
|
26
|
+
broadcast(m) { broadcasts.push(m); },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A tunnel wedged by sleep: the socket is accepted and then ignored forever.
|
|
31
|
+
function startWedgedListener() {
|
|
32
|
+
const sockets = [];
|
|
33
|
+
const server = createServer((sock) => { sockets.push(sock); });
|
|
34
|
+
return new Promise((res) => {
|
|
35
|
+
server.listen(0, '127.0.0.1', () => {
|
|
36
|
+
res({
|
|
37
|
+
port: server.address().port,
|
|
38
|
+
close: () => { for (const s of sockets) s.destroy(); server.close(); },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A tunnel that works — answers /api/health like the remote daemon would.
|
|
45
|
+
function startHealthyListener() {
|
|
46
|
+
const server = createServer((sock) => {
|
|
47
|
+
sock.on('data', () => {
|
|
48
|
+
const body = '{"ok":true}';
|
|
49
|
+
sock.end(
|
|
50
|
+
'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
|
|
51
|
+
+ `Content-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return new Promise((res) => {
|
|
56
|
+
server.listen(0, '127.0.0.1', () => {
|
|
57
|
+
res({ port: server.address().port, close: () => server.close() });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
63
|
+
let daemon, mgr, grooveDir;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
grooveDir = mkdtempSync(resolve(tmpdir(), 'groove-tunnel-'));
|
|
67
|
+
daemon = makeDaemon(grooveDir);
|
|
68
|
+
mgr = new TunnelManager(daemon);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
mgr.shutdown();
|
|
73
|
+
try { rmSync(grooveDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('a TCP-accept probe cannot tell a wedged tunnel from a live one', async () => {
|
|
77
|
+
const wedged = await startWedgedListener();
|
|
78
|
+
try {
|
|
79
|
+
// This is what the old code trusted — and why the bug survived.
|
|
80
|
+
assert.equal(await mgr._isPortInUse(wedged.port), true);
|
|
81
|
+
} finally { wedged.close(); }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('_tunnelResponds rejects a tunnel that accepts but never answers', async () => {
|
|
85
|
+
const wedged = await startWedgedListener();
|
|
86
|
+
try {
|
|
87
|
+
assert.equal(await mgr._tunnelResponds(wedged.port, 1500), false);
|
|
88
|
+
} finally { wedged.close(); }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('_tunnelResponds accepts a tunnel that actually serves', async () => {
|
|
92
|
+
const live = await startHealthyListener();
|
|
93
|
+
try {
|
|
94
|
+
assert.equal(await mgr._tunnelResponds(live.port, 3000), true);
|
|
95
|
+
} finally { live.close(); }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('connect() rebuilds instead of handing back a wedged tunnel', async () => {
|
|
99
|
+
const wedged = await startWedgedListener();
|
|
100
|
+
try {
|
|
101
|
+
mgr.saved.set('s19', {
|
|
102
|
+
id: 's19', name: 'S19 Agency', host: 'example.invalid',
|
|
103
|
+
user: 'ops', port: 22, lastConnected: new Date().toISOString(),
|
|
104
|
+
});
|
|
105
|
+
mgr.active.set('s19', {
|
|
106
|
+
pid: 999999, localPort: wedged.port, healthy: true, failCount: 0,
|
|
107
|
+
startedAt: new Date().toISOString(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Host is unreachable, so the rebuild fails — the point is that it TRIED
|
|
111
|
+
// rather than returning the dead port as if it were usable.
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
() => mgr.connect('s19', { skipTest: false }),
|
|
114
|
+
(err) => !/^$/.test(err.message),
|
|
115
|
+
);
|
|
116
|
+
assert.equal(mgr.active.has('s19'), false, 'the dead tunnel was torn down');
|
|
117
|
+
} finally { wedged.close(); }
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('connect() reuses a tunnel that is genuinely alive', async () => {
|
|
121
|
+
const live = await startHealthyListener();
|
|
122
|
+
try {
|
|
123
|
+
mgr.saved.set('spark', { id: 'spark', name: 'DGX Spark', host: '10.0.0.5', user: 'rok', port: 22 });
|
|
124
|
+
mgr.active.set('spark', {
|
|
125
|
+
pid: 12345, localPort: live.port, healthy: true, failCount: 0,
|
|
126
|
+
startedAt: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const res = await mgr.connect('spark');
|
|
130
|
+
assert.equal(res.localPort, live.port, 'a working tunnel is reused, not rebuilt');
|
|
131
|
+
assert.equal(mgr.active.has('spark'), true);
|
|
132
|
+
} finally { live.close(); }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('the health pass reaps a dead tunnel so the next connect starts clean', async () => {
|
|
136
|
+
const wedged = await startWedgedListener();
|
|
137
|
+
try {
|
|
138
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
|
|
139
|
+
mgr.active.set('s19', {
|
|
140
|
+
pid: 999999, localPort: wedged.port, healthy: true,
|
|
141
|
+
failCount: 99, // already past the failure limit
|
|
142
|
+
startedAt: new Date().toISOString(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await mgr._healthCheckAll();
|
|
146
|
+
|
|
147
|
+
assert.equal(mgr.active.has('s19'), false, 'dead tunnel removed from active');
|
|
148
|
+
assert.ok(
|
|
149
|
+
daemon.broadcasts.some((b) => b.type === 'tunnel.unhealthy'),
|
|
150
|
+
'the GUI is told the tunnel went unhealthy',
|
|
151
|
+
);
|
|
152
|
+
} finally { wedged.close(); }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('treats a long timer gap as a sleep and drops the failure tolerance', async () => {
|
|
156
|
+
const wedged = await startWedgedListener();
|
|
157
|
+
try {
|
|
158
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
|
|
159
|
+
mgr.active.set('s19', {
|
|
160
|
+
pid: 999999, localPort: wedged.port, healthy: true, failCount: 0,
|
|
161
|
+
startedAt: new Date().toISOString(),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Last check was 10 minutes ago — the machine was asleep.
|
|
165
|
+
mgr._lastHealthCheck = Date.now() - 10 * 60 * 1000;
|
|
166
|
+
await mgr._healthCheckAll();
|
|
167
|
+
|
|
168
|
+
const conn = mgr.active.get('s19');
|
|
169
|
+
assert.equal(conn?.healthy, false, 'one failure after a sleep gap is enough');
|
|
170
|
+
} finally { wedged.close(); }
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('_waitForPortFree reports a released port', async () => {
|
|
174
|
+
const live = await startHealthyListener();
|
|
175
|
+
assert.equal(await mgr._waitForPortFree(live.port, 600), false, 'still held while listening');
|
|
176
|
+
live.close();
|
|
177
|
+
assert.equal(await mgr._waitForPortFree(live.port, 3000), true, 'free once closed');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('_waitForExit returns true for a pid that does not exist', async () => {
|
|
181
|
+
assert.equal(await mgr._waitForExit(999999, 500), true);
|
|
182
|
+
});
|
|
183
|
+
});
|