groove-dev 0.27.197 → 0.27.199
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/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.45.35_PM.png +0 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +2 -0
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +368 -0
- package/node_modules/@groove-dev/daemon/src/axom-install.js +162 -0
- package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +249 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +14 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
- package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
- package/node_modules/@groove-dev/daemon/src/process.js +1 -1
- package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +267 -0
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +334 -20
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +455 -0
- package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +228 -0
- package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BbA-X4CE.js +1084 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.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/node_modules/@groove-dev/gui/src/App.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/app.css +53 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +22 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +279 -0
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1897 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +232 -4
- package/package.json +2 -2
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +2 -0
- package/packages/daemon/src/axom-connector.js +368 -0
- package/packages/daemon/src/axom-install.js +162 -0
- package/packages/daemon/src/axom-remote.js +230 -0
- package/packages/daemon/src/axom-server.js +249 -0
- package/packages/daemon/src/federation.js +6 -0
- package/packages/daemon/src/index.js +14 -0
- package/packages/daemon/src/innerchat-docs.js +8 -1
- package/packages/daemon/src/innerchat-relay.js +89 -0
- package/packages/daemon/src/innerchat.js +261 -1
- package/packages/daemon/src/introducer.js +1 -1
- package/packages/daemon/src/network-guard.js +56 -0
- package/packages/daemon/src/process.js +1 -1
- package/packages/daemon/src/providers/axom.js +66 -0
- package/packages/daemon/src/providers/index.js +2 -0
- package/packages/daemon/src/routes/axom.js +267 -0
- package/packages/daemon/src/routes/innerchat.js +334 -20
- package/packages/gui/dist/assets/index-BbA-X4CE.js +1084 -0
- package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +2 -0
- package/packages/gui/src/app.css +53 -0
- package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
- package/packages/gui/src/stores/groove.js +22 -0
- package/packages/gui/src/stores/slices/axom-slice.js +279 -0
- package/packages/gui/src/views/axom.jsx +1897 -0
- package/packages/gui/src/views/settings.jsx +232 -4
- package/node_modules/@groove-dev/gui/dist/assets/index-Da2nbd6M.js +0 -1043
- package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +0 -1
- package/packages/gui/dist/assets/index-Da2nbd6M.js +0 -1043
- package/packages/gui/dist/assets/index-zmrIbwNm.css +0 -1
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// GROOVE — Axom Connector Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Runs the connector against a mock bridge speaking the Axom provider
|
|
5
|
+
// protocol (GROOVE ⇄ Axom Integration Contract v0). The mock is contract-
|
|
6
|
+
// faithful: /about with kinds, /sessions, per-session WS with ?since replay,
|
|
7
|
+
// interrupt with 2000-char truncation, sticky idempotent stop.
|
|
8
|
+
|
|
9
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { createServer } from 'http';
|
|
12
|
+
import { WebSocketServer } from 'ws';
|
|
13
|
+
import { AxomConnector, KNOWN_KINDS, validateEndpoint } from '../src/axom-connector.js';
|
|
14
|
+
|
|
15
|
+
function envelope(seq, kind, payload = {}, session = 's-test0001') {
|
|
16
|
+
return {
|
|
17
|
+
id: `ev-${String(seq).padStart(6, '0')}`,
|
|
18
|
+
ts: 1753000000 + seq,
|
|
19
|
+
session,
|
|
20
|
+
firing_id: null,
|
|
21
|
+
step: null,
|
|
22
|
+
kind,
|
|
23
|
+
payload,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Contract-faithful mock of `axom serve`'s bridge surface.
|
|
28
|
+
class MockBridge {
|
|
29
|
+
constructor({ kinds = [...KNOWN_KINDS], sessions } = {}) {
|
|
30
|
+
this.kinds = kinds;
|
|
31
|
+
this.sessions = sessions || { 's-test0001': { started: 1753000000, live: true, events: [] } };
|
|
32
|
+
this.interrupts = [];
|
|
33
|
+
this.stops = [];
|
|
34
|
+
this.shutdowns = [];
|
|
35
|
+
this.messages = [];
|
|
36
|
+
this.sinceSeen = []; // ?since values observed on WS connects
|
|
37
|
+
this.sockets = new Set();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async start(port = 0) {
|
|
41
|
+
this.server = createServer((req, res) => this._http(req, res));
|
|
42
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
43
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
44
|
+
const url = new URL(req.url, 'http://localhost');
|
|
45
|
+
const match = url.pathname.match(/^\/ws\/session\/([^/]+)$/);
|
|
46
|
+
const session = match && this.sessions[decodeURIComponent(match[1])];
|
|
47
|
+
if (!session) { socket.destroy(); return; }
|
|
48
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
49
|
+
this.sockets.add(ws);
|
|
50
|
+
ws.on('close', () => this.sockets.delete(ws));
|
|
51
|
+
const since = url.searchParams.get('since');
|
|
52
|
+
this.sinceSeen.push(since);
|
|
53
|
+
// Ring replay: everything after `since`, then live.
|
|
54
|
+
const from = since ? parseInt(since.slice(3), 10) : 0;
|
|
55
|
+
for (const e of session.events) {
|
|
56
|
+
if (parseInt(e.id.slice(3), 10) > from) ws.send(JSON.stringify(e));
|
|
57
|
+
}
|
|
58
|
+
session.socket = ws;
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
this.server.once('error', reject);
|
|
63
|
+
this.server.listen(port, '127.0.0.1', resolve);
|
|
64
|
+
});
|
|
65
|
+
this.url = `http://127.0.0.1:${this.server.address().port}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
emit(sessionId, env) {
|
|
69
|
+
const session = this.sessions[sessionId];
|
|
70
|
+
session.events.push(env);
|
|
71
|
+
if (session.socket && session.socket.readyState === 1) {
|
|
72
|
+
session.socket.send(JSON.stringify(env));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_http(req, res) {
|
|
77
|
+
const url = new URL(req.url, 'http://localhost');
|
|
78
|
+
const json = (code, body) => {
|
|
79
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
80
|
+
res.end(JSON.stringify(body));
|
|
81
|
+
};
|
|
82
|
+
if (req.method === 'GET' && url.pathname === '/about') {
|
|
83
|
+
return json(200, {
|
|
84
|
+
name: 'axom', version: '0.1.0', family: 'phi4mini-s1',
|
|
85
|
+
record: { benchmark: 'HumanEval+', 'pass@1': '73.78%' },
|
|
86
|
+
chassis: { model: 'phi4mini_stock_q8.gguf', loaded: true },
|
|
87
|
+
leaves: [{ name: 'chat', loaded: true }, { name: 'agentic', loaded: true }],
|
|
88
|
+
narrator: 'template',
|
|
89
|
+
kinds: this.kinds,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (req.method === 'GET' && url.pathname === '/sessions') {
|
|
93
|
+
return json(200, Object.entries(this.sessions).map(([id, s]) => ({
|
|
94
|
+
session: id, started: s.started, live: s.live,
|
|
95
|
+
})));
|
|
96
|
+
}
|
|
97
|
+
const interrupt = url.pathname.match(/^\/session\/([^/]+)\/interrupt$/);
|
|
98
|
+
const stop = url.pathname.match(/^\/session\/([^/]+)\/stop$/);
|
|
99
|
+
const message = url.pathname.match(/^\/session\/([^/]+)\/message$/);
|
|
100
|
+
if (req.method === 'POST' && url.pathname === '/shutdown') {
|
|
101
|
+
let body = '';
|
|
102
|
+
req.on('data', (c) => { body += c; });
|
|
103
|
+
req.on('end', () => {
|
|
104
|
+
const { force } = JSON.parse(body || '{}');
|
|
105
|
+
const busy = Object.values(this.sessions).some((s) => s.live);
|
|
106
|
+
if (busy && !force) return json(409, { error: 'turn in flight' });
|
|
107
|
+
this.shutdowns.push({ force: !!force });
|
|
108
|
+
return json(202, { stopping: true });
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (req.method === 'POST' && (interrupt || stop || message)) {
|
|
113
|
+
let body = '';
|
|
114
|
+
req.on('data', (c) => { body += c; });
|
|
115
|
+
req.on('end', () => {
|
|
116
|
+
if (interrupt) {
|
|
117
|
+
const { text } = JSON.parse(body || '{}');
|
|
118
|
+
this.interrupts.push({ session: interrupt[1], text });
|
|
119
|
+
return json(200, { id: `int-${this.interrupts.length}`, truncated: text.length > 2000 });
|
|
120
|
+
}
|
|
121
|
+
if (message) {
|
|
122
|
+
// §12 semantics: caller-chosen id, first message creates; one turn
|
|
123
|
+
// at a time; hard max with 413, never truncation.
|
|
124
|
+
const id = decodeURIComponent(message[1]);
|
|
125
|
+
const parsed = JSON.parse(body || '{}');
|
|
126
|
+
const { text } = parsed;
|
|
127
|
+
// Store the parsed body verbatim so tests can assert on key
|
|
128
|
+
// PRESENCE (a destructured undefined would fabricate the key).
|
|
129
|
+
this.messages.push({ session: id, body: parsed });
|
|
130
|
+
if (text.length > 32768) return json(413, { error: 'too_long', max: 32768 });
|
|
131
|
+
let session = this.sessions[id];
|
|
132
|
+
if (!session) { session = this.sessions[id] = { started: Date.now() / 1000, live: false, events: [] }; }
|
|
133
|
+
if (session.live) return json(409, { error: 'busy' });
|
|
134
|
+
session.live = true;
|
|
135
|
+
return json(202, { session: id, accepted: true });
|
|
136
|
+
}
|
|
137
|
+
this.stops.push({ session: stop[1] });
|
|
138
|
+
return json(200, { ok: true });
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
json(404, { error: 'not found' });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async close() {
|
|
146
|
+
for (const ws of this.sockets) ws.terminate();
|
|
147
|
+
this.wss?.close();
|
|
148
|
+
this.server.closeAllConnections?.();
|
|
149
|
+
await new Promise((r) => this.server.close(r));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function fakeDaemon() {
|
|
154
|
+
const broadcasts = [];
|
|
155
|
+
return {
|
|
156
|
+
broadcasts,
|
|
157
|
+
config: { axom: { endpoints: [] } },
|
|
158
|
+
broadcast: (m) => broadcasts.push(m),
|
|
159
|
+
audit: { log() {} },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function waitFor(predicate, ms = 3000) {
|
|
164
|
+
const deadline = Date.now() + ms;
|
|
165
|
+
while (Date.now() < deadline) {
|
|
166
|
+
if (predicate()) return;
|
|
167
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
168
|
+
}
|
|
169
|
+
throw new Error('waitFor timed out');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
describe('AxomConnector', () => {
|
|
173
|
+
let bridge, daemon, connector;
|
|
174
|
+
|
|
175
|
+
beforeEach(async () => {
|
|
176
|
+
bridge = new MockBridge();
|
|
177
|
+
await bridge.start();
|
|
178
|
+
daemon = fakeDaemon();
|
|
179
|
+
connector = new AxomConnector(daemon, { backoffBaseMs: 50, sessionPollMs: 100 });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterEach(async () => {
|
|
183
|
+
connector.destroy();
|
|
184
|
+
await bridge.close();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
function connect(name = 'local') {
|
|
188
|
+
connector.configure([{ name, url: bridge.url }]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it('handshakes via /about and reports a clean schema match', async () => {
|
|
192
|
+
connect();
|
|
193
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
194
|
+
const ep = connector.status().endpoints[0];
|
|
195
|
+
assert.equal(ep.about.family, 'phi4mini-s1');
|
|
196
|
+
assert.equal(ep.about.narrator, 'template');
|
|
197
|
+
assert.deepEqual(ep.drift, { novel: [], missing: [] });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('surfaces schema drift in both directions without dropping anything', async () => {
|
|
201
|
+
await bridge.close();
|
|
202
|
+
bridge = new MockBridge({ kinds: [...KNOWN_KINDS.filter((k) => k !== 'leaf_swap'), 'brand_new_kind'] });
|
|
203
|
+
await bridge.start();
|
|
204
|
+
connect();
|
|
205
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
206
|
+
const ep = connector.status().endpoints[0];
|
|
207
|
+
assert.deepEqual(ep.drift.novel, ['brand_new_kind']);
|
|
208
|
+
assert.deepEqual(ep.drift.missing, ['leaf_swap']);
|
|
209
|
+
assert.equal(ep.status, 'connected'); // drift is surfaced, never fatal
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('discovers live sessions and passes envelopes through verbatim', async () => {
|
|
213
|
+
connect();
|
|
214
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
215
|
+
const env = envelope(1, 'pipeline_start', { firing_id: 'f-1' });
|
|
216
|
+
bridge.emit('s-test0001', env);
|
|
217
|
+
await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:event'));
|
|
218
|
+
const msg = daemon.broadcasts.find((b) => b.type === 'axom:event');
|
|
219
|
+
assert.equal(msg.endpoint, 'local');
|
|
220
|
+
assert.equal(msg.session, 's-test0001');
|
|
221
|
+
assert.deepEqual(msg.envelope, env); // byte-faithful passthrough
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('tolerates unknown kinds — passed through and counted, never dropped', async () => {
|
|
225
|
+
connect();
|
|
226
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
227
|
+
bridge.emit('s-test0001', envelope(1, 'kind_from_the_future', { x: 1 }));
|
|
228
|
+
await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:event'));
|
|
229
|
+
assert.equal(daemon.broadcasts.find((b) => b.type === 'axom:event').envelope.kind, 'kind_from_the_future');
|
|
230
|
+
const s = connector.status().endpoints[0].sessions[0];
|
|
231
|
+
assert.equal(s.unknownKinds.kind_from_the_future, 1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('resumes with ?since after reconnect and dedups the ring replay', async () => {
|
|
235
|
+
connect();
|
|
236
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
237
|
+
bridge.emit('s-test0001', envelope(1, 'pipeline_start'));
|
|
238
|
+
bridge.emit('s-test0001', envelope(2, 'thought', { text: 'hm' }));
|
|
239
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 2);
|
|
240
|
+
|
|
241
|
+
// Kill the socket server-side; connector should reconnect with since=ev-000002.
|
|
242
|
+
bridge.sessions['s-test0001'].socket.terminate();
|
|
243
|
+
await waitFor(() => bridge.sinceSeen.length === 2);
|
|
244
|
+
assert.equal(bridge.sinceSeen[1], 'ev-000002');
|
|
245
|
+
|
|
246
|
+
// Replayed history must not re-broadcast; a genuinely new event must.
|
|
247
|
+
bridge.emit('s-test0001', envelope(3, 'resolution'));
|
|
248
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 3);
|
|
249
|
+
const seqs = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.id);
|
|
250
|
+
assert.deepEqual(seqs, ['ev-000001', 'ev-000002', 'ev-000003']);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('proxies interrupt verbatim including the runtime truncation flag', async () => {
|
|
254
|
+
connect();
|
|
255
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
256
|
+
const short = await connector.interrupt('local', 's-test0001', 'skip the auth module');
|
|
257
|
+
assert.deepEqual(short, { id: 'int-1', truncated: false });
|
|
258
|
+
const long = await connector.interrupt('local', 's-test0001', 'x'.repeat(2500));
|
|
259
|
+
assert.equal(long.truncated, true); // the runtime is the truncation authority
|
|
260
|
+
assert.equal(bridge.interrupts.length, 2);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('proxies stop and resolves the endpoint when unambiguous', async () => {
|
|
264
|
+
connect();
|
|
265
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
266
|
+
// Omitted endpoint name resolves with exactly one endpoint configured.
|
|
267
|
+
const result = await connector.stop(undefined, 's-test0001');
|
|
268
|
+
assert.deepEqual(result, { ok: true });
|
|
269
|
+
assert.deepEqual(bridge.stops, [{ session: 's-test0001' }]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('counts ring overflow instead of dropping silently', async () => {
|
|
273
|
+
connector.destroy();
|
|
274
|
+
connector = new AxomConnector(daemon, { ringSize: 2, backoffBaseMs: 50, sessionPollMs: 100 });
|
|
275
|
+
connect();
|
|
276
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
277
|
+
for (let i = 1; i <= 5; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
278
|
+
await waitFor(() => connector.status().endpoints[0].sessions[0].overflow === 3);
|
|
279
|
+
const s = connector.status().endpoints[0].sessions[0];
|
|
280
|
+
assert.equal(s.buffered, 2);
|
|
281
|
+
assert.equal(s.overflow, 3);
|
|
282
|
+
// All five still broadcast — the ring bounds memory, not delivery.
|
|
283
|
+
assert.equal(daemon.broadcasts.filter((b) => b.type === 'axom:event').length, 5);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('serves buffered events with a since cursor for GUI backfill', async () => {
|
|
287
|
+
connect();
|
|
288
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
289
|
+
for (let i = 1; i <= 3; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
290
|
+
await waitFor(() => connector.status().endpoints[0].sessions[0].buffered === 3);
|
|
291
|
+
const all = connector.events('local', 's-test0001');
|
|
292
|
+
assert.equal(all.events.length, 3);
|
|
293
|
+
const tail = connector.events('local', 's-test0001', 2);
|
|
294
|
+
assert.deepEqual(tail.events.map((e) => e.id), ['ev-000003']);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('isolates concurrent sessions — streams, backfill cursors, and rings never cross', async () => {
|
|
298
|
+
connector.destroy();
|
|
299
|
+
await bridge.close();
|
|
300
|
+
bridge = new MockBridge({
|
|
301
|
+
sessions: {
|
|
302
|
+
's-alpha': { started: 1, live: true, events: [] },
|
|
303
|
+
's-beta': { started: 2, live: true, events: [] },
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
await bridge.start();
|
|
307
|
+
connector = new AxomConnector(daemon, { ringSize: 2, backoffBaseMs: 50, sessionPollMs: 100 });
|
|
308
|
+
connect();
|
|
309
|
+
await waitFor(() => {
|
|
310
|
+
const sessions = connector.status().endpoints[0]?.sessions || [];
|
|
311
|
+
return sessions.length === 2 && sessions.every((s) => s.watching);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
bridge.emit('s-alpha', envelope(1, 'pipeline_start', {}, 's-alpha'));
|
|
315
|
+
bridge.emit('s-beta', envelope(1, 'thought', { text: 'b' }, 's-beta'));
|
|
316
|
+
for (let i = 2; i <= 4; i++) bridge.emit('s-alpha', envelope(i, 'thought', {}, 's-alpha'));
|
|
317
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 5);
|
|
318
|
+
|
|
319
|
+
// Broadcast routing: every envelope tagged with its own session, only.
|
|
320
|
+
for (const b of daemon.broadcasts.filter((x) => x.type === 'axom:event')) {
|
|
321
|
+
assert.equal(b.envelope.session, b.session);
|
|
322
|
+
}
|
|
323
|
+
// Backfill cursors never leak across sessions.
|
|
324
|
+
const alpha = connector.events('local', 's-alpha');
|
|
325
|
+
const beta = connector.events('local', 's-beta');
|
|
326
|
+
assert.ok(alpha.events.every((e) => e.session === 's-alpha'));
|
|
327
|
+
assert.deepEqual(beta.events.map((e) => e.session), ['s-beta']);
|
|
328
|
+
// Rings overflow independently: alpha (4 events, ring 2) overflowed, beta did not.
|
|
329
|
+
assert.equal(alpha.overflow, 2);
|
|
330
|
+
assert.equal(beta.overflow, 0);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('passes a second stop through cleanly — sticky idempotence is preserved end to end', async () => {
|
|
334
|
+
connect();
|
|
335
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
336
|
+
const first = await connector.stop('local', 's-test0001');
|
|
337
|
+
const second = await connector.stop('local', 's-test0001');
|
|
338
|
+
// The runtime's stop is sticky-idempotent; the proxy must neither error,
|
|
339
|
+
// dedupe, nor add semantics — both calls land, both return {ok}.
|
|
340
|
+
assert.deepEqual(first, { ok: true });
|
|
341
|
+
assert.deepEqual(second, { ok: true });
|
|
342
|
+
assert.equal(bridge.stops.length, 2);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('survives a WS drop mid-flow with no gap and no duplicate when the producer never paused', async () => {
|
|
346
|
+
connect();
|
|
347
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
348
|
+
for (let i = 1; i <= 3; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
349
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 3);
|
|
350
|
+
|
|
351
|
+
// Drop the socket while the producer keeps firing into the runtime ring.
|
|
352
|
+
bridge.sessions['s-test0001'].socket.terminate();
|
|
353
|
+
for (let i = 4; i <= 6; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
354
|
+
|
|
355
|
+
// Reconnect replays 4-6 from the ring via ?since, then goes live for 7-8.
|
|
356
|
+
await waitFor(() => bridge.sinceSeen.length === 2);
|
|
357
|
+
bridge.emit('s-test0001', envelope(7, 'thought'));
|
|
358
|
+
bridge.emit('s-test0001', envelope(8, 'resolution'));
|
|
359
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 8);
|
|
360
|
+
const ids = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.id);
|
|
361
|
+
assert.deepEqual(ids, Array.from({ length: 8 }, (_, i) => `ev-${String(i + 1).padStart(6, '0')}`));
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('message creates a caller-chosen session (202) and the connector attaches to it', async () => {
|
|
365
|
+
connect();
|
|
366
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
367
|
+
const result = await connector.message('local', 's-mine-0001', 'hello axom');
|
|
368
|
+
assert.equal(result.status, 202);
|
|
369
|
+
assert.deepEqual(result.body, { session: 's-mine-0001', accepted: true });
|
|
370
|
+
// The post-202 poll discovers and watches the new session.
|
|
371
|
+
await waitFor(() => {
|
|
372
|
+
const s = connector.status().endpoints[0].sessions.find((x) => x.session === 's-mine-0001');
|
|
373
|
+
return s && s.watching;
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('mirrors §12 contract statuses verbatim — 409 busy, 413 too_long', async () => {
|
|
378
|
+
connect();
|
|
379
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
380
|
+
await connector.message('local', 's-busy', 'first turn');
|
|
381
|
+
const busy = await connector.message('local', 's-busy', 'second turn');
|
|
382
|
+
assert.equal(busy.status, 409);
|
|
383
|
+
assert.equal(busy.body.error, 'busy');
|
|
384
|
+
const tooLong = await connector.message('local', 's-long', 'x'.repeat(40000));
|
|
385
|
+
assert.equal(tooLong.status, 413);
|
|
386
|
+
assert.equal(tooLong.body.max, 32768);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('stays attached to a session between turns (live:false still watched)', async () => {
|
|
390
|
+
connect();
|
|
391
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
392
|
+
bridge.sessions['s-test0001'].live = false;
|
|
393
|
+
// Force a poll cycle; the watch must not drop on live:false (§12: events
|
|
394
|
+
// for the next turn can start at any moment).
|
|
395
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
396
|
+
const s = connector.status().endpoints[0].sessions.find((x) => x.session === 's-test0001');
|
|
397
|
+
assert.equal(s.live, false);
|
|
398
|
+
assert.equal(s.watching, true);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('sends client_ref only when given, and omits it otherwise (§15)', async () => {
|
|
402
|
+
connect();
|
|
403
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
404
|
+
await connector.message('local', 's-ref', 'with a ref', 'g-abc123');
|
|
405
|
+
await connector.message('local', 's-noref', 'without one');
|
|
406
|
+
assert.equal(bridge.messages[0].body.client_ref, 'g-abc123');
|
|
407
|
+
// Pre-§15 runtimes must see exactly today's payload — no null key.
|
|
408
|
+
assert.equal('client_ref' in bridge.messages[1].body, false);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('shuts the runtime down, refusing mid-turn unless forced (§14)', async () => {
|
|
412
|
+
connect();
|
|
413
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
414
|
+
// s-test0001 starts live: a turn is in flight.
|
|
415
|
+
const refused = await connector.shutdown('local');
|
|
416
|
+
assert.equal(refused.status, 409);
|
|
417
|
+
assert.equal(refused.body.error, 'turn in flight');
|
|
418
|
+
assert.equal(bridge.shutdowns.length, 0);
|
|
419
|
+
|
|
420
|
+
const forced = await connector.shutdown('local', { force: true });
|
|
421
|
+
assert.equal(forced.status, 202);
|
|
422
|
+
assert.deepEqual(forced.body, { stopping: true });
|
|
423
|
+
assert.deepEqual(bridge.shutdowns, [{ force: true }]);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('reports an unreachable endpoint honestly and recovers by retry', async () => {
|
|
427
|
+
const deadUrl = bridge.url;
|
|
428
|
+
const port = Number(new URL(deadUrl).port);
|
|
429
|
+
await bridge.close();
|
|
430
|
+
connector.configure([{ name: 'local', url: deadUrl }]);
|
|
431
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'error');
|
|
432
|
+
assert.ok(connector.status().endpoints[0].error);
|
|
433
|
+
|
|
434
|
+
// Bring a bridge back on the same port; backoff retry should reconnect.
|
|
435
|
+
bridge = new MockBridge();
|
|
436
|
+
await bridge.start(port);
|
|
437
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected', 5000);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe('validateEndpoint', () => {
|
|
442
|
+
it('accepts a clean http endpoint', () => {
|
|
443
|
+
assert.equal(validateEndpoint({ name: 'local', url: 'http://127.0.0.1:8737' }), null);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('rejects bad names, schemes, and embedded credentials', () => {
|
|
447
|
+
assert.ok(validateEndpoint({ name: 'bad name!', url: 'http://127.0.0.1:8737' }));
|
|
448
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'ftp://127.0.0.1' }));
|
|
449
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'javascript:alert(1)' }));
|
|
450
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'http://user:pass@host:8737' }));
|
|
451
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'not a url' }));
|
|
452
|
+
assert.ok(validateEndpoint({ name: 'x' }));
|
|
453
|
+
assert.ok(validateEndpoint(null));
|
|
454
|
+
});
|
|
455
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// GROOVE — Axom Remote Control Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// SSH is injected, so these assert the COMMANDS we would run and the honesty
|
|
5
|
+
// of what we report — never that ssh itself works.
|
|
6
|
+
|
|
7
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { AxomRemote, validateRemote } from '../src/axom-remote.js';
|
|
10
|
+
|
|
11
|
+
function harness({ responses = {}, remote = { host: 'spark.local', user: 'axom' } } = {}) {
|
|
12
|
+
const calls = [];
|
|
13
|
+
const daemon = {
|
|
14
|
+
config: { axom: { remote } },
|
|
15
|
+
broadcast() {},
|
|
16
|
+
audit: { log() {} },
|
|
17
|
+
};
|
|
18
|
+
const exec = (bin, args, opts, cb) => {
|
|
19
|
+
const command = args[args.length - 1];
|
|
20
|
+
calls.push({ bin, target: args[args.length - 2], command });
|
|
21
|
+
const match = Object.keys(responses).find((k) => command.includes(k));
|
|
22
|
+
const out = match ? responses[match] : '';
|
|
23
|
+
if (out instanceof Error) return cb(out, '', out.message);
|
|
24
|
+
cb(null, out, '');
|
|
25
|
+
};
|
|
26
|
+
return { remote: new AxomRemote(daemon, { exec }), calls, daemon };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('AxomRemote', () => {
|
|
30
|
+
it('reports running/not-running from a probe on the remote host', async () => {
|
|
31
|
+
const up = harness({ responses: { '/about': 'UP\n' } });
|
|
32
|
+
assert.deepEqual(await up.remote.status(), {
|
|
33
|
+
configured: true, host: 'spark.local', user: 'axom', port: 8737, running: true, error: null,
|
|
34
|
+
});
|
|
35
|
+
const down = harness({ responses: { '/about': 'DOWN\n' } });
|
|
36
|
+
assert.equal((await down.remote.status()).running, false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('reports UNKNOWN (not "stopped") when the host is unreachable', async () => {
|
|
40
|
+
const h = harness({ responses: { '/about': new Error('ssh: connect to host spark.local port 22: No route to host') } });
|
|
41
|
+
const s = await h.remote.status();
|
|
42
|
+
// A host we cannot reach tells us nothing about the runtime. Saying
|
|
43
|
+
// "stopped" would be a guess presented as fact.
|
|
44
|
+
assert.equal(s.running, null);
|
|
45
|
+
assert.match(s.error, /No route to host/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('starts detached with no supervisor and confirms it actually came up', async () => {
|
|
49
|
+
let probes = 0;
|
|
50
|
+
const h = harness({
|
|
51
|
+
responses: {
|
|
52
|
+
get '/about'() { probes += 1; return probes === 1 ? 'DOWN\n' : 'UP\n'; },
|
|
53
|
+
'nohup': 'STARTED\n',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const result = await h.remote.start();
|
|
57
|
+
assert.equal(result.started, true);
|
|
58
|
+
const startCmd = h.calls.find((c) => c.command.includes('nohup')).command;
|
|
59
|
+
assert.match(startCmd, /axom serve --port 8737/);
|
|
60
|
+
assert.match(startCmd, /disown/); // survives the ssh session
|
|
61
|
+
assert.equal(/while|until|restart|systemctl/.test(startCmd), false); // never supervised
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not start a second runtime when one is already up', async () => {
|
|
65
|
+
const h = harness({ responses: { '/about': 'UP\n' } });
|
|
66
|
+
assert.deepEqual(await h.remote.start(), { started: false, alreadyRunning: true, port: 8737 });
|
|
67
|
+
assert.equal(h.calls.some((c) => c.command.includes('nohup')), false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('stops via the §14 shutdown verb when the runtime supports it', async () => {
|
|
71
|
+
const h = harness({ responses: { '/shutdown': '202', '/about': 'DOWN\n' } });
|
|
72
|
+
const result = await h.remote.stop();
|
|
73
|
+
assert.deepEqual(result, { stopped: true, via: 'shutdown' });
|
|
74
|
+
assert.equal(h.calls.some((c) => c.command.includes('kill')), false); // no signal needed
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('refuses to stop mid-turn unless forced, and passes force through', async () => {
|
|
78
|
+
const busy = harness({ responses: { '/shutdown': '409', '/about': 'UP\n' } });
|
|
79
|
+
assert.deepEqual(await busy.remote.stop(), { stopped: false, turnInFlight: true });
|
|
80
|
+
|
|
81
|
+
const forced = harness({ responses: { '/shutdown': '202', '/about': 'UP\n' } });
|
|
82
|
+
await forced.remote.stop({ force: true });
|
|
83
|
+
assert.match(forced.calls.find((c) => c.command.includes('/shutdown')).command, /"force":true/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('falls back to a signal only for runtimes predating §14', async () => {
|
|
87
|
+
const h = harness({ responses: { '/shutdown': '404', '/about': 'UP\n' } });
|
|
88
|
+
const result = await h.remote.stop();
|
|
89
|
+
assert.deepEqual(result, { stopped: true, via: 'signal' });
|
|
90
|
+
assert.match(h.calls.find((c) => c.command.includes('kill')).command, /lsof -t -i:8737/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('reports unconfigured rather than pretending', async () => {
|
|
94
|
+
const h = harness({ remote: null });
|
|
95
|
+
assert.deepEqual(await h.remote.status(), { configured: false, running: null });
|
|
96
|
+
await assert.rejects(() => h.remote.start(), /no remote Axom host configured/);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('validateRemote', () => {
|
|
101
|
+
it('accepts a clean config', () => {
|
|
102
|
+
assert.equal(validateRemote({ host: 'spark.local', user: 'axom', sshPort: 22, port: 8737 }), null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects shell metacharacters and malformed values', () => {
|
|
106
|
+
// host/user reach execFile as single argv entries, but a permissive
|
|
107
|
+
// validator would still invite injection attempts through config.
|
|
108
|
+
assert.ok(validateRemote({ host: 'spark.local; rm -rf /', user: 'axom' }));
|
|
109
|
+
assert.ok(validateRemote({ host: 'spark.local', user: 'axom$(id)' }));
|
|
110
|
+
assert.ok(validateRemote({ host: 'a'.repeat(300), user: 'axom' }));
|
|
111
|
+
assert.ok(validateRemote({ host: 'spark.local', user: 'axom', sshPort: 99999 }));
|
|
112
|
+
assert.ok(validateRemote({ host: 'spark.local', user: 'axom', command: 'x'.repeat(600) }));
|
|
113
|
+
assert.ok(validateRemote(null));
|
|
114
|
+
});
|
|
115
|
+
});
|