groove-dev 0.27.207 → 0.27.208
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 +301 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +23 -4
- package/node_modules/@groove-dev/daemon/src/index.js +3 -1
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -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 +198 -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-3Lzv2-To.js} +234 -234
- package/node_modules/@groove-dev/gui/dist/assets/index-Bh_HF8ed.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 +301 -0
- package/packages/daemon/src/axom-server.js +23 -4
- package/packages/daemon/src/index.js +3 -1
- package/packages/daemon/src/routes/axom.js +73 -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-3Lzv2-To.js} +234 -234
- package/packages/gui/dist/assets/index-Bh_HF8ed.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
|
@@ -186,6 +186,7 @@ export class AxomConnector {
|
|
|
186
186
|
live: !!info.live,
|
|
187
187
|
ws: null,
|
|
188
188
|
lastSeq: 0,
|
|
189
|
+
epoch: null,
|
|
189
190
|
ring: [],
|
|
190
191
|
overflow: 0,
|
|
191
192
|
unknownKinds: {},
|
|
@@ -209,14 +210,35 @@ export class AxomConnector {
|
|
|
209
210
|
|
|
210
211
|
_watchSession(ep, s) {
|
|
211
212
|
if (this.destroyed) return;
|
|
212
|
-
|
|
213
|
-
|
|
213
|
+
// §16.4: reconnects carry both cursor and epoch; a stale epoch voids the
|
|
214
|
+
// cursor runtime-side and we get a full replay instead of a silent gap.
|
|
215
|
+
const params = [];
|
|
216
|
+
if (s.lastSeq > 0) params.push(`since=ev-${String(s.lastSeq).padStart(6, '0')}`);
|
|
217
|
+
if (s.epoch) params.push(`epoch=${encodeURIComponent(s.epoch)}`);
|
|
218
|
+
const query = params.length ? `?${params.join('&')}` : '';
|
|
219
|
+
const wsUrl = `${ep.url.replace(/^http/, 'ws')}/ws/session/${encodeURIComponent(s.id)}${query}`;
|
|
214
220
|
const ws = new WebSocket(wsUrl);
|
|
215
221
|
s.ws = ws;
|
|
216
222
|
|
|
217
223
|
ws.on('message', (data) => {
|
|
218
224
|
let envelope;
|
|
219
225
|
try { envelope = JSON.parse(data.toString()); } catch { return; }
|
|
226
|
+
// §16.4 handshake frame — transport metadata, never a transcript event.
|
|
227
|
+
// A changed epoch means the runtime restarted and its event ids reset:
|
|
228
|
+
// our monotonic dedup would silently swallow the entire replay, so the
|
|
229
|
+
// cursor, ring, and GUI copy are all voided together.
|
|
230
|
+
if (envelope.kind === 'ws_hello') {
|
|
231
|
+
const epoch = envelope.payload?.epoch;
|
|
232
|
+
if (epoch && s.epoch && epoch !== s.epoch) {
|
|
233
|
+
s.lastSeq = 0;
|
|
234
|
+
s.ring = [];
|
|
235
|
+
s.overflow = 0;
|
|
236
|
+
s.unknownKinds = {};
|
|
237
|
+
this.daemon.broadcast({ type: 'axom:session:reset', endpoint: ep.name, session: s.id, epoch });
|
|
238
|
+
}
|
|
239
|
+
if (epoch) s.epoch = epoch;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
220
242
|
const seq = seqOf(envelope.id);
|
|
221
243
|
// Dedup on ring-buffer replay after reconnect — ids are monotonic.
|
|
222
244
|
if (seq !== null && seq <= s.lastSeq) return;
|
|
@@ -371,6 +393,32 @@ export class AxomConnector {
|
|
|
371
393
|
|
|
372
394
|
_broadcastStatus() {
|
|
373
395
|
this.daemon.broadcast({ type: 'axom:status', data: this.status() });
|
|
396
|
+
// Runtime-level state is derived from connector state — push it too so
|
|
397
|
+
// the GUI's runtime cards move on events, not on a poll.
|
|
398
|
+
this.daemon.axomRuntimes?.broadcastStatus?.();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Collapse the 'running but not connected' window: when a runtime is known
|
|
402
|
+
// to be answering /about, don't make the user wait out our backoff.
|
|
403
|
+
nudge(name) {
|
|
404
|
+
const ep = this.endpoints.get(name);
|
|
405
|
+
if (!ep || ep.status === 'connected' || this.destroyed) return;
|
|
406
|
+
if (ep.retryTimer) { clearTimeout(ep.retryTimer); ep.retryTimer = null; }
|
|
407
|
+
ep.backoffMs = this.backoffBaseMs;
|
|
408
|
+
this._handshake(ep);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// The inverse of nudge: after a deliberate stop, 'connected' is presumed
|
|
412
|
+
// stale — re-probe now so the endpoint transitions (and broadcasts) with the
|
|
413
|
+
// verb instead of lingering until the next scheduled poll fails.
|
|
414
|
+
recheck(name) {
|
|
415
|
+
const ep = this.endpoints.get(name);
|
|
416
|
+
if (!ep || this.destroyed) return;
|
|
417
|
+
if (ep.status === 'connected') {
|
|
418
|
+
this._pollSessions(ep).catch(() => this._endpointLost(ep));
|
|
419
|
+
} else {
|
|
420
|
+
this.nudge(name);
|
|
421
|
+
}
|
|
374
422
|
}
|
|
375
423
|
|
|
376
424
|
_teardownEndpoint(ep) {
|
|
@@ -55,8 +55,8 @@ export class AxomRemote {
|
|
|
55
55
|
return this.daemon.config?.axom?.remote || null;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
_ssh(command, timeoutMs = 30000) {
|
|
59
|
-
const cfg = this._config();
|
|
58
|
+
_ssh(command, timeoutMs = 30000, cfgOverride = null) {
|
|
59
|
+
const cfg = cfgOverride || this._config();
|
|
60
60
|
if (!cfg) return Promise.reject(new Error('no remote Axom host configured'));
|
|
61
61
|
const problem = validateRemote(cfg);
|
|
62
62
|
if (problem) return Promise.reject(new Error(`remote config invalid: ${problem}`));
|
|
@@ -71,14 +71,15 @@ export class AxomRemote {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// Is a runtime listening on the remote port right now?
|
|
74
|
-
async status() {
|
|
75
|
-
const cfg = this._config();
|
|
74
|
+
async status(cfgOverride = null) {
|
|
75
|
+
const cfg = cfgOverride || this._config();
|
|
76
76
|
if (!cfg) return { configured: false, running: null };
|
|
77
77
|
const port = cfg.port || AXOM_DEFAULT_PORT;
|
|
78
78
|
try {
|
|
79
79
|
const { stdout } = await this._ssh(
|
|
80
80
|
`curl -sf --max-time 4 http://127.0.0.1:${port}/about >/dev/null 2>&1 && echo UP || echo DOWN`,
|
|
81
81
|
15000,
|
|
82
|
+
cfg,
|
|
82
83
|
);
|
|
83
84
|
return {
|
|
84
85
|
configured: true,
|
|
@@ -94,11 +95,11 @@ export class AxomRemote {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
async start() {
|
|
98
|
-
const cfg = this._config();
|
|
98
|
+
async start(cfgOverride = null) {
|
|
99
|
+
const cfg = cfgOverride || this._config();
|
|
99
100
|
if (!cfg) throw new Error('no remote Axom host configured');
|
|
100
101
|
const port = cfg.port || AXOM_DEFAULT_PORT;
|
|
101
|
-
const already = await this.status();
|
|
102
|
+
const already = await this.status(cfg);
|
|
102
103
|
if (already.running) return { started: false, alreadyRunning: true, port };
|
|
103
104
|
|
|
104
105
|
const command = cfg.command
|
|
@@ -112,12 +113,12 @@ export class AxomRemote {
|
|
|
112
113
|
// NOTHING while reporting success. Found by running it.
|
|
113
114
|
const log = cfg.logPath || '~/axom-serve/serve.log';
|
|
114
115
|
const quoted = `'${command.replace(/'/g, `'\\''`)}'`;
|
|
115
|
-
await this._ssh(`nohup bash -lc ${quoted} >> ${log} 2>&1 < /dev/null & disown; echo STARTED`, 30000);
|
|
116
|
+
await this._ssh(`nohup bash -lc ${quoted} >> ${log} 2>&1 < /dev/null & disown; echo STARTED`, 30000, cfg);
|
|
116
117
|
|
|
117
118
|
// Confirm it actually came up rather than reporting optimism.
|
|
118
119
|
for (let i = 0; i < 30; i++) {
|
|
119
120
|
await new Promise((r) => setTimeout(r, 2000));
|
|
120
|
-
const s = await this.status();
|
|
121
|
+
const s = await this.status(cfg);
|
|
121
122
|
if (s.running) {
|
|
122
123
|
this.daemon.audit.log('axom.remote.start', { host: cfg.host, port });
|
|
123
124
|
this._broadcast();
|
|
@@ -127,8 +128,8 @@ export class AxomRemote {
|
|
|
127
128
|
throw new Error(`started the command but nothing answered on port ${port} within 60s — check ${log} on ${cfg.host}`);
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
async stop({ force = false } = {}) {
|
|
131
|
-
const cfg = this._config();
|
|
131
|
+
async stop({ force = false } = {}, cfgOverride = null) {
|
|
132
|
+
const cfg = cfgOverride || this._config();
|
|
132
133
|
if (!cfg) throw new Error('no remote Axom host configured');
|
|
133
134
|
const port = cfg.port || AXOM_DEFAULT_PORT;
|
|
134
135
|
|
|
@@ -141,6 +142,7 @@ export class AxomRemote {
|
|
|
141
142
|
+ `-H 'Content-Type: application/json' -d '{"force":${force ? 'true' : 'false'}}' `
|
|
142
143
|
+ `http://127.0.0.1:${port}/shutdown`,
|
|
143
144
|
20000,
|
|
145
|
+
cfg,
|
|
144
146
|
);
|
|
145
147
|
const code = parseInt(stdout.trim().slice(-3), 10);
|
|
146
148
|
if (code === 202) {
|
|
@@ -156,7 +158,7 @@ export class AxomRemote {
|
|
|
156
158
|
|
|
157
159
|
// Pre-§14 runtime: signal the process that owns the port. Still the
|
|
158
160
|
// user's own machine, still an explicit action they asked for.
|
|
159
|
-
await this._ssh(`PID=$(lsof -t -i:${port} 2>/dev/null | head -1); [ -n "$PID" ] && kill $PID && echo KILLED || echo NOTFOUND`, 20000);
|
|
161
|
+
await this._ssh(`PID=$(lsof -t -i:${port} 2>/dev/null | head -1); [ -n "$PID" ] && kill $PID && echo KILLED || echo NOTFOUND`, 20000, cfg);
|
|
160
162
|
this.daemon.audit.log('axom.remote.stop', { host: cfg.host, port, via: 'signal' });
|
|
161
163
|
this._broadcast();
|
|
162
164
|
return { stopped: true, via: 'signal' };
|
|
@@ -181,8 +183,8 @@ export class AxomRemote {
|
|
|
181
183
|
}
|
|
182
184
|
}
|
|
183
185
|
|
|
184
|
-
async ensureTunnel() {
|
|
185
|
-
const cfg = this._config();
|
|
186
|
+
async ensureTunnel(cfgOverride = null) {
|
|
187
|
+
const cfg = cfgOverride || this._config();
|
|
186
188
|
if (!cfg) return { tunneled: false, reason: 'no remote configured' };
|
|
187
189
|
const problem = validateRemote(cfg);
|
|
188
190
|
if (problem) return { tunneled: false, reason: problem };
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// GROOVE — Axom Runtime Model (the redesign's one entity)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// A `runtime` is the single thing the GUI reasons about: a named Axom with a
|
|
5
|
+
// URL and a control mode. Everything else (connector endpoints, spawned
|
|
6
|
+
// instances, SSH lifecycle, tunnels) is a backend behind this model.
|
|
7
|
+
//
|
|
8
|
+
// control: 'local' — a process THIS daemon spawns/kills (AxomServerManager)
|
|
9
|
+
// control: 'ssh' — start/stop over SSH on a machine the user owns
|
|
10
|
+
// control: 'none' — someone else's runtime; connect-only
|
|
11
|
+
//
|
|
12
|
+
// States, each owning one recovery action (plans/axom-runtime-flow-redesign.md):
|
|
13
|
+
// connected → events flowing (verb: stop / disconnect)
|
|
14
|
+
// running → /about answers, WS catching up (no verb needed)
|
|
15
|
+
// stopped → reachable, no runtime (verb: start, if controllable)
|
|
16
|
+
// unreachable → can't reach the URL/host (verb: heal tunnel / retry)
|
|
17
|
+
// `unknown` appears only when probing itself failed — never guessed away.
|
|
18
|
+
|
|
19
|
+
import { validateEndpoint } from './axom-connector.js';
|
|
20
|
+
import { validateRemote } from './axom-remote.js';
|
|
21
|
+
import { saveConfig } from './firstrun.js';
|
|
22
|
+
|
|
23
|
+
export function validateRuntime(rt) {
|
|
24
|
+
if (!rt || typeof rt !== 'object') return 'runtime must be an object';
|
|
25
|
+
if (!rt.id || !/^[a-zA-Z0-9_-]{1,40}$/.test(rt.id)) return 'invalid runtime id';
|
|
26
|
+
if (!rt.name || typeof rt.name !== 'string' || rt.name.length > 60) return 'invalid runtime name';
|
|
27
|
+
if (!['local', 'ssh', 'none'].includes(rt.control)) return 'control must be local, ssh, or none';
|
|
28
|
+
if (rt.control !== 'local' || rt.url) {
|
|
29
|
+
// local runtimes get their URL from the spawned port; others must have one
|
|
30
|
+
const problem = validateEndpoint({ name: rt.id, url: rt.url });
|
|
31
|
+
if (rt.control !== 'local' && problem) return problem;
|
|
32
|
+
}
|
|
33
|
+
if (rt.control === 'ssh') {
|
|
34
|
+
const problem = validateRemote({ port: undefined, ...rt.ssh });
|
|
35
|
+
if (problem) return `ssh config: ${problem}`;
|
|
36
|
+
}
|
|
37
|
+
if (rt.launch !== undefined) {
|
|
38
|
+
if (typeof rt.launch !== 'object' || typeof rt.launch.command !== 'string'
|
|
39
|
+
|| rt.launch.command.length === 0 || rt.launch.command.length > 500) {
|
|
40
|
+
return 'launch.command must be a non-empty string of at most 500 chars';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class AxomRuntimes {
|
|
47
|
+
constructor(daemon) {
|
|
48
|
+
this.daemon = daemon;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Config ────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
_cfg() {
|
|
54
|
+
if (!this.daemon.config.axom) this.daemon.config.axom = {};
|
|
55
|
+
return this.daemon.config.axom;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
list() {
|
|
59
|
+
return this._cfg().runtimes || [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get(id) {
|
|
63
|
+
return this.list().find((r) => r.id === id) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
activeId() {
|
|
67
|
+
const cfg = this._cfg();
|
|
68
|
+
return cfg.activeRuntimeId && this.get(cfg.activeRuntimeId)
|
|
69
|
+
? cfg.activeRuntimeId
|
|
70
|
+
: this.list()[0]?.id || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One-time, idempotent: fold the four legacy keys into runtimes[]. Old keys
|
|
74
|
+
// are kept until the model proves out — compat routes still read them.
|
|
75
|
+
migrate() {
|
|
76
|
+
const cfg = this._cfg();
|
|
77
|
+
if (Array.isArray(cfg.runtimes)) return false;
|
|
78
|
+
const runtimes = [];
|
|
79
|
+
const remote = cfg.remote || null;
|
|
80
|
+
const ep = (cfg.endpoints || [])[0] || null;
|
|
81
|
+
if (ep && remote && ep.url.endsWith(`:${remote.port || 8737}`)) {
|
|
82
|
+
runtimes.push({
|
|
83
|
+
id: 'spark', name: remote.host.split('.')[0] || 'Remote', url: ep.url,
|
|
84
|
+
control: 'ssh',
|
|
85
|
+
ssh: { host: remote.host, user: remote.user, sshPort: remote.sshPort, autoTunnel: true },
|
|
86
|
+
launch: remote.command ? { command: remote.command } : undefined,
|
|
87
|
+
logPath: remote.logPath,
|
|
88
|
+
});
|
|
89
|
+
} else if (ep) {
|
|
90
|
+
let name = 'Axom';
|
|
91
|
+
try { name = new URL(ep.url).hostname; } catch { /* keep default */ }
|
|
92
|
+
runtimes.push({ id: ep.name || 'axom', name, url: ep.url, control: 'none' });
|
|
93
|
+
}
|
|
94
|
+
cfg.runtimes = runtimes;
|
|
95
|
+
if (runtimes.length && !cfg.activeRuntimeId) cfg.activeRuntimeId = runtimes[0].id;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_save() {
|
|
100
|
+
saveConfig(this.daemon.grooveDir, this.daemon.config);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
start() {
|
|
104
|
+
this.migrate();
|
|
105
|
+
this._syncConnector();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Every runtime with a URL becomes a connector endpoint — the connector
|
|
109
|
+
// stays the single owner of event streams.
|
|
110
|
+
_syncConnector() {
|
|
111
|
+
const entries = this.list()
|
|
112
|
+
.filter((r) => r.url)
|
|
113
|
+
.map((r) => ({ name: r.id, url: r.url }));
|
|
114
|
+
this.daemon.axom.configure(entries);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
add(rt) {
|
|
118
|
+
const problem = validateRuntime(rt);
|
|
119
|
+
if (problem) throw new Error(problem);
|
|
120
|
+
if (this.get(rt.id)) throw new Error(`runtime "${rt.id}" already exists`);
|
|
121
|
+
this._cfg().runtimes = [...this.list(), rt];
|
|
122
|
+
if (!this._cfg().activeRuntimeId) this._cfg().activeRuntimeId = rt.id;
|
|
123
|
+
this._save();
|
|
124
|
+
this._syncConnector();
|
|
125
|
+
this.broadcastStatus();
|
|
126
|
+
return rt;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
update(id, patch) {
|
|
130
|
+
const existing = this.get(id);
|
|
131
|
+
if (!existing) throw new Error(`no runtime "${id}"`);
|
|
132
|
+
const next = { ...existing, ...patch, id };
|
|
133
|
+
const problem = validateRuntime(next);
|
|
134
|
+
if (problem) throw new Error(problem);
|
|
135
|
+
this._cfg().runtimes = this.list().map((r) => (r.id === id ? next : r));
|
|
136
|
+
this._save();
|
|
137
|
+
this._syncConnector();
|
|
138
|
+
this.broadcastStatus();
|
|
139
|
+
return next;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
remove(id) {
|
|
143
|
+
if (!this.get(id)) throw new Error(`no runtime "${id}"`);
|
|
144
|
+
this._cfg().runtimes = this.list().filter((r) => r.id !== id);
|
|
145
|
+
if (this._cfg().activeRuntimeId === id) {
|
|
146
|
+
this._cfg().activeRuntimeId = this.list()[0]?.id || null;
|
|
147
|
+
}
|
|
148
|
+
this._save();
|
|
149
|
+
this._syncConnector();
|
|
150
|
+
this.broadcastStatus();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
activate(id) {
|
|
154
|
+
if (!this.get(id)) throw new Error(`no runtime "${id}"`);
|
|
155
|
+
this._cfg().activeRuntimeId = id;
|
|
156
|
+
this._save();
|
|
157
|
+
this.broadcastStatus();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── State derivation ──────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async state(id) {
|
|
163
|
+
const rt = this.get(id);
|
|
164
|
+
if (!rt) throw new Error(`no runtime "${id}"`);
|
|
165
|
+
const ep = rt.url ? this.daemon.axom.endpoints.get(rt.id) : null;
|
|
166
|
+
|
|
167
|
+
if (ep?.status === 'connected') {
|
|
168
|
+
return { state: 'connected', detail: null };
|
|
169
|
+
}
|
|
170
|
+
// Probe the URL directly — the connector's backoff may simply not have
|
|
171
|
+
// caught up yet, and "running" beats a stale "error".
|
|
172
|
+
if (rt.url) {
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch(`${rt.url}/about`, { signal: AbortSignal.timeout(3000) });
|
|
175
|
+
if (res.ok) {
|
|
176
|
+
// It answers — pull the connector in NOW instead of letting its
|
|
177
|
+
// backoff stretch the 'running' limbo into a felt stall.
|
|
178
|
+
this.daemon.axom.nudge?.(rt.id);
|
|
179
|
+
return { state: 'running', detail: 'connecting to event stream' };
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const refused = /ECONNREFUSED/.test(err.cause?.code || err.message || '');
|
|
183
|
+
if (refused && rt.control !== 'ssh') {
|
|
184
|
+
return { state: 'stopped', detail: null };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (rt.control === 'ssh') {
|
|
189
|
+
// The host knows more than the tunnel does.
|
|
190
|
+
const remote = await this.daemon.axomRemote.status(this._sshCfg(rt));
|
|
191
|
+
if (remote.running === true) return { state: 'unreachable', detail: 'runtime is up on the host — the tunnel is down' };
|
|
192
|
+
if (remote.running === false) return { state: 'stopped', detail: null };
|
|
193
|
+
return { state: 'unreachable', detail: remote.error || `can't reach ${rt.ssh.host}` };
|
|
194
|
+
}
|
|
195
|
+
if (rt.control === 'local') {
|
|
196
|
+
const inst = this.daemon.axomServer.list().find((i) => i.id === rt.id);
|
|
197
|
+
if (inst?.status === 'running') return { state: 'running', detail: 'connecting to event stream' };
|
|
198
|
+
return { state: 'stopped', detail: inst?.error || null };
|
|
199
|
+
}
|
|
200
|
+
return { state: 'unreachable', detail: 'nothing answers at this endpoint' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_broadcasting = false;
|
|
204
|
+
async broadcastStatus() {
|
|
205
|
+
if (this._broadcasting) return; // status() probes; don't stampede
|
|
206
|
+
this._broadcasting = true;
|
|
207
|
+
try {
|
|
208
|
+
this.daemon.broadcast({ type: 'axom:runtimes', data: await this.status() });
|
|
209
|
+
} catch { /* next state change rebroadcasts */ } finally {
|
|
210
|
+
this._broadcasting = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async status() {
|
|
215
|
+
const runtimes = await Promise.all(this.list().map(async (rt) => {
|
|
216
|
+
let derived;
|
|
217
|
+
try {
|
|
218
|
+
derived = await this.state(rt.id);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
derived = { state: 'unknown', detail: err.message };
|
|
221
|
+
}
|
|
222
|
+
const ep = this.daemon.axom.endpoints.get(rt.id);
|
|
223
|
+
return {
|
|
224
|
+
id: rt.id,
|
|
225
|
+
name: rt.name,
|
|
226
|
+
control: rt.control,
|
|
227
|
+
url: rt.url || null,
|
|
228
|
+
...derived,
|
|
229
|
+
about: ep?.about || null,
|
|
230
|
+
error: ep?.error || null,
|
|
231
|
+
canStart: rt.control !== 'none' && derived.state === 'stopped',
|
|
232
|
+
canStop: rt.control !== 'none' && (derived.state === 'connected' || derived.state === 'running'),
|
|
233
|
+
canHeal: rt.control === 'ssh' && derived.state === 'unreachable',
|
|
234
|
+
};
|
|
235
|
+
}));
|
|
236
|
+
return { runtimes, activeRuntimeId: this.activeId() };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Verbs — dispatch on control, never guess ─────────────────────────────
|
|
240
|
+
|
|
241
|
+
_sshCfg(rt) {
|
|
242
|
+
let port = 8737;
|
|
243
|
+
try { port = Number(new URL(rt.url).port) || 8737; } catch { /* default */ }
|
|
244
|
+
return {
|
|
245
|
+
...rt.ssh,
|
|
246
|
+
port,
|
|
247
|
+
command: rt.launch?.command,
|
|
248
|
+
logPath: rt.logPath,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async startRuntime(id) {
|
|
253
|
+
const rt = this.get(id);
|
|
254
|
+
if (!rt) throw new Error(`no runtime "${id}"`);
|
|
255
|
+
if (rt.control === 'none') throw new Error(`"${rt.name}" is not controlled by GROOVE — start it where it runs`);
|
|
256
|
+
if (rt.control === 'ssh') {
|
|
257
|
+
const result = await this.daemon.axomRemote.start(this._sshCfg(rt));
|
|
258
|
+
if (rt.ssh?.autoTunnel !== false) await this.daemon.axomRemote.ensureTunnel(this._sshCfg(rt));
|
|
259
|
+
this._syncConnector();
|
|
260
|
+
this.daemon.axom.nudge?.(rt.id);
|
|
261
|
+
this.broadcastStatus();
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
// local: the spawned port becomes the runtime's URL.
|
|
265
|
+
const instance = await this.daemon.axomServer.start(rt.id, {
|
|
266
|
+
launch: rt.launch,
|
|
267
|
+
dataDir: rt.dataDir,
|
|
268
|
+
});
|
|
269
|
+
this.update(id, { url: `http://127.0.0.1:${instance.port}` });
|
|
270
|
+
this.daemon.axom.nudge?.(rt.id);
|
|
271
|
+
return { started: true, port: instance.port };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async stopRuntime(id, { force = false } = {}) {
|
|
275
|
+
const rt = this.get(id);
|
|
276
|
+
if (!rt) throw new Error(`no runtime "${id}"`);
|
|
277
|
+
if (rt.control === 'none') throw new Error(`"${rt.name}" is not controlled by GROOVE`);
|
|
278
|
+
let result;
|
|
279
|
+
if (rt.control === 'ssh') {
|
|
280
|
+
result = await this.daemon.axomRemote.stop({ force }, this._sshCfg(rt));
|
|
281
|
+
} else {
|
|
282
|
+
await this.daemon.axomServer.stop(rt.id);
|
|
283
|
+
result = { stopped: true };
|
|
284
|
+
}
|
|
285
|
+
// The connector still believes 'connected' until its next poll fails —
|
|
286
|
+
// re-probe now so the runtime card moves with the verb, not the poll.
|
|
287
|
+
this.daemon.axom.recheck?.(rt.id);
|
|
288
|
+
this.broadcastStatus();
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async heal(id) {
|
|
293
|
+
const rt = this.get(id);
|
|
294
|
+
if (!rt) throw new Error(`no runtime "${id}"`);
|
|
295
|
+
if (rt.control !== 'ssh') throw new Error('only ssh runtimes have a tunnel to heal');
|
|
296
|
+
const result = await this.daemon.axomRemote.ensureTunnel(this._sshCfg(rt));
|
|
297
|
+
this.daemon.axom.nudge?.(rt.id);
|
|
298
|
+
this.broadcastStatus();
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -106,7 +106,14 @@ export class AxomServerManager {
|
|
|
106
106
|
|
|
107
107
|
// Start a local instance. `id` doubles as the data-dir name, so the same id
|
|
108
108
|
// across restarts resumes the same sovereign memory.
|
|
109
|
-
|
|
109
|
+
// opts.launch: {command, cwd?, env?} — a SPEC, not a binary name (source
|
|
110
|
+
// checkouts launch as `python3 -u -m axom.cli ...` with cwd+env; the
|
|
111
|
+
// installed default is just `axom`). Specs are verbatim: GROOVE never adds
|
|
112
|
+
// or removes flags (--cpu on the Spark is policy, not ours to "improve").
|
|
113
|
+
// opts.dataDir: adopt an existing sovereign ledger instead of minting one
|
|
114
|
+
// under .groove — one central ledger per user; sessions are recency
|
|
115
|
+
// scopes, never memory walls (SPARK_DEV_SETUP.md ruling).
|
|
116
|
+
async start(id = 'default', opts = {}) {
|
|
110
117
|
if (!/^[a-zA-Z0-9_-]{1,40}$/.test(id)) throw new Error('invalid instance id');
|
|
111
118
|
const existing = this.instances.get(id);
|
|
112
119
|
if (existing && existing.status === 'running') return this.list().find((i) => i.id === id);
|
|
@@ -124,7 +131,7 @@ export class AxomServerManager {
|
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
const port = this._allocatePort();
|
|
127
|
-
const dataDir = join(this.daemon.grooveDir, 'axom', 'instances', id);
|
|
134
|
+
const dataDir = opts.dataDir || join(this.daemon.grooveDir, 'axom', 'instances', id);
|
|
128
135
|
mkdirSync(dataDir, { recursive: true });
|
|
129
136
|
|
|
130
137
|
const args = [
|
|
@@ -144,9 +151,21 @@ export class AxomServerManager {
|
|
|
144
151
|
this.instances.set(id, instance);
|
|
145
152
|
this._broadcast();
|
|
146
153
|
|
|
154
|
+
const launch = opts.launch || { command: this._command() };
|
|
147
155
|
let proc;
|
|
148
156
|
try {
|
|
149
|
-
|
|
157
|
+
if (launch.cwd || launch.env || /\s/.test(launch.command)) {
|
|
158
|
+
// Compound commands run through bash -lc — `nohup cd x && prog`-class
|
|
159
|
+
// failures taught us a spec is a shell line, not an argv[0].
|
|
160
|
+
const quotedArgs = args.map((a) => `'${String(a).replace(/'/g, `'\\''`)}'`).join(' ');
|
|
161
|
+
proc = spawn('bash', ['-lc', `exec ${launch.command} ${quotedArgs}`], {
|
|
162
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
163
|
+
cwd: launch.cwd || undefined,
|
|
164
|
+
env: launch.env ? { ...process.env, ...launch.env } : process.env,
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
proc = spawn(launch.command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
168
|
+
}
|
|
150
169
|
} catch (err) {
|
|
151
170
|
instance.status = 'error';
|
|
152
171
|
instance.error = err.message;
|
|
@@ -159,7 +178,7 @@ export class AxomServerManager {
|
|
|
159
178
|
proc.on('error', (err) => {
|
|
160
179
|
instance.status = 'error';
|
|
161
180
|
instance.error = err.code === 'ENOENT'
|
|
162
|
-
? `"${
|
|
181
|
+
? `"${launch.command}" not found — install the Axom runtime first`
|
|
163
182
|
: err.message;
|
|
164
183
|
this._broadcast();
|
|
165
184
|
});
|
|
@@ -58,6 +58,7 @@ import { AxomConnector } from './axom-connector.js';
|
|
|
58
58
|
import { AxomServerManager } from './axom-server.js';
|
|
59
59
|
import { AxomInstaller } from './axom-install.js';
|
|
60
60
|
import { AxomRemote } from './axom-remote.js';
|
|
61
|
+
import { AxomRuntimes } from './axom-runtimes.js';
|
|
61
62
|
import { setProviderPaths } from './providers/index.js';
|
|
62
63
|
|
|
63
64
|
const DEFAULT_PORT = 31415;
|
|
@@ -175,6 +176,7 @@ export class Daemon {
|
|
|
175
176
|
this.axomServer = new AxomServerManager(this);
|
|
176
177
|
this.axomInstaller = new AxomInstaller(this);
|
|
177
178
|
this.axomRemote = new AxomRemote(this);
|
|
179
|
+
this.axomRuntimes = new AxomRuntimes(this);
|
|
178
180
|
this.trajectoryCapture = null;
|
|
179
181
|
|
|
180
182
|
// Hook teams.delete to clean up agent-loop session files
|
|
@@ -642,7 +644,7 @@ export class Daemon {
|
|
|
642
644
|
this.orchestrator.start();
|
|
643
645
|
this.timeline.start();
|
|
644
646
|
this.gateways.start();
|
|
645
|
-
this.
|
|
647
|
+
this.axomRuntimes.start();
|
|
646
648
|
this.federation.initialize();
|
|
647
649
|
this._startGarbageCollector();
|
|
648
650
|
|
|
@@ -122,6 +122,79 @@ 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
|
+
app.post('/api/axom/runtimes/:id/activate', (req, res) => {
|
|
162
|
+
try {
|
|
163
|
+
daemon.axomRuntimes.activate(req.params.id);
|
|
164
|
+
res.json({ ok: true, activeRuntimeId: req.params.id });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
res.status(400).json({ error: err.message });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
app.post('/api/axom/runtimes/:id/start', async (req, res) => {
|
|
171
|
+
try {
|
|
172
|
+
const result = await daemon.axomRuntimes.startRuntime(req.params.id);
|
|
173
|
+
daemon.audit.log('axom.runtime.start', { id: req.params.id });
|
|
174
|
+
res.json(result);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
res.status(502).json({ error: err.message });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
app.post('/api/axom/runtimes/:id/stop', async (req, res) => {
|
|
181
|
+
try {
|
|
182
|
+
const result = await daemon.axomRuntimes.stopRuntime(req.params.id, { force: !!req.body?.force });
|
|
183
|
+
daemon.audit.log('axom.runtime.stop', { id: req.params.id });
|
|
184
|
+
res.json(result);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
res.status(502).json({ error: err.message });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
app.post('/api/axom/runtimes/:id/heal', async (req, res) => {
|
|
191
|
+
try {
|
|
192
|
+
res.json(await daemon.axomRuntimes.heal(req.params.id));
|
|
193
|
+
} catch (err) {
|
|
194
|
+
res.status(502).json({ error: err.message });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
125
198
|
// ── Remote runtime control over SSH (manual only, never automatic) ──────
|
|
126
199
|
|
|
127
200
|
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,
|