groove-dev 0.27.208 → 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-runtimes.js +204 -7
- package/node_modules/@groove-dev/daemon/src/axom-server.js +3 -1
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +40 -0
- package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +170 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-3Lzv2-To.js → index-217ZVIOc.js} +240 -235
- 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-runtimes.js +204 -7
- package/packages/daemon/src/axom-server.js +3 -1
- package/packages/daemon/src/routes/axom.js +40 -0
- package/packages/gui/dist/assets/{index-3Lzv2-To.js → index-217ZVIOc.js} +240 -235
- 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-Bh_HF8ed.css +0 -1
- package/packages/gui/dist/assets/index-Bh_HF8ed.css +0 -1
|
@@ -20,6 +20,25 @@ import { validateEndpoint } from './axom-connector.js';
|
|
|
20
20
|
import { validateRemote } from './axom-remote.js';
|
|
21
21
|
import { saveConfig } from './firstrun.js';
|
|
22
22
|
|
|
23
|
+
// The blessed launch spec's env (SPARK_DEV_SETUP.md / §10). These change
|
|
24
|
+
// runtime BEHAVIOUR, not just paths — a spec missing AXOM_MAX_CTX boots a
|
|
25
|
+
// 2048-ctx runtime that looks fine and answers worse. Applied as DEFAULTS
|
|
26
|
+
// only: a value the user set in their own spec always wins, because "GROOVE
|
|
27
|
+
// never edits a spec's flags" is the standing ruling.
|
|
28
|
+
export const BLESSED_ENV = { AXOM_MAX_CTX: '8192' };
|
|
29
|
+
|
|
30
|
+
function withBlessedEnv(launch) {
|
|
31
|
+
if (!launch?.command) return launch;
|
|
32
|
+
return { ...launch, env: { ...BLESSED_ENV, ...(launch.env || {}) } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Single-quote for a POSIX shell. The spec is the user's own, but it crosses
|
|
36
|
+
// an ssh command line — an unquoted path or value must not be able to end the
|
|
37
|
+
// command and start another.
|
|
38
|
+
function shellQuote(s) {
|
|
39
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
export function validateRuntime(rt) {
|
|
24
43
|
if (!rt || typeof rt !== 'object') return 'runtime must be an object';
|
|
25
44
|
if (!rt.id || !/^[a-zA-Z0-9_-]{1,40}$/.test(rt.id)) return 'invalid runtime id';
|
|
@@ -39,6 +58,18 @@ export function validateRuntime(rt) {
|
|
|
39
58
|
|| rt.launch.command.length === 0 || rt.launch.command.length > 500) {
|
|
40
59
|
return 'launch.command must be a non-empty string of at most 500 chars';
|
|
41
60
|
}
|
|
61
|
+
if (rt.launch.cwd !== undefined && typeof rt.launch.cwd !== 'string') {
|
|
62
|
+
return 'launch.cwd must be a string';
|
|
63
|
+
}
|
|
64
|
+
if (rt.launch.env !== undefined) {
|
|
65
|
+
if (typeof rt.launch.env !== 'object' || rt.launch.env === null || Array.isArray(rt.launch.env)) {
|
|
66
|
+
return 'launch.env must be an object of name/value pairs';
|
|
67
|
+
}
|
|
68
|
+
for (const [k, v] of Object.entries(rt.launch.env)) {
|
|
69
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) return `invalid env var name "${k}"`;
|
|
70
|
+
if (typeof v !== 'string' && typeof v !== 'number') return `env var "${k}" must be a string or number`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
42
73
|
}
|
|
43
74
|
return null;
|
|
44
75
|
}
|
|
@@ -74,13 +105,26 @@ export class AxomRuntimes {
|
|
|
74
105
|
// are kept until the model proves out — compat routes still read them.
|
|
75
106
|
migrate() {
|
|
76
107
|
const cfg = this._cfg();
|
|
77
|
-
|
|
108
|
+
// Marker, not mere presence of the array: an earlier migration could write
|
|
109
|
+
// an EMPTY runtimes[] and then never retry, stranding a configured host
|
|
110
|
+
// behind a first-run splash forever. Re-run until it produces something or
|
|
111
|
+
// there is genuinely nothing legacy left to fold.
|
|
112
|
+
if (cfg.runtimesMigrated) return false;
|
|
113
|
+
if (Array.isArray(cfg.runtimes) && cfg.runtimes.length) {
|
|
114
|
+
cfg.runtimesMigrated = true;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
78
117
|
const runtimes = [];
|
|
79
118
|
const remote = cfg.remote || null;
|
|
80
119
|
const ep = (cfg.endpoints || [])[0] || null;
|
|
81
|
-
|
|
120
|
+
// A remote host is a configured runtime whether or not an endpoint entry
|
|
121
|
+
// survives beside it — the endpoint list gets cleared by a disconnect, and
|
|
122
|
+
// dropping the host on that basis would strand a machine the user set up
|
|
123
|
+
// and show them a first-run splash instead.
|
|
124
|
+
if (remote?.host && (!ep || ep.url.endsWith(`:${remote.port || 8737}`))) {
|
|
82
125
|
runtimes.push({
|
|
83
|
-
id: 'spark', name: remote.host.split('.')[0] || 'Remote',
|
|
126
|
+
id: 'spark', name: remote.host.split('.')[0] || 'Remote',
|
|
127
|
+
url: ep?.url || `http://127.0.0.1:${remote.port || 8737}`,
|
|
84
128
|
control: 'ssh',
|
|
85
129
|
ssh: { host: remote.host, user: remote.user, sshPort: remote.sshPort, autoTunnel: true },
|
|
86
130
|
launch: remote.command ? { command: remote.command } : undefined,
|
|
@@ -92,7 +136,10 @@ export class AxomRuntimes {
|
|
|
92
136
|
runtimes.push({ id: ep.name || 'axom', name, url: ep.url, control: 'none' });
|
|
93
137
|
}
|
|
94
138
|
cfg.runtimes = runtimes;
|
|
95
|
-
if (runtimes.length
|
|
139
|
+
if (runtimes.length) {
|
|
140
|
+
cfg.runtimesMigrated = true;
|
|
141
|
+
if (!cfg.activeRuntimeId) cfg.activeRuntimeId = runtimes[0].id;
|
|
142
|
+
}
|
|
96
143
|
return true;
|
|
97
144
|
}
|
|
98
145
|
|
|
@@ -101,7 +148,9 @@ export class AxomRuntimes {
|
|
|
101
148
|
}
|
|
102
149
|
|
|
103
150
|
start() {
|
|
104
|
-
|
|
151
|
+
// Persist it: an unsaved migration re-derives on every boot, so a runtime
|
|
152
|
+
// the user later removed would come back from the legacy keys each time.
|
|
153
|
+
if (this.migrate()) this._save();
|
|
105
154
|
this._syncConnector();
|
|
106
155
|
}
|
|
107
156
|
|
|
@@ -231,20 +280,168 @@ export class AxomRuntimes {
|
|
|
231
280
|
canStart: rt.control !== 'none' && derived.state === 'stopped',
|
|
232
281
|
canStop: rt.control !== 'none' && (derived.state === 'connected' || derived.state === 'running'),
|
|
233
282
|
canHeal: rt.control === 'ssh' && derived.state === 'unreachable',
|
|
283
|
+
...this.generation(rt.id),
|
|
234
284
|
};
|
|
235
285
|
}));
|
|
236
286
|
return { runtimes, activeRuntimeId: this.activeId() };
|
|
237
287
|
}
|
|
238
288
|
|
|
289
|
+
// ── Mono-Axom (§10) ───────────────────────────────────────────────────────
|
|
290
|
+
//
|
|
291
|
+
// One Axom per user per machine. Every hook — a selector entry, a tab, a new
|
|
292
|
+
// chat — is a fresh SESSION on the one runtime, never a second process. The
|
|
293
|
+
// §14 lockfile is the enforcement mechanism, so racing hooks are safe: the
|
|
294
|
+
// loser is refused cleanly and joins the winner's runtime.
|
|
295
|
+
|
|
296
|
+
// Until multi-sequence lands, hooks share ONE generation slot. Concurrent
|
|
297
|
+
// work queues, and the UI is required to say so rather than looking hung.
|
|
298
|
+
generation(id) {
|
|
299
|
+
const ep = this.daemon.axom.endpoints.get(id);
|
|
300
|
+
const busySession = (ep?.sessions ? [...ep.sessions.values()] : []).find((s) => s.live);
|
|
301
|
+
return {
|
|
302
|
+
generationBusy: !!busySession,
|
|
303
|
+
generationHolder: busySession?.id || null,
|
|
304
|
+
// The holder's human name, if it is a chat this GROOVE minted. A session
|
|
305
|
+
// opened elsewhere (the REPL, another client) has none — the UI says
|
|
306
|
+
// "another session" rather than inventing one.
|
|
307
|
+
generationHolderLabel: busySession ? (this.getChat(busySession.id)?.label || null) : null,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Chats — the persistent hook list ─────────────────────────────────────
|
|
312
|
+
//
|
|
313
|
+
// A chat is a named hook. It lives in daemon config, not the browser: tunnel
|
|
314
|
+
// ports move, tabs reload, and a chat list that evaporates on refresh reads
|
|
315
|
+
// as data loss even though the ledger kept everything.
|
|
316
|
+
|
|
317
|
+
chats() {
|
|
318
|
+
return (this._cfg().chats || []).filter((c) => !c.hidden);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
getChat(session) {
|
|
322
|
+
return (this._cfg().chats || []).find((c) => c.session === session) || null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
_putChat(chat) {
|
|
326
|
+
const all = this._cfg().chats || [];
|
|
327
|
+
const i = all.findIndex((c) => c.session === chat.session);
|
|
328
|
+
this._cfg().chats = i >= 0 ? all.map((c) => (c.session === chat.session ? chat : c)) : [...all, chat];
|
|
329
|
+
this._save();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
renameChat(session, label) {
|
|
333
|
+
const chat = this.getChat(session);
|
|
334
|
+
if (!chat) throw new Error(`no chat "${session}"`);
|
|
335
|
+
if (typeof label !== 'string' || !label.trim() || label.length > 80) {
|
|
336
|
+
throw new Error('label must be a non-empty string of at most 80 chars');
|
|
337
|
+
}
|
|
338
|
+
this._putChat({ ...chat, label: label.trim() });
|
|
339
|
+
this.broadcastChats();
|
|
340
|
+
return this.getChat(session);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Hide, never delete. The conversation lives in the runtime's ledger and is
|
|
344
|
+
// the user's memory — GROOVE tidying its own list must never be able to
|
|
345
|
+
// destroy it. The session id is REMEMBERED so the connector's /sessions poll
|
|
346
|
+
// can't resurrect the row the user just cleared away.
|
|
347
|
+
hideChat(session) {
|
|
348
|
+
const chat = this.getChat(session);
|
|
349
|
+
if (!chat) throw new Error(`no chat "${session}"`);
|
|
350
|
+
this._putChat({ ...chat, hidden: true });
|
|
351
|
+
this.broadcastChats();
|
|
352
|
+
return { hidden: true, session, note: 'removed from the list; the conversation remains in Axom\'s memory' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
broadcastChats() {
|
|
356
|
+
this.daemon.broadcast({ type: 'axom:chats', data: { chats: this.chats() } });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Idempotent by design: if the runtime already answers, this is a no-op. We
|
|
360
|
+
// never start a second process to satisfy a hook.
|
|
361
|
+
async ensureRunning(id) {
|
|
362
|
+
const { state } = await this.state(id);
|
|
363
|
+
if (state === 'connected' || state === 'running') return { started: false, alreadyRunning: true };
|
|
364
|
+
const rt = this.get(id);
|
|
365
|
+
if (rt.control === 'none') {
|
|
366
|
+
throw new Error(`"${rt.name}" runs on another machine — start it there, then hook in`);
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const result = await this.startRuntime(id);
|
|
370
|
+
return { ...result, started: result.started !== false };
|
|
371
|
+
} catch (err) {
|
|
372
|
+
// A racing hook that lost the §14 lock has NOT failed: the runtime it
|
|
373
|
+
// wanted is up, someone else just got there first. Re-derive rather
|
|
374
|
+
// than surfacing a lock error the user can do nothing about.
|
|
375
|
+
const after = await this.state(id);
|
|
376
|
+
if (after.state === 'connected' || after.state === 'running') {
|
|
377
|
+
return { started: false, alreadyRunning: true, wonBy: 'another hook' };
|
|
378
|
+
}
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async hook(id, { session, label } = {}) {
|
|
384
|
+
const rt = this.get(id || this.activeId());
|
|
385
|
+
if (!rt) throw new Error('no Axom runtime configured');
|
|
386
|
+
const launch = await this.ensureRunning(rt.id);
|
|
387
|
+
// §9: a hook mints its own session id — its own recency thread under the
|
|
388
|
+
// one identity. Callers may pass one to rejoin an existing thread.
|
|
389
|
+
const sessionId = session || `s-${Math.random().toString(36).slice(2, 10)}`;
|
|
390
|
+
// Persist the hook as a chat so the list survives a refresh. Rejoining an
|
|
391
|
+
// existing session must NOT un-hide a chat the user cleared away.
|
|
392
|
+
const existing = this.getChat(sessionId);
|
|
393
|
+
if (!existing) {
|
|
394
|
+
this._putChat({
|
|
395
|
+
session: sessionId,
|
|
396
|
+
runtimeId: rt.id,
|
|
397
|
+
label: label || `Chat ${this.chats().length + 1}`,
|
|
398
|
+
createdAt: Date.now(),
|
|
399
|
+
});
|
|
400
|
+
} else if (label && !existing.hidden) {
|
|
401
|
+
this._putChat({ ...existing, label });
|
|
402
|
+
}
|
|
403
|
+
this.broadcastChats();
|
|
404
|
+
this.broadcastStatus();
|
|
405
|
+
return {
|
|
406
|
+
runtimeId: rt.id,
|
|
407
|
+
name: rt.name,
|
|
408
|
+
url: rt.url,
|
|
409
|
+
session: sessionId,
|
|
410
|
+
label: this.getChat(sessionId)?.label || null,
|
|
411
|
+
launched: !!launch.started,
|
|
412
|
+
...this.generation(rt.id),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
239
416
|
// ── Verbs — dispatch on control, never guess ─────────────────────────────
|
|
240
417
|
|
|
418
|
+
// A launch spec must mean exactly one thing in every control mode. The local
|
|
419
|
+
// path gets {cwd, env} as real spawn options; SSH has only a command string,
|
|
420
|
+
// so compose them INTO it here rather than dropping them — a spec whose env
|
|
421
|
+
// is silently ignored launches a subtly different runtime (wrong context
|
|
422
|
+
// window, wrong tree) while reporting success. Found in the wild: a spec
|
|
423
|
+
// without AXOM_MAX_CTX booted a 2048-ctx instance.
|
|
424
|
+
_sshCommand(rt) {
|
|
425
|
+
const launch = withBlessedEnv(rt.launch);
|
|
426
|
+
if (!launch?.command) return undefined;
|
|
427
|
+
const parts = [];
|
|
428
|
+
// `export`, not a `VAR=x prog` prefix: real specs are COMPOUND shell lines
|
|
429
|
+
// ("cd /x && prog"), and a prefix binds only to the first word — the var
|
|
430
|
+
// would decorate `cd` and never reach the runtime. Caught by its own test.
|
|
431
|
+
for (const [k, v] of Object.entries(launch.env || {})) {
|
|
432
|
+
parts.push(`export ${k}=${shellQuote(String(v))}; `);
|
|
433
|
+
}
|
|
434
|
+
if (launch.cwd) parts.push(`cd ${shellQuote(launch.cwd)} && `);
|
|
435
|
+
return `${parts.join('')}${launch.command}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
241
438
|
_sshCfg(rt) {
|
|
242
439
|
let port = 8737;
|
|
243
440
|
try { port = Number(new URL(rt.url).port) || 8737; } catch { /* default */ }
|
|
244
441
|
return {
|
|
245
442
|
...rt.ssh,
|
|
246
443
|
port,
|
|
247
|
-
command: rt
|
|
444
|
+
command: this._sshCommand(rt),
|
|
248
445
|
logPath: rt.logPath,
|
|
249
446
|
};
|
|
250
447
|
}
|
|
@@ -263,7 +460,7 @@ export class AxomRuntimes {
|
|
|
263
460
|
}
|
|
264
461
|
// local: the spawned port becomes the runtime's URL.
|
|
265
462
|
const instance = await this.daemon.axomServer.start(rt.id, {
|
|
266
|
-
launch: rt.launch,
|
|
463
|
+
launch: withBlessedEnv(rt.launch) || { env: { ...BLESSED_ENV } },
|
|
267
464
|
dataDir: rt.dataDir,
|
|
268
465
|
});
|
|
269
466
|
this.update(id, { url: `http://127.0.0.1:${instance.port}` });
|
|
@@ -151,7 +151,9 @@ export class AxomServerManager {
|
|
|
151
151
|
this.instances.set(id, instance);
|
|
152
152
|
this._broadcast();
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
// A spec may carry env/cwd without naming a command (the caller wanted the
|
|
155
|
+
// configured binary plus the blessed env) — fill the command, keep the rest.
|
|
156
|
+
const launch = { ...opts.launch, command: opts.launch?.command || this._command() };
|
|
155
157
|
let proc;
|
|
156
158
|
try {
|
|
157
159
|
if (launch.cwd || launch.env || /\s/.test(launch.command)) {
|
|
@@ -158,6 +158,46 @@ export function registerAxomRoutes(app, daemon) {
|
|
|
158
158
|
}
|
|
159
159
|
});
|
|
160
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
|
+
|
|
161
201
|
app.post('/api/axom/runtimes/:id/activate', (req, res) => {
|
|
162
202
|
try {
|
|
163
203
|
daemon.axomRuntimes.activate(req.params.id);
|
|
@@ -75,6 +75,37 @@ describe('AxomRuntimes', () => {
|
|
|
75
75
|
assert.equal(model.migrate(), false); // idempotent
|
|
76
76
|
});
|
|
77
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
|
+
|
|
78
109
|
it('migrates a lone endpoint into a connect-only runtime', () => {
|
|
79
110
|
daemon.config.axom = { endpoints: [{ name: 'other', url: 'http://127.0.0.1:9999' }] };
|
|
80
111
|
model.migrate();
|
|
@@ -89,7 +120,37 @@ describe('AxomRuntimes', () => {
|
|
|
89
120
|
assert.ok(kinds.includes('tunnel')); // reachability follows lifecycle
|
|
90
121
|
const cfg = daemon.calls.remote.find((c) => c[0] === 'start')[1];
|
|
91
122
|
assert.equal(cfg.host, 'spark.local');
|
|
92
|
-
|
|
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);
|
|
93
154
|
});
|
|
94
155
|
|
|
95
156
|
it('start dispatches by control: local spawns and adopts the resulting port as its URL', async () => {
|
|
@@ -136,6 +197,114 @@ describe('AxomRuntimes', () => {
|
|
|
136
197
|
assert.equal(theirs.canHeal, false);
|
|
137
198
|
});
|
|
138
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
|
+
|
|
139
308
|
it('keeps the connector in sync with the runtime list', () => {
|
|
140
309
|
model.add(SSH_RT);
|
|
141
310
|
model.remove('spark');
|