groove-dev 0.27.207 → 0.27.209
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/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +50 -2
- package/node_modules/@groove-dev/daemon/src/axom-remote.js +16 -14
- package/node_modules/@groove-dev/daemon/src/axom-runtimes.js +498 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +25 -4
- package/node_modules/@groove-dev/daemon/src/index.js +3 -1
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +113 -0
- package/node_modules/@groove-dev/daemon/src/routes/watch.js +11 -0
- package/node_modules/@groove-dev/daemon/src/watcher.js +55 -2
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
- package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +367 -0
- package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -2
- package/node_modules/@groove-dev/gui/dist/assets/{index-OzNfp6-Y.js → index-217ZVIOc.js} +242 -237
- package/node_modules/@groove-dev/gui/dist/assets/index-DdCadtGL.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/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/axom-connector.js +50 -2
- package/packages/daemon/src/axom-remote.js +16 -14
- package/packages/daemon/src/axom-runtimes.js +498 -0
- package/packages/daemon/src/axom-server.js +25 -4
- package/packages/daemon/src/index.js +3 -1
- package/packages/daemon/src/routes/axom.js +113 -0
- package/packages/daemon/src/routes/watch.js +11 -0
- package/packages/daemon/src/watcher.js +55 -2
- package/packages/gui/dist/assets/{index-OzNfp6-Y.js → index-217ZVIOc.js} +242 -237
- package/packages/gui/dist/assets/index-DdCadtGL.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-DG6yq4dB.css +0 -1
- package/packages/gui/dist/assets/index-DG6yq4dB.css +0 -1
|
@@ -122,6 +122,119 @@ export function registerAxomRoutes(app, daemon) {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
// ── Runtimes — the one entity (plans/axom-runtime-flow-redesign.md) ─────
|
|
126
|
+
// The GUI reasons about runtimes only; endpoints/instances/ssh are backends.
|
|
127
|
+
|
|
128
|
+
app.get('/api/axom/runtimes', async (req, res) => {
|
|
129
|
+
res.json(await daemon.axomRuntimes.status());
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
app.post('/api/axom/runtimes', (req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
const rt = daemon.axomRuntimes.add(req.body);
|
|
135
|
+
if (req.body?.activate) daemon.axomRuntimes.activate(rt.id);
|
|
136
|
+
daemon.audit.log('axom.runtime.add', { id: rt.id, control: rt.control });
|
|
137
|
+
res.json(rt);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
res.status(400).json({ error: err.message });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
app.patch('/api/axom/runtimes/:id', (req, res) => {
|
|
144
|
+
try {
|
|
145
|
+
res.json(daemon.axomRuntimes.update(req.params.id, req.body || {}));
|
|
146
|
+
} catch (err) {
|
|
147
|
+
res.status(400).json({ error: err.message });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
app.delete('/api/axom/runtimes/:id', (req, res) => {
|
|
152
|
+
try {
|
|
153
|
+
daemon.axomRuntimes.remove(req.params.id);
|
|
154
|
+
daemon.audit.log('axom.runtime.remove', { id: req.params.id });
|
|
155
|
+
res.json({ ok: true });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
res.status(400).json({ error: err.message });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Mono-Axom (§10): a hook is a fresh session on the ONE runtime — never a
|
|
162
|
+
// second process. Used by the agent selector, new tabs, and new chats alike.
|
|
163
|
+
// Chats are named hooks, persisted daemon-side so the list survives a
|
|
164
|
+
// refresh. Removing one HIDES it — the conversation is the user's memory and
|
|
165
|
+
// lives in the runtime's ledger; GROOVE tidying its list never destroys it.
|
|
166
|
+
app.get('/api/axom/chats', (req, res) => {
|
|
167
|
+
res.json({ chats: daemon.axomRuntimes.chats() });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
app.patch('/api/axom/chats/:session', (req, res) => {
|
|
171
|
+
try {
|
|
172
|
+
res.json(daemon.axomRuntimes.renameChat(req.params.session, req.body?.label));
|
|
173
|
+
} catch (err) {
|
|
174
|
+
res.status(400).json({ error: err.message });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
app.delete('/api/axom/chats/:session', (req, res) => {
|
|
179
|
+
try {
|
|
180
|
+
const result = daemon.axomRuntimes.hideChat(req.params.session);
|
|
181
|
+
daemon.audit.log('axom.chat.hide', { session: req.params.session });
|
|
182
|
+
res.json(result);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
res.status(400).json({ error: err.message });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
app.post('/api/axom/hook', async (req, res) => {
|
|
189
|
+
try {
|
|
190
|
+
const result = await daemon.axomRuntimes.hook(req.body?.runtimeId, {
|
|
191
|
+
session: req.body?.session,
|
|
192
|
+
label: req.body?.label,
|
|
193
|
+
});
|
|
194
|
+
daemon.audit.log('axom.hook', { runtime: result.runtimeId, session: result.session });
|
|
195
|
+
res.json(result);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
res.status(502).json({ error: err.message });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
app.post('/api/axom/runtimes/:id/activate', (req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
daemon.axomRuntimes.activate(req.params.id);
|
|
204
|
+
res.json({ ok: true, activeRuntimeId: req.params.id });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
res.status(400).json({ error: err.message });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
app.post('/api/axom/runtimes/:id/start', async (req, res) => {
|
|
211
|
+
try {
|
|
212
|
+
const result = await daemon.axomRuntimes.startRuntime(req.params.id);
|
|
213
|
+
daemon.audit.log('axom.runtime.start', { id: req.params.id });
|
|
214
|
+
res.json(result);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
res.status(502).json({ error: err.message });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
app.post('/api/axom/runtimes/:id/stop', async (req, res) => {
|
|
221
|
+
try {
|
|
222
|
+
const result = await daemon.axomRuntimes.stopRuntime(req.params.id, { force: !!req.body?.force });
|
|
223
|
+
daemon.audit.log('axom.runtime.stop', { id: req.params.id });
|
|
224
|
+
res.json(result);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
res.status(502).json({ error: err.message });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
app.post('/api/axom/runtimes/:id/heal', async (req, res) => {
|
|
231
|
+
try {
|
|
232
|
+
res.json(await daemon.axomRuntimes.heal(req.params.id));
|
|
233
|
+
} catch (err) {
|
|
234
|
+
res.status(502).json({ error: err.message });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
125
238
|
// ── Remote runtime control over SSH (manual only, never automatic) ──────
|
|
126
239
|
|
|
127
240
|
app.get('/api/axom/remote', async (req, res) => {
|
|
@@ -20,6 +20,17 @@ export function registerWatchRoutes(app, daemon) {
|
|
|
20
20
|
if (!who) return res.status(404).json({ error: `Unknown agent: ${agent}` });
|
|
21
21
|
|
|
22
22
|
const watch = daemon.watcher.create(who.id, { command, until, label, timeoutMs, intervalMs });
|
|
23
|
+
if (watch.reattached) {
|
|
24
|
+
return res.json({
|
|
25
|
+
ok: true,
|
|
26
|
+
watchId: watch.id,
|
|
27
|
+
reattached: true,
|
|
28
|
+
message: `That command is ALREADY RUNNING under watch ${watch.id} ("${watch.label}") — `
|
|
29
|
+
+ 'this request re-attached to it instead of starting a second copy. '
|
|
30
|
+
+ 'Do NOT launch it again by hand. You will be resumed with the result when it finishes; '
|
|
31
|
+
+ 'you can end your turn now.',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
23
34
|
res.json({
|
|
24
35
|
ok: true,
|
|
25
36
|
watchId: watch.id,
|
|
@@ -59,6 +59,28 @@ export class Watcher {
|
|
|
59
59
|
if (!command && !until) throw new Error('Provide either "command" (run it) or "until" (poll it)');
|
|
60
60
|
if (command && until) throw new Error('Provide only one of "command" or "until"');
|
|
61
61
|
|
|
62
|
+
// Re-attach, never re-execute. A daemon restart resumes the agent mid-turn,
|
|
63
|
+
// so it re-issues the watch it thinks never completed — and a second copy of
|
|
64
|
+
// a long job (training, benchmark) launched against the same working dir
|
|
65
|
+
// corrupts the first one's output and competes for the GPU. If an identical
|
|
66
|
+
// command is still running for this agent, hand back the existing watch.
|
|
67
|
+
// This is the daemon-side equivalent of an flock, and it has to live here
|
|
68
|
+
// because the agent cannot know a duplicate already exists.
|
|
69
|
+
if (command) {
|
|
70
|
+
const dup = [...this.watches.values()].find(
|
|
71
|
+
(w) => w.status === 'active'
|
|
72
|
+
&& w.mode === 'command'
|
|
73
|
+
&& w.command === command
|
|
74
|
+
&& (w.agentName === agent.name || w.agentId === agentId)
|
|
75
|
+
&& this._jobAlive(w),
|
|
76
|
+
);
|
|
77
|
+
if (dup) {
|
|
78
|
+
this.daemon.audit?.log('watch.duplicate', { id: dup.id, agent: agentId, label: dup.label });
|
|
79
|
+
console.log(`[Groove:Watcher] Re-attached to running watch ${dup.id} instead of re-running: ${dup.label}`);
|
|
80
|
+
return { ...this._public(dup), reattached: true };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
const active = [...this.watches.values()].filter((w) => w.agentId === agentId && w.status === 'active');
|
|
63
85
|
if (active.length >= MAX_WATCHES_PER_AGENT) {
|
|
64
86
|
throw new Error(`You already have ${MAX_WATCHES_PER_AGENT} active watches — cancel one before adding another`);
|
|
@@ -118,11 +140,14 @@ export class Watcher {
|
|
|
118
140
|
// Run the command in a SUBSHELL so its own `exit N` can't kill the wrapper
|
|
119
141
|
// before the sentinel is written; tee output to a file, then atomically
|
|
120
142
|
// publish the exit code so the poller never sees a half-written value.
|
|
143
|
+
// Append rather than truncate: if this script is ever run twice, `>` would
|
|
144
|
+
// erase the first run's record — exactly the evidence you need to work out
|
|
145
|
+
// what happened. Appending keeps both.
|
|
121
146
|
const script = [
|
|
122
147
|
'#!/bin/sh',
|
|
123
148
|
'(',
|
|
124
149
|
watch.command,
|
|
125
|
-
`)
|
|
150
|
+
`) >> ${shq(watch.outFile)} 2>&1`,
|
|
126
151
|
`echo $? > ${shq(watch.statusFile)}.tmp && mv ${shq(watch.statusFile)}.tmp ${shq(watch.statusFile)}`,
|
|
127
152
|
'',
|
|
128
153
|
].join('\n');
|
|
@@ -143,6 +168,16 @@ export class Watcher {
|
|
|
143
168
|
}
|
|
144
169
|
}
|
|
145
170
|
|
|
171
|
+
// Is this watch's detached job still running? Signal 0 tests for existence
|
|
172
|
+
// without delivering anything. A finished job (sentinel present) is not alive
|
|
173
|
+
// even if the pid happens to have been recycled by another process.
|
|
174
|
+
_jobAlive(watch) {
|
|
175
|
+
if (watch.mode !== 'command') return false;
|
|
176
|
+
if (watch.statusFile && existsSync(watch.statusFile)) return false; // already exited
|
|
177
|
+
if (!watch.pid) return false;
|
|
178
|
+
try { process.kill(watch.pid, 0); return true; } catch { return false; }
|
|
179
|
+
}
|
|
180
|
+
|
|
146
181
|
// One poll iteration — checks for completion (sentinel for command mode, the
|
|
147
182
|
// `until` command for until mode) and wakes the agent when it's met.
|
|
148
183
|
_tick(watch) {
|
|
@@ -257,6 +292,7 @@ export class Watcher {
|
|
|
257
292
|
if (!Array.isArray(data)) return 0;
|
|
258
293
|
|
|
259
294
|
let rearmed = 0;
|
|
295
|
+
let lost = 0;
|
|
260
296
|
const now = Date.now();
|
|
261
297
|
for (const w of data) {
|
|
262
298
|
// Drop stale finished watches; keep recent ones for history.
|
|
@@ -268,10 +304,27 @@ export class Watcher {
|
|
|
268
304
|
}
|
|
269
305
|
const watch = { ...w, _poll: null, _deadline: null };
|
|
270
306
|
this.watches.set(watch.id, watch);
|
|
307
|
+
|
|
308
|
+
// A command watch whose job is gone with no exit sentinel died while the
|
|
309
|
+
// daemon was down. Polling it would just burn until the timeout and then
|
|
310
|
+
// report "may still be running" — say what actually happened instead.
|
|
311
|
+
if (watch.mode === 'command' && watch.pid
|
|
312
|
+
&& !existsSync(watch.statusFile || '') && !this._jobAlive(watch)) {
|
|
313
|
+
lost++;
|
|
314
|
+
this._wake(watch, {
|
|
315
|
+
outcome: 'error',
|
|
316
|
+
summary: `The job for "${watch.label}" is no longer running and never recorded an exit code — `
|
|
317
|
+
+ 'it was lost while the daemon was down. Check its output before assuming it finished.',
|
|
318
|
+
output: tailFile(watch.outFile, OUTPUT_TAIL),
|
|
319
|
+
});
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
271
323
|
this._arm(watch);
|
|
272
324
|
rearmed++;
|
|
273
325
|
}
|
|
274
|
-
if (rearmed > 0) console.log(`[Groove:Watcher]
|
|
326
|
+
if (rearmed > 0) console.log(`[Groove:Watcher] Re-attached to ${rearmed} running watch(es) after restart`);
|
|
327
|
+
if (lost > 0) console.log(`[Groove:Watcher] ${lost} watch(es) lost their job while the daemon was down`);
|
|
275
328
|
this._persist();
|
|
276
329
|
return rearmed;
|
|
277
330
|
}
|
|
@@ -34,6 +34,8 @@ class MockBridge {
|
|
|
34
34
|
this.shutdowns = [];
|
|
35
35
|
this.messages = [];
|
|
36
36
|
this.sinceSeen = []; // ?since values observed on WS connects
|
|
37
|
+
this.epochsSeen = [];
|
|
38
|
+
this.epoch = 'epoch-A';
|
|
37
39
|
this.sockets = new Set();
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -50,8 +52,12 @@ class MockBridge {
|
|
|
50
52
|
ws.on('close', () => this.sockets.delete(ws));
|
|
51
53
|
const since = url.searchParams.get('since');
|
|
52
54
|
this.sinceSeen.push(since);
|
|
55
|
+
this.epochsSeen.push(url.searchParams.get('epoch'));
|
|
56
|
+
// §16.4: hello first; a stale client epoch voids `since` (full replay).
|
|
57
|
+
ws.send(JSON.stringify({ kind: 'ws_hello', payload: { epoch: this.epoch, since_honored: url.searchParams.get('epoch') === this.epoch } }));
|
|
58
|
+
const staleEpoch = url.searchParams.get('epoch') && url.searchParams.get('epoch') !== this.epoch;
|
|
53
59
|
// Ring replay: everything after `since`, then live.
|
|
54
|
-
const from = since ? parseInt(since.slice(3), 10) : 0;
|
|
60
|
+
const from = (since && !staleEpoch) ? parseInt(since.slice(3), 10) : 0;
|
|
55
61
|
for (const e of session.events) {
|
|
56
62
|
if (parseInt(e.id.slice(3), 10) > from) ws.send(JSON.stringify(e));
|
|
57
63
|
}
|
|
@@ -435,6 +441,43 @@ describe('AxomConnector', () => {
|
|
|
435
441
|
assert.equal(latest.data.endpoints[0].sessions[0].live, false);
|
|
436
442
|
});
|
|
437
443
|
|
|
444
|
+
it('§16.4: a changed epoch resets the cursor — a runtime restart is replayed, never swallowed', async () => {
|
|
445
|
+
connect();
|
|
446
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
447
|
+
bridge.emit('s-test0001', envelope(1, 'pipeline_start'));
|
|
448
|
+
bridge.emit('s-test0001', envelope(2, 'thought'));
|
|
449
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 2);
|
|
450
|
+
|
|
451
|
+
// Runtime "restarts": new epoch, event ids reset to 1, fresh history.
|
|
452
|
+
bridge.epoch = 'epoch-B';
|
|
453
|
+
bridge.sessions['s-test0001'].events = [envelope(1, 'pipeline_start', { fresh: true }), envelope(2, 'narration', { text: 'post-restart' })];
|
|
454
|
+
bridge.sessions['s-test0001'].socket.terminate();
|
|
455
|
+
|
|
456
|
+
// Reconnect: our epoch-A + since goes up, hello says epoch-B → we reset
|
|
457
|
+
// and take the full replay. Without the reset, monotonic dedup would
|
|
458
|
+
// silently drop both replayed events (ids <= lastSeq).
|
|
459
|
+
await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:session:reset'), 5000);
|
|
460
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event' && b.envelope.payload?.fresh).length === 1, 5000);
|
|
461
|
+
const s = connector.status().endpoints[0].sessions[0];
|
|
462
|
+
assert.equal(s.buffered, 2); // the ring holds ONLY post-restart history
|
|
463
|
+
assert.ok(bridge.epochsSeen.includes('epoch-A')); // we did present the old epoch
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('recheck collapses a stale connected state the moment the runtime is gone', async () => {
|
|
467
|
+
connect();
|
|
468
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
469
|
+
daemon.broadcasts.length = 0;
|
|
470
|
+
|
|
471
|
+
// Runtime dies (a deliberate stop); without recheck the endpoint would
|
|
472
|
+
// stay 'connected' until the next scheduled session poll failed.
|
|
473
|
+
await bridge.close();
|
|
474
|
+
connector.recheck('local');
|
|
475
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'error');
|
|
476
|
+
// The transition broadcast rode the recheck — the GUI card moves with
|
|
477
|
+
// the verb, not with a poll.
|
|
478
|
+
assert.ok(daemon.broadcasts.some((b) => b.type === 'axom:status'));
|
|
479
|
+
});
|
|
480
|
+
|
|
438
481
|
it('reports an unreachable endpoint honestly and recovers by retry', async () => {
|
|
439
482
|
const deadUrl = bridge.url;
|
|
440
483
|
const port = Number(new URL(deadUrl).port);
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// GROOVE — Axom Runtime Model Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// The model is exercised with faked backends: what matters here is that verbs
|
|
5
|
+
// dispatch on control mode, states derive honestly, and migration folds the
|
|
6
|
+
// legacy keys without forking anyone's ledger.
|
|
7
|
+
|
|
8
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { mkdtempSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { tmpdir } from 'os';
|
|
13
|
+
import { AxomRuntimes, validateRuntime } from '../src/axom-runtimes.js';
|
|
14
|
+
|
|
15
|
+
function fakeDaemon() {
|
|
16
|
+
const calls = { remote: [], server: [], connector: [], nudges: [], rechecks: [] };
|
|
17
|
+
const broadcasts = [];
|
|
18
|
+
return {
|
|
19
|
+
calls,
|
|
20
|
+
broadcasts,
|
|
21
|
+
grooveDir: mkdtempSync(join(tmpdir(), 'groove-axrt-')),
|
|
22
|
+
config: { axom: {} },
|
|
23
|
+
broadcast(m) { broadcasts.push(m); },
|
|
24
|
+
audit: { log() {} },
|
|
25
|
+
axom: {
|
|
26
|
+
endpoints: new Map(),
|
|
27
|
+
configure(entries) { calls.connector.push(entries); },
|
|
28
|
+
nudge(name) { calls.nudges.push(name); },
|
|
29
|
+
recheck(name) { calls.rechecks.push(name); },
|
|
30
|
+
},
|
|
31
|
+
axomRemote: {
|
|
32
|
+
async status(cfg) { calls.remote.push(['status', cfg]); return fakeDaemon._remoteStatus || { running: false }; },
|
|
33
|
+
async start(cfg) { calls.remote.push(['start', cfg]); return { started: true }; },
|
|
34
|
+
async stop(o, cfg) { calls.remote.push(['stop', o, cfg]); return { stopped: true, via: 'shutdown' }; },
|
|
35
|
+
async ensureTunnel(cfg) { calls.remote.push(['tunnel', cfg]); return { tunneled: true }; },
|
|
36
|
+
},
|
|
37
|
+
axomServer: {
|
|
38
|
+
instances: [],
|
|
39
|
+
list() { return this.instances; },
|
|
40
|
+
async start(id, opts) { calls.server.push(['start', id, opts]); return { id, port: 8737, status: 'running' }; },
|
|
41
|
+
async stop(id) { calls.server.push(['stop', id]); },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SSH_RT = {
|
|
47
|
+
id: 'spark', name: 'Spark', url: 'http://127.0.0.1:8737', control: 'ssh',
|
|
48
|
+
ssh: { host: 'spark.local', user: 'axom' },
|
|
49
|
+
launch: { command: 'cd /x && PYTHONPATH=model python3 -u -m axom.cli serve --cpu' },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe('AxomRuntimes', () => {
|
|
53
|
+
let daemon, model;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
daemon = fakeDaemon();
|
|
57
|
+
model = new AxomRuntimes(daemon);
|
|
58
|
+
// The real broadcastStatus probes runtime URLs; record the push instead so
|
|
59
|
+
// these tests stay hermetic. The genuine payload has its own test below.
|
|
60
|
+
model.broadcastStatus = async () => { daemon.broadcasts.push({ type: 'axom:runtimes' }); };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('migrates the legacy endpoint+remote pair into one ssh runtime', () => {
|
|
64
|
+
daemon.config.axom = {
|
|
65
|
+
endpoints: [{ name: 'local', url: 'http://127.0.0.1:8737' }],
|
|
66
|
+
remote: { host: 'edgexpert-aaa6.local', user: 'axom', port: 8737, command: 'python3 -m axom.cli serve', logPath: '/x.log' },
|
|
67
|
+
};
|
|
68
|
+
assert.equal(model.migrate(), true);
|
|
69
|
+
const [rt] = model.list();
|
|
70
|
+
assert.equal(rt.control, 'ssh');
|
|
71
|
+
assert.equal(rt.url, 'http://127.0.0.1:8737');
|
|
72
|
+
assert.equal(rt.ssh.host, 'edgexpert-aaa6.local');
|
|
73
|
+
assert.equal(rt.launch.command, 'python3 -m axom.cli serve');
|
|
74
|
+
assert.equal(model.activeId(), rt.id);
|
|
75
|
+
assert.equal(model.migrate(), false); // idempotent
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Found in Ryan's live config: endpoints[] emptied by a disconnect, remote
|
|
79
|
+
// host still configured — the old rule dropped it and showed a first-run
|
|
80
|
+
// splash for a machine he had already set up.
|
|
81
|
+
it('migrates a configured remote host even with no endpoint entry beside it', () => {
|
|
82
|
+
daemon.config.axom = {
|
|
83
|
+
endpoints: [],
|
|
84
|
+
remote: { host: 'edgexpert-aaa6.local', user: 'axom', port: 8737, command: 'python3 -u -m axom.cli serve' },
|
|
85
|
+
};
|
|
86
|
+
model.migrate();
|
|
87
|
+
const [rt] = model.list();
|
|
88
|
+
assert.equal(rt.control, 'ssh');
|
|
89
|
+
assert.equal(rt.ssh.host, 'edgexpert-aaa6.local');
|
|
90
|
+
assert.equal(rt.url, 'http://127.0.0.1:8737');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('retries a migration that produced nothing, but never re-derives a real one', () => {
|
|
94
|
+
// An earlier build wrote an empty runtimes[] and then early-returned
|
|
95
|
+
// forever on `Array.isArray` — the host stayed stranded across restarts.
|
|
96
|
+
daemon.config.axom = {
|
|
97
|
+
runtimes: [],
|
|
98
|
+
remote: { host: 'spark.local', user: 'axom', port: 8737 },
|
|
99
|
+
};
|
|
100
|
+
assert.equal(model.migrate(), true);
|
|
101
|
+
assert.equal(model.list().length, 1);
|
|
102
|
+
// Now it is marked done: a runtime the user removes must not resurrect.
|
|
103
|
+
assert.equal(model.migrate(), false);
|
|
104
|
+
model.remove('spark');
|
|
105
|
+
assert.equal(model.migrate(), false);
|
|
106
|
+
assert.equal(model.list().length, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('migrates a lone endpoint into a connect-only runtime', () => {
|
|
110
|
+
daemon.config.axom = { endpoints: [{ name: 'other', url: 'http://127.0.0.1:9999' }] };
|
|
111
|
+
model.migrate();
|
|
112
|
+
assert.equal(model.list()[0].control, 'none');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('start dispatches by control: ssh starts remotely and heals the tunnel', async () => {
|
|
116
|
+
model.add(SSH_RT);
|
|
117
|
+
await model.startRuntime('spark');
|
|
118
|
+
const kinds = daemon.calls.remote.map((c) => c[0]);
|
|
119
|
+
assert.ok(kinds.includes('start'));
|
|
120
|
+
assert.ok(kinds.includes('tunnel')); // reachability follows lifecycle
|
|
121
|
+
const cfg = daemon.calls.remote.find((c) => c[0] === 'start')[1];
|
|
122
|
+
assert.equal(cfg.host, 'spark.local');
|
|
123
|
+
// Spec passed verbatim, with only the blessed env exported ahead of it.
|
|
124
|
+
assert.ok(cfg.command.endsWith(SSH_RT.launch.command));
|
|
125
|
+
assert.match(cfg.command, /^export AXOM_MAX_CTX='8192'; /);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// A spec must mean the same thing in every control mode. SSH previously
|
|
129
|
+
// honoured only `command`, so structured cwd/env vanished — the class of bug
|
|
130
|
+
// that boots a 2048-ctx runtime from a spec that asked for 8192.
|
|
131
|
+
it('composes launch cwd and env into the ssh command instead of dropping them', async () => {
|
|
132
|
+
model.add({
|
|
133
|
+
...SSH_RT,
|
|
134
|
+
launch: {
|
|
135
|
+
command: 'python3 -u -m axom.cli serve --cpu',
|
|
136
|
+
cwd: '/home/axom/Desktop/Axom/axom-release',
|
|
137
|
+
env: { PYTHONPATH: 'model', AXOM_MAX_CTX: '8192' },
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
await model.startRuntime('spark');
|
|
141
|
+
const { command } = daemon.calls.remote.find((c) => c[0] === 'start')[1];
|
|
142
|
+
// export before cd: a `VAR=x` prefix would bind to `cd`, not the runtime.
|
|
143
|
+
assert.match(command, /export PYTHONPATH='model';/);
|
|
144
|
+
assert.match(command, /cd '\/home\/axom\/Desktop\/Axom\/axom-release' && python3/);
|
|
145
|
+
assert.match(command, /AXOM_MAX_CTX='8192'/);
|
|
146
|
+
assert.ok(command.endsWith('python3 -u -m axom.cli serve --cpu'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('rejects launch env that is not clean name/value pairs', () => {
|
|
150
|
+
assert.ok(validateRuntime({ ...SSH_RT, launch: { command: 'x', env: ['A=1'] } }));
|
|
151
|
+
assert.ok(validateRuntime({ ...SSH_RT, launch: { command: 'x', env: { 'BAD NAME': '1' } } }));
|
|
152
|
+
assert.ok(validateRuntime({ ...SSH_RT, launch: { command: 'x', env: { A: { nested: 1 } } } }));
|
|
153
|
+
assert.equal(validateRuntime({ ...SSH_RT, launch: { command: 'x', env: { AXOM_MAX_CTX: '8192' }, cwd: '/x' } }), null);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('start dispatches by control: local spawns and adopts the resulting port as its URL', async () => {
|
|
157
|
+
model.add({ id: 'here', name: 'This machine', control: 'local', launch: { command: 'axom' }, dataDir: '/home/axom/axom-serve/data' });
|
|
158
|
+
await model.startRuntime('here');
|
|
159
|
+
const [, id, opts] = daemon.calls.server[0];
|
|
160
|
+
assert.equal(id, 'here');
|
|
161
|
+
assert.equal(opts.dataDir, '/home/axom/axom-serve/data'); // ADOPTS the ledger
|
|
162
|
+
assert.equal(model.get('here').url, 'http://127.0.0.1:8737');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('refuses lifecycle verbs on connect-only runtimes with an honest message', async () => {
|
|
166
|
+
model.add({ id: 'theirs', name: 'Theirs', url: 'http://127.0.0.1:7000', control: 'none' });
|
|
167
|
+
await assert.rejects(() => model.startRuntime('theirs'), /not controlled by GROOVE/);
|
|
168
|
+
await assert.rejects(() => model.stopRuntime('theirs'), /not controlled by GROOVE/);
|
|
169
|
+
await assert.rejects(() => model.heal('theirs'), /only ssh runtimes/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('derives ssh states honestly: tunnel-down vs stopped vs host-unreachable', async () => {
|
|
173
|
+
model.add({ ...SSH_RT, url: 'http://127.0.0.1:1' }); // nothing listens on :1
|
|
174
|
+
fakeDaemon._remoteStatus = { running: true };
|
|
175
|
+
assert.equal((await model.state('spark')).state, 'unreachable'); // up on host, tunnel down
|
|
176
|
+
fakeDaemon._remoteStatus = { running: false };
|
|
177
|
+
assert.equal((await model.state('spark')).state, 'stopped');
|
|
178
|
+
fakeDaemon._remoteStatus = { running: null, error: 'no route to host' };
|
|
179
|
+
const s = await model.state('spark');
|
|
180
|
+
assert.equal(s.state, 'unreachable');
|
|
181
|
+
assert.match(s.detail, /no route/);
|
|
182
|
+
delete fakeDaemon._remoteStatus;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reports connected when the connector has the stream', async () => {
|
|
186
|
+
model.add(SSH_RT);
|
|
187
|
+
daemon.axom.endpoints.set('spark', { status: 'connected' });
|
|
188
|
+
assert.equal((await model.state('spark')).state, 'connected');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('status exposes exactly the verbs each state supports', async () => {
|
|
192
|
+
model.add({ id: 'theirs', name: 'Theirs', url: 'http://127.0.0.1:1', control: 'none' });
|
|
193
|
+
const { runtimes } = await model.status();
|
|
194
|
+
const theirs = runtimes.find((r) => r.id === 'theirs');
|
|
195
|
+
assert.equal(theirs.canStart, false);
|
|
196
|
+
assert.equal(theirs.canStop, false);
|
|
197
|
+
assert.equal(theirs.canHeal, false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── Mono-Axom (§10) ─────────────────────────────────────────────────────
|
|
201
|
+
// One Axom per machine; hooks are sessions, never processes.
|
|
202
|
+
|
|
203
|
+
it('a hook on a running runtime mints a session and starts nothing', async () => {
|
|
204
|
+
model.add(SSH_RT);
|
|
205
|
+
daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
|
|
206
|
+
const a = await model.hook('spark');
|
|
207
|
+
const b = await model.hook('spark');
|
|
208
|
+
assert.match(a.session, /^s-/);
|
|
209
|
+
assert.notEqual(a.session, b.session); // each hook is its own recency thread
|
|
210
|
+
assert.equal(a.launched, false);
|
|
211
|
+
assert.equal(daemon.calls.remote.filter((c) => c[0] === 'start').length, 0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('a hook launches from the blessed spec only when nothing is running', async () => {
|
|
215
|
+
model.add({ ...SSH_RT, url: 'http://127.0.0.1:1' });
|
|
216
|
+
fakeDaemon._remoteStatus = { running: false };
|
|
217
|
+
const h = await model.hook('spark');
|
|
218
|
+
delete fakeDaemon._remoteStatus;
|
|
219
|
+
assert.equal(h.launched, true);
|
|
220
|
+
assert.equal(daemon.calls.remote.filter((c) => c[0] === 'start').length, 1);
|
|
221
|
+
// The blessed env rides the launch — a 2048-ctx boot is the bug it prevents.
|
|
222
|
+
assert.match(daemon.calls.remote.find((c) => c[0] === 'start')[1].command, /AXOM_MAX_CTX='8192'/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('a hook that loses the lock race joins the winner instead of erroring', async () => {
|
|
226
|
+
model.add({ ...SSH_RT, url: 'http://127.0.0.1:1' });
|
|
227
|
+
let probes = 0;
|
|
228
|
+
fakeDaemon._remoteStatus = { running: false };
|
|
229
|
+
daemon.axomRemote.start = async () => { throw new Error('another instance holds the data-dir lock'); };
|
|
230
|
+
// The loser re-derives: by the time it asks again, the winner is up.
|
|
231
|
+
const realState = model.state.bind(model);
|
|
232
|
+
model.state = async (id) => (++probes >= 2 ? { state: 'connected', detail: null } : realState(id));
|
|
233
|
+
const h = await model.hook('spark');
|
|
234
|
+
delete fakeDaemon._remoteStatus;
|
|
235
|
+
assert.equal(h.launched, false);
|
|
236
|
+
assert.equal(h.wonBy, undefined); // hook() reports the session, not the race
|
|
237
|
+
assert.match(h.session, /^s-/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('a user-set env value beats the blessed default — GROOVE never edits a spec', async () => {
|
|
241
|
+
model.add({ ...SSH_RT, launch: { command: 'serve', env: { AXOM_MAX_CTX: '4096' } } });
|
|
242
|
+
await model.startRuntime('spark');
|
|
243
|
+
const { command } = daemon.calls.remote.find((c) => c[0] === 'start')[1];
|
|
244
|
+
assert.match(command, /AXOM_MAX_CTX='4096'/);
|
|
245
|
+
assert.doesNotMatch(command, /8192/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('reports the shared generation slot so concurrent hooks can queue honestly', async () => {
|
|
249
|
+
model.add(SSH_RT);
|
|
250
|
+
const sessions = new Map([
|
|
251
|
+
['s-one', { id: 's-one', live: true }],
|
|
252
|
+
['s-two', { id: 's-two', live: false }],
|
|
253
|
+
]);
|
|
254
|
+
daemon.axom.endpoints.set('spark', { status: 'connected', sessions });
|
|
255
|
+
const { runtimes } = await model.status();
|
|
256
|
+
assert.equal(runtimes[0].generationBusy, true);
|
|
257
|
+
assert.equal(runtimes[0].generationHolder, 's-one');
|
|
258
|
+
sessions.get('s-one').live = false;
|
|
259
|
+
assert.equal(model.generation('spark').generationBusy, false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('refuses to hook a runtime on someone else\'s machine with an honest sentence', async () => {
|
|
263
|
+
model.add({ id: 'theirs', name: 'Theirs', url: 'http://127.0.0.1:1', control: 'none' });
|
|
264
|
+
await assert.rejects(() => model.hook('theirs'), /runs on another machine/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ── Chats — the persistent hook list ────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
it('records every hook as a chat that survives a reload', async () => {
|
|
270
|
+
model.add(SSH_RT);
|
|
271
|
+
daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
|
|
272
|
+
const a = await model.hook('spark', { label: 'Research' });
|
|
273
|
+
await model.hook('spark');
|
|
274
|
+
assert.equal(a.label, 'Research');
|
|
275
|
+
assert.equal(model.chats().length, 2);
|
|
276
|
+
// A fresh model over the same config sees them — the list is daemon-side.
|
|
277
|
+
assert.equal(new AxomRuntimes(daemon).chats().length, 2);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('deleting a chat hides it and never touches the conversation', async () => {
|
|
281
|
+
model.add(SSH_RT);
|
|
282
|
+
daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
|
|
283
|
+
const { session } = await model.hook('spark', { label: 'Scratch' });
|
|
284
|
+
const result = model.hideChat(session);
|
|
285
|
+
assert.equal(model.chats().length, 0);
|
|
286
|
+
assert.match(result.note, /remains in Axom's memory/);
|
|
287
|
+
// The row is REMEMBERED as hidden, so rejoining the same session — which
|
|
288
|
+
// the connector's /sessions poll will keep reporting — can't resurrect it.
|
|
289
|
+
await model.hook('spark', { session });
|
|
290
|
+
assert.equal(model.chats().length, 0);
|
|
291
|
+
assert.equal(model.getChat(session).hidden, true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('names the generation holder only when it is a chat we minted', async () => {
|
|
295
|
+
model.add(SSH_RT);
|
|
296
|
+
const sessions = new Map();
|
|
297
|
+
daemon.axom.endpoints.set('spark', { status: 'connected', sessions });
|
|
298
|
+
const { session } = await model.hook('spark', { label: 'Research' });
|
|
299
|
+
sessions.set(session, { id: session, live: true });
|
|
300
|
+
assert.equal(model.generation('spark').generationHolderLabel, 'Research');
|
|
301
|
+
// A session opened elsewhere (REPL, another client) gets no invented name.
|
|
302
|
+
sessions.clear();
|
|
303
|
+
sessions.set('s-foreign', { id: 's-foreign', live: true });
|
|
304
|
+
assert.equal(model.generation('spark').generationHolder, 's-foreign');
|
|
305
|
+
assert.equal(model.generation('spark').generationHolderLabel, null);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('keeps the connector in sync with the runtime list', () => {
|
|
309
|
+
model.add(SSH_RT);
|
|
310
|
+
model.remove('spark');
|
|
311
|
+
const last = daemon.calls.connector.pop();
|
|
312
|
+
assert.deepEqual(last, []);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Axom-UX flag 1: the GUI's runtime cards must move on events, not a poll.
|
|
316
|
+
it('pushes fresh runtime state on every mutation and lifecycle verb', async () => {
|
|
317
|
+
const seen = () => daemon.broadcasts.filter((b) => b.type === 'axom:runtimes').length;
|
|
318
|
+
model.add(SSH_RT);
|
|
319
|
+
assert.ok(seen() >= 1);
|
|
320
|
+
let n = seen();
|
|
321
|
+
model.update('spark', { name: 'Spark 2' });
|
|
322
|
+
assert.ok(seen() > n);
|
|
323
|
+
n = seen();
|
|
324
|
+
model.activate('spark');
|
|
325
|
+
assert.ok(seen() > n);
|
|
326
|
+
n = seen();
|
|
327
|
+
await model.startRuntime('spark');
|
|
328
|
+
assert.ok(seen() > n);
|
|
329
|
+
// Axom-UX flag 3: start pulls the connector in NOW — 'running' must not
|
|
330
|
+
// linger for a backoff cycle before becoming 'connected'.
|
|
331
|
+
assert.deepEqual(daemon.calls.nudges, ['spark']);
|
|
332
|
+
n = seen();
|
|
333
|
+
await model.stopRuntime('spark');
|
|
334
|
+
assert.ok(seen() > n);
|
|
335
|
+
// ...and stop re-probes so a stale 'connected' collapses with the verb.
|
|
336
|
+
assert.deepEqual(daemon.calls.rechecks, ['spark']);
|
|
337
|
+
n = seen();
|
|
338
|
+
model.remove('spark');
|
|
339
|
+
assert.ok(seen() > n);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('broadcastStatus emits the full axom:runtimes payload', async () => {
|
|
343
|
+
const d = fakeDaemon();
|
|
344
|
+
const m = new AxomRuntimes(d);
|
|
345
|
+
m.add({ id: 'here', name: 'This machine', control: 'local' }); // no URL → no probe
|
|
346
|
+
const deadline = Date.now() + 2000;
|
|
347
|
+
while (!d.broadcasts.some((b) => b.type === 'axom:runtimes')) {
|
|
348
|
+
if (Date.now() > deadline) throw new Error('no axom:runtimes broadcast');
|
|
349
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
350
|
+
}
|
|
351
|
+
const msg = d.broadcasts.find((b) => b.type === 'axom:runtimes');
|
|
352
|
+
assert.equal(msg.data.activeRuntimeId, 'here');
|
|
353
|
+
assert.equal(msg.data.runtimes[0].state, 'stopped');
|
|
354
|
+
assert.equal(msg.data.runtimes[0].canStart, true);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('validateRuntime', () => {
|
|
359
|
+
it('accepts the three shapes and rejects garbage', () => {
|
|
360
|
+
assert.equal(validateRuntime(SSH_RT), null);
|
|
361
|
+
assert.equal(validateRuntime({ id: 'x', name: 'X', control: 'local' }), null); // url comes from spawn
|
|
362
|
+
assert.equal(validateRuntime({ id: 'y', name: 'Y', url: 'http://127.0.0.1:1', control: 'none' }), null);
|
|
363
|
+
assert.ok(validateRuntime({ id: 'z', name: 'Z', control: 'teleport' }));
|
|
364
|
+
assert.ok(validateRuntime({ id: 'z', name: 'Z', control: 'none' })); // none requires url
|
|
365
|
+
assert.ok(validateRuntime({ id: 'z', name: 'Z', control: 'ssh', url: 'http://127.0.0.1:1', ssh: { host: 'h;rm', user: 'u' } }));
|
|
366
|
+
});
|
|
367
|
+
});
|