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
|
@@ -0,0 +1,498 @@
|
|
|
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
|
+
// 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
|
+
|
|
42
|
+
export function validateRuntime(rt) {
|
|
43
|
+
if (!rt || typeof rt !== 'object') return 'runtime must be an object';
|
|
44
|
+
if (!rt.id || !/^[a-zA-Z0-9_-]{1,40}$/.test(rt.id)) return 'invalid runtime id';
|
|
45
|
+
if (!rt.name || typeof rt.name !== 'string' || rt.name.length > 60) return 'invalid runtime name';
|
|
46
|
+
if (!['local', 'ssh', 'none'].includes(rt.control)) return 'control must be local, ssh, or none';
|
|
47
|
+
if (rt.control !== 'local' || rt.url) {
|
|
48
|
+
// local runtimes get their URL from the spawned port; others must have one
|
|
49
|
+
const problem = validateEndpoint({ name: rt.id, url: rt.url });
|
|
50
|
+
if (rt.control !== 'local' && problem) return problem;
|
|
51
|
+
}
|
|
52
|
+
if (rt.control === 'ssh') {
|
|
53
|
+
const problem = validateRemote({ port: undefined, ...rt.ssh });
|
|
54
|
+
if (problem) return `ssh config: ${problem}`;
|
|
55
|
+
}
|
|
56
|
+
if (rt.launch !== undefined) {
|
|
57
|
+
if (typeof rt.launch !== 'object' || typeof rt.launch.command !== 'string'
|
|
58
|
+
|| rt.launch.command.length === 0 || rt.launch.command.length > 500) {
|
|
59
|
+
return 'launch.command must be a non-empty string of at most 500 chars';
|
|
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
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class AxomRuntimes {
|
|
78
|
+
constructor(daemon) {
|
|
79
|
+
this.daemon = daemon;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Config ────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
_cfg() {
|
|
85
|
+
if (!this.daemon.config.axom) this.daemon.config.axom = {};
|
|
86
|
+
return this.daemon.config.axom;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
list() {
|
|
90
|
+
return this._cfg().runtimes || [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get(id) {
|
|
94
|
+
return this.list().find((r) => r.id === id) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
activeId() {
|
|
98
|
+
const cfg = this._cfg();
|
|
99
|
+
return cfg.activeRuntimeId && this.get(cfg.activeRuntimeId)
|
|
100
|
+
? cfg.activeRuntimeId
|
|
101
|
+
: this.list()[0]?.id || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// One-time, idempotent: fold the four legacy keys into runtimes[]. Old keys
|
|
105
|
+
// are kept until the model proves out — compat routes still read them.
|
|
106
|
+
migrate() {
|
|
107
|
+
const cfg = this._cfg();
|
|
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
|
+
}
|
|
117
|
+
const runtimes = [];
|
|
118
|
+
const remote = cfg.remote || null;
|
|
119
|
+
const ep = (cfg.endpoints || [])[0] || null;
|
|
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}`))) {
|
|
125
|
+
runtimes.push({
|
|
126
|
+
id: 'spark', name: remote.host.split('.')[0] || 'Remote',
|
|
127
|
+
url: ep?.url || `http://127.0.0.1:${remote.port || 8737}`,
|
|
128
|
+
control: 'ssh',
|
|
129
|
+
ssh: { host: remote.host, user: remote.user, sshPort: remote.sshPort, autoTunnel: true },
|
|
130
|
+
launch: remote.command ? { command: remote.command } : undefined,
|
|
131
|
+
logPath: remote.logPath,
|
|
132
|
+
});
|
|
133
|
+
} else if (ep) {
|
|
134
|
+
let name = 'Axom';
|
|
135
|
+
try { name = new URL(ep.url).hostname; } catch { /* keep default */ }
|
|
136
|
+
runtimes.push({ id: ep.name || 'axom', name, url: ep.url, control: 'none' });
|
|
137
|
+
}
|
|
138
|
+
cfg.runtimes = runtimes;
|
|
139
|
+
if (runtimes.length) {
|
|
140
|
+
cfg.runtimesMigrated = true;
|
|
141
|
+
if (!cfg.activeRuntimeId) cfg.activeRuntimeId = runtimes[0].id;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_save() {
|
|
147
|
+
saveConfig(this.daemon.grooveDir, this.daemon.config);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
start() {
|
|
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();
|
|
154
|
+
this._syncConnector();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Every runtime with a URL becomes a connector endpoint — the connector
|
|
158
|
+
// stays the single owner of event streams.
|
|
159
|
+
_syncConnector() {
|
|
160
|
+
const entries = this.list()
|
|
161
|
+
.filter((r) => r.url)
|
|
162
|
+
.map((r) => ({ name: r.id, url: r.url }));
|
|
163
|
+
this.daemon.axom.configure(entries);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
add(rt) {
|
|
167
|
+
const problem = validateRuntime(rt);
|
|
168
|
+
if (problem) throw new Error(problem);
|
|
169
|
+
if (this.get(rt.id)) throw new Error(`runtime "${rt.id}" already exists`);
|
|
170
|
+
this._cfg().runtimes = [...this.list(), rt];
|
|
171
|
+
if (!this._cfg().activeRuntimeId) this._cfg().activeRuntimeId = rt.id;
|
|
172
|
+
this._save();
|
|
173
|
+
this._syncConnector();
|
|
174
|
+
this.broadcastStatus();
|
|
175
|
+
return rt;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
update(id, patch) {
|
|
179
|
+
const existing = this.get(id);
|
|
180
|
+
if (!existing) throw new Error(`no runtime "${id}"`);
|
|
181
|
+
const next = { ...existing, ...patch, id };
|
|
182
|
+
const problem = validateRuntime(next);
|
|
183
|
+
if (problem) throw new Error(problem);
|
|
184
|
+
this._cfg().runtimes = this.list().map((r) => (r.id === id ? next : r));
|
|
185
|
+
this._save();
|
|
186
|
+
this._syncConnector();
|
|
187
|
+
this.broadcastStatus();
|
|
188
|
+
return next;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
remove(id) {
|
|
192
|
+
if (!this.get(id)) throw new Error(`no runtime "${id}"`);
|
|
193
|
+
this._cfg().runtimes = this.list().filter((r) => r.id !== id);
|
|
194
|
+
if (this._cfg().activeRuntimeId === id) {
|
|
195
|
+
this._cfg().activeRuntimeId = this.list()[0]?.id || null;
|
|
196
|
+
}
|
|
197
|
+
this._save();
|
|
198
|
+
this._syncConnector();
|
|
199
|
+
this.broadcastStatus();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
activate(id) {
|
|
203
|
+
if (!this.get(id)) throw new Error(`no runtime "${id}"`);
|
|
204
|
+
this._cfg().activeRuntimeId = id;
|
|
205
|
+
this._save();
|
|
206
|
+
this.broadcastStatus();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── State derivation ──────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
async state(id) {
|
|
212
|
+
const rt = this.get(id);
|
|
213
|
+
if (!rt) throw new Error(`no runtime "${id}"`);
|
|
214
|
+
const ep = rt.url ? this.daemon.axom.endpoints.get(rt.id) : null;
|
|
215
|
+
|
|
216
|
+
if (ep?.status === 'connected') {
|
|
217
|
+
return { state: 'connected', detail: null };
|
|
218
|
+
}
|
|
219
|
+
// Probe the URL directly — the connector's backoff may simply not have
|
|
220
|
+
// caught up yet, and "running" beats a stale "error".
|
|
221
|
+
if (rt.url) {
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch(`${rt.url}/about`, { signal: AbortSignal.timeout(3000) });
|
|
224
|
+
if (res.ok) {
|
|
225
|
+
// It answers — pull the connector in NOW instead of letting its
|
|
226
|
+
// backoff stretch the 'running' limbo into a felt stall.
|
|
227
|
+
this.daemon.axom.nudge?.(rt.id);
|
|
228
|
+
return { state: 'running', detail: 'connecting to event stream' };
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const refused = /ECONNREFUSED/.test(err.cause?.code || err.message || '');
|
|
232
|
+
if (refused && rt.control !== 'ssh') {
|
|
233
|
+
return { state: 'stopped', detail: null };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (rt.control === 'ssh') {
|
|
238
|
+
// The host knows more than the tunnel does.
|
|
239
|
+
const remote = await this.daemon.axomRemote.status(this._sshCfg(rt));
|
|
240
|
+
if (remote.running === true) return { state: 'unreachable', detail: 'runtime is up on the host — the tunnel is down' };
|
|
241
|
+
if (remote.running === false) return { state: 'stopped', detail: null };
|
|
242
|
+
return { state: 'unreachable', detail: remote.error || `can't reach ${rt.ssh.host}` };
|
|
243
|
+
}
|
|
244
|
+
if (rt.control === 'local') {
|
|
245
|
+
const inst = this.daemon.axomServer.list().find((i) => i.id === rt.id);
|
|
246
|
+
if (inst?.status === 'running') return { state: 'running', detail: 'connecting to event stream' };
|
|
247
|
+
return { state: 'stopped', detail: inst?.error || null };
|
|
248
|
+
}
|
|
249
|
+
return { state: 'unreachable', detail: 'nothing answers at this endpoint' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_broadcasting = false;
|
|
253
|
+
async broadcastStatus() {
|
|
254
|
+
if (this._broadcasting) return; // status() probes; don't stampede
|
|
255
|
+
this._broadcasting = true;
|
|
256
|
+
try {
|
|
257
|
+
this.daemon.broadcast({ type: 'axom:runtimes', data: await this.status() });
|
|
258
|
+
} catch { /* next state change rebroadcasts */ } finally {
|
|
259
|
+
this._broadcasting = false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async status() {
|
|
264
|
+
const runtimes = await Promise.all(this.list().map(async (rt) => {
|
|
265
|
+
let derived;
|
|
266
|
+
try {
|
|
267
|
+
derived = await this.state(rt.id);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
derived = { state: 'unknown', detail: err.message };
|
|
270
|
+
}
|
|
271
|
+
const ep = this.daemon.axom.endpoints.get(rt.id);
|
|
272
|
+
return {
|
|
273
|
+
id: rt.id,
|
|
274
|
+
name: rt.name,
|
|
275
|
+
control: rt.control,
|
|
276
|
+
url: rt.url || null,
|
|
277
|
+
...derived,
|
|
278
|
+
about: ep?.about || null,
|
|
279
|
+
error: ep?.error || null,
|
|
280
|
+
canStart: rt.control !== 'none' && derived.state === 'stopped',
|
|
281
|
+
canStop: rt.control !== 'none' && (derived.state === 'connected' || derived.state === 'running'),
|
|
282
|
+
canHeal: rt.control === 'ssh' && derived.state === 'unreachable',
|
|
283
|
+
...this.generation(rt.id),
|
|
284
|
+
};
|
|
285
|
+
}));
|
|
286
|
+
return { runtimes, activeRuntimeId: this.activeId() };
|
|
287
|
+
}
|
|
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
|
+
|
|
416
|
+
// ── Verbs — dispatch on control, never guess ─────────────────────────────
|
|
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
|
+
|
|
438
|
+
_sshCfg(rt) {
|
|
439
|
+
let port = 8737;
|
|
440
|
+
try { port = Number(new URL(rt.url).port) || 8737; } catch { /* default */ }
|
|
441
|
+
return {
|
|
442
|
+
...rt.ssh,
|
|
443
|
+
port,
|
|
444
|
+
command: this._sshCommand(rt),
|
|
445
|
+
logPath: rt.logPath,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async startRuntime(id) {
|
|
450
|
+
const rt = this.get(id);
|
|
451
|
+
if (!rt) throw new Error(`no runtime "${id}"`);
|
|
452
|
+
if (rt.control === 'none') throw new Error(`"${rt.name}" is not controlled by GROOVE — start it where it runs`);
|
|
453
|
+
if (rt.control === 'ssh') {
|
|
454
|
+
const result = await this.daemon.axomRemote.start(this._sshCfg(rt));
|
|
455
|
+
if (rt.ssh?.autoTunnel !== false) await this.daemon.axomRemote.ensureTunnel(this._sshCfg(rt));
|
|
456
|
+
this._syncConnector();
|
|
457
|
+
this.daemon.axom.nudge?.(rt.id);
|
|
458
|
+
this.broadcastStatus();
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
461
|
+
// local: the spawned port becomes the runtime's URL.
|
|
462
|
+
const instance = await this.daemon.axomServer.start(rt.id, {
|
|
463
|
+
launch: withBlessedEnv(rt.launch) || { env: { ...BLESSED_ENV } },
|
|
464
|
+
dataDir: rt.dataDir,
|
|
465
|
+
});
|
|
466
|
+
this.update(id, { url: `http://127.0.0.1:${instance.port}` });
|
|
467
|
+
this.daemon.axom.nudge?.(rt.id);
|
|
468
|
+
return { started: true, port: instance.port };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async stopRuntime(id, { force = false } = {}) {
|
|
472
|
+
const rt = this.get(id);
|
|
473
|
+
if (!rt) throw new Error(`no runtime "${id}"`);
|
|
474
|
+
if (rt.control === 'none') throw new Error(`"${rt.name}" is not controlled by GROOVE`);
|
|
475
|
+
let result;
|
|
476
|
+
if (rt.control === 'ssh') {
|
|
477
|
+
result = await this.daemon.axomRemote.stop({ force }, this._sshCfg(rt));
|
|
478
|
+
} else {
|
|
479
|
+
await this.daemon.axomServer.stop(rt.id);
|
|
480
|
+
result = { stopped: true };
|
|
481
|
+
}
|
|
482
|
+
// The connector still believes 'connected' until its next poll fails —
|
|
483
|
+
// re-probe now so the runtime card moves with the verb, not the poll.
|
|
484
|
+
this.daemon.axom.recheck?.(rt.id);
|
|
485
|
+
this.broadcastStatus();
|
|
486
|
+
return result;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async heal(id) {
|
|
490
|
+
const rt = this.get(id);
|
|
491
|
+
if (!rt) throw new Error(`no runtime "${id}"`);
|
|
492
|
+
if (rt.control !== 'ssh') throw new Error('only ssh runtimes have a tunnel to heal');
|
|
493
|
+
const result = await this.daemon.axomRemote.ensureTunnel(this._sshCfg(rt));
|
|
494
|
+
this.daemon.axom.nudge?.(rt.id);
|
|
495
|
+
this.broadcastStatus();
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -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,23 @@ export class AxomServerManager {
|
|
|
144
151
|
this.instances.set(id, instance);
|
|
145
152
|
this._broadcast();
|
|
146
153
|
|
|
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() };
|
|
147
157
|
let proc;
|
|
148
158
|
try {
|
|
149
|
-
|
|
159
|
+
if (launch.cwd || launch.env || /\s/.test(launch.command)) {
|
|
160
|
+
// Compound commands run through bash -lc — `nohup cd x && prog`-class
|
|
161
|
+
// failures taught us a spec is a shell line, not an argv[0].
|
|
162
|
+
const quotedArgs = args.map((a) => `'${String(a).replace(/'/g, `'\\''`)}'`).join(' ');
|
|
163
|
+
proc = spawn('bash', ['-lc', `exec ${launch.command} ${quotedArgs}`], {
|
|
164
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
165
|
+
cwd: launch.cwd || undefined,
|
|
166
|
+
env: launch.env ? { ...process.env, ...launch.env } : process.env,
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
proc = spawn(launch.command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
170
|
+
}
|
|
150
171
|
} catch (err) {
|
|
151
172
|
instance.status = 'error';
|
|
152
173
|
instance.error = err.message;
|
|
@@ -159,7 +180,7 @@ export class AxomServerManager {
|
|
|
159
180
|
proc.on('error', (err) => {
|
|
160
181
|
instance.status = 'error';
|
|
161
182
|
instance.error = err.code === 'ENOENT'
|
|
162
|
-
? `"${
|
|
183
|
+
? `"${launch.command}" not found — install the Axom runtime first`
|
|
163
184
|
: err.message;
|
|
164
185
|
this._broadcast();
|
|
165
186
|
});
|
|
@@ -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
|
|