groove-dev 0.27.196 → 0.27.198
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +5 -5
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +340 -0
- package/node_modules/@groove-dev/daemon/src/axom-install.js +140 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +229 -0
- package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +14 -2
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
- package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
- package/node_modules/@groove-dev/daemon/src/process.js +2 -2
- package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +196 -0
- package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +152 -20
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +412 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +187 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -0
- package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/app.css +53 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +24 -163
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +197 -0
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1060 -0
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +178 -64
- package/package.json +2 -2
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +5 -5
- package/packages/daemon/src/axom-connector.js +340 -0
- package/packages/daemon/src/axom-install.js +140 -0
- package/packages/daemon/src/axom-server.js +229 -0
- package/packages/daemon/src/chatstore.js +104 -0
- package/packages/daemon/src/federation.js +6 -0
- package/packages/daemon/src/index.js +14 -2
- package/packages/daemon/src/innerchat-docs.js +8 -1
- package/packages/daemon/src/innerchat-relay.js +89 -0
- package/packages/daemon/src/innerchat.js +261 -1
- package/packages/daemon/src/introducer.js +1 -1
- package/packages/daemon/src/network-guard.js +56 -0
- package/packages/daemon/src/process.js +2 -2
- package/packages/daemon/src/providers/axom.js +66 -0
- package/packages/daemon/src/providers/index.js +2 -0
- package/packages/daemon/src/routes/agents.js +1 -175
- package/packages/daemon/src/routes/axom.js +196 -0
- package/packages/daemon/src/routes/chat-history.js +34 -0
- package/packages/daemon/src/routes/innerchat.js +152 -20
- package/packages/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/packages/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +2 -2
- package/packages/gui/src/app.css +53 -0
- package/packages/gui/src/components/agents/agent-config.jsx +2 -2
- package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/packages/gui/src/components/layout/command-palette.jsx +1 -1
- package/packages/gui/src/stores/groove.js +24 -163
- package/packages/gui/src/stores/slices/agents-slice.js +52 -2
- package/packages/gui/src/stores/slices/axom-slice.js +197 -0
- package/packages/gui/src/views/axom.jsx +1060 -0
- package/packages/gui/src/views/marketplace.jsx +1 -1
- package/packages/gui/src/views/settings.jsx +178 -64
- package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
- package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
- package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
- package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
- package/packages/daemon/src/conversations.js +0 -576
- package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/packages/gui/src/components/chat/chat-header.jsx +0 -198
- package/packages/gui/src/components/chat/chat-input.jsx +0 -367
- package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
- package/packages/gui/src/components/chat/chat-view.jsx +0 -212
- package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
- package/packages/gui/src/components/chat/model-picker.jsx +0 -201
- package/packages/gui/src/stores/slices/chat-slice.js +0 -227
- package/packages/gui/src/views/chat.jsx +0 -6
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// GROOVE — Axom Connector
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Consumes the Axom provider protocol (GROOVE ⇄ Axom Integration Contract v0,
|
|
5
|
+
// Axom-Labs/Axom-Private docs/GROOVE_INTEGRATION.md):
|
|
6
|
+
//
|
|
7
|
+
// GET /about identity + KINDS schema handshake
|
|
8
|
+
// GET /sessions [{session, started, live}]
|
|
9
|
+
// WS /ws/session/{id}?since=<ev-id> envelope stream, verbatim
|
|
10
|
+
// POST /session/{id}/interrupt {text} -> {id, truncated}
|
|
11
|
+
// POST /session/{id}/stop {} -> {ok}
|
|
12
|
+
//
|
|
13
|
+
// The envelope schema is additive-only: unknown kinds are surfaced and passed
|
|
14
|
+
// through, never dropped. GROOVE consumes the protocol; it never imports Axom
|
|
15
|
+
// code. Host-agnostic by contract §5 — an endpoint is a URL, nothing here may
|
|
16
|
+
// assume where the runtime lives.
|
|
17
|
+
|
|
18
|
+
import { WebSocket } from 'ws';
|
|
19
|
+
|
|
20
|
+
export const AXOM_DEFAULT_PORT = 8737;
|
|
21
|
+
|
|
22
|
+
// The frozen KINDS enumeration from Axom's events.py, mirrored for the /about
|
|
23
|
+
// handshake. Drift (either direction) is surfaced on the endpoint status —
|
|
24
|
+
// new kinds still flow through; this list is for visibility, not filtering.
|
|
25
|
+
export const KNOWN_KINDS = Object.freeze([
|
|
26
|
+
'pipeline_start', 'firing_start', 'step_start', 'firing_end', 'pipeline_done',
|
|
27
|
+
'thought', 'text', 'action', 'observation', 'resolution',
|
|
28
|
+
'delegate', 'yield', 'interrupt', 'interrupt_ack',
|
|
29
|
+
'trace_other', 'tool_start', 'tool_end', 'recall_end',
|
|
30
|
+
'swarm_start', 'swarm_weights', 'swarm_agent_done', 'swarm_agent_error',
|
|
31
|
+
'swarm_memory_fused', 'swarm_fused', 'swarm_facts_extracted', 'swarm_done',
|
|
32
|
+
'leaf_swap', 'stop_requested', 'stop_effected', 'candidate_banked',
|
|
33
|
+
'narration', 'narration_dropped',
|
|
34
|
+
'candidate_arrived', 'evidence_scored', 'champion_changed',
|
|
35
|
+
'confidence_updated', 'verifier_verdict',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const RING_SIZE = 4096;
|
|
39
|
+
const SESSION_POLL_MS = 15000;
|
|
40
|
+
const BACKOFF_BASE_MS = 1000;
|
|
41
|
+
const BACKOFF_MAX_MS = 30000;
|
|
42
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
43
|
+
|
|
44
|
+
export function validateEndpoint(entry) {
|
|
45
|
+
if (!entry || typeof entry !== 'object') return 'endpoint must be an object';
|
|
46
|
+
const { name, url } = entry;
|
|
47
|
+
if (!name || typeof name !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(name)) {
|
|
48
|
+
return 'endpoint name must be 1-40 chars (letters, digits, dash, underscore)';
|
|
49
|
+
}
|
|
50
|
+
if (!url || typeof url !== 'string') return 'endpoint url is required';
|
|
51
|
+
let parsed;
|
|
52
|
+
try { parsed = new URL(url); } catch { return `invalid url: ${url}`; }
|
|
53
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
54
|
+
return 'endpoint url must be http(s)';
|
|
55
|
+
}
|
|
56
|
+
if (parsed.username || parsed.password) return 'credentials in the url are not allowed';
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function seqOf(eventId) {
|
|
61
|
+
if (typeof eventId !== 'string' || !eventId.startsWith('ev-')) return null;
|
|
62
|
+
const n = parseInt(eventId.slice(3), 10);
|
|
63
|
+
return Number.isFinite(n) ? n : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class AxomConnector {
|
|
67
|
+
constructor(daemon, opts = {}) {
|
|
68
|
+
this.daemon = daemon;
|
|
69
|
+
this.ringSize = opts.ringSize || RING_SIZE;
|
|
70
|
+
this.backoffBaseMs = opts.backoffBaseMs || BACKOFF_BASE_MS;
|
|
71
|
+
this.sessionPollMs = opts.sessionPollMs || SESSION_POLL_MS;
|
|
72
|
+
this.endpoints = new Map(); // name -> endpoint state
|
|
73
|
+
this.destroyed = false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
start() {
|
|
77
|
+
const configured = this.daemon.config?.axom?.endpoints || [];
|
|
78
|
+
this.configure(configured);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Reconcile live connections against a validated endpoint list. Persistence
|
|
82
|
+
// is the caller's job (routes save config); this manages connections only.
|
|
83
|
+
configure(entries) {
|
|
84
|
+
const keep = new Set();
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (validateEndpoint(entry)) continue; // routes validate; skip defensively
|
|
87
|
+
keep.add(entry.name);
|
|
88
|
+
const existing = this.endpoints.get(entry.name);
|
|
89
|
+
if (existing && existing.url === entry.url.replace(/\/+$/, '')) continue;
|
|
90
|
+
if (existing) this._teardownEndpoint(existing);
|
|
91
|
+
this._connectEndpoint(entry);
|
|
92
|
+
}
|
|
93
|
+
for (const [name, ep] of this.endpoints) {
|
|
94
|
+
if (!keep.has(name)) {
|
|
95
|
+
this._teardownEndpoint(ep);
|
|
96
|
+
this.endpoints.delete(name);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_connectEndpoint({ name, url }) {
|
|
102
|
+
const ep = {
|
|
103
|
+
name,
|
|
104
|
+
url: url.replace(/\/+$/, ''),
|
|
105
|
+
status: 'connecting', // connecting | connected | error
|
|
106
|
+
error: null,
|
|
107
|
+
about: null,
|
|
108
|
+
drift: null, // {novel: [], missing: []} vs KNOWN_KINDS
|
|
109
|
+
sessions: new Map(), // sessionId -> session state
|
|
110
|
+
pollTimer: null,
|
|
111
|
+
backoffMs: this.backoffBaseMs,
|
|
112
|
+
};
|
|
113
|
+
this.endpoints.set(name, ep);
|
|
114
|
+
this._handshake(ep);
|
|
115
|
+
return ep;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async _fetch(ep, path, options = {}) {
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(`${ep.url}${path}`, { ...options, signal: controller.signal });
|
|
123
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} from ${path}`);
|
|
124
|
+
return await res.json();
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async _handshake(ep) {
|
|
131
|
+
if (this.destroyed) return;
|
|
132
|
+
try {
|
|
133
|
+
const about = await this._fetch(ep, '/about');
|
|
134
|
+
ep.about = about;
|
|
135
|
+
// Mechanical schema handshake (contract §6): compare, surface, never drop.
|
|
136
|
+
const remote = Array.isArray(about.kinds) ? about.kinds : [];
|
|
137
|
+
ep.drift = {
|
|
138
|
+
novel: remote.filter((k) => !KNOWN_KINDS.includes(k)),
|
|
139
|
+
missing: remote.length ? KNOWN_KINDS.filter((k) => !remote.includes(k)) : [],
|
|
140
|
+
};
|
|
141
|
+
ep.status = 'connected';
|
|
142
|
+
ep.error = null;
|
|
143
|
+
ep.backoffMs = this.backoffBaseMs;
|
|
144
|
+
this._broadcastStatus();
|
|
145
|
+
await this._pollSessions(ep);
|
|
146
|
+
ep.pollTimer = setInterval(() => {
|
|
147
|
+
this._pollSessions(ep).catch(() => this._endpointLost(ep));
|
|
148
|
+
}, this.sessionPollMs);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
ep.status = 'error';
|
|
151
|
+
ep.error = err.message;
|
|
152
|
+
this._broadcastStatus();
|
|
153
|
+
this._scheduleEndpointRetry(ep);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_endpointLost(ep) {
|
|
158
|
+
if (this.destroyed || ep.status === 'error') return;
|
|
159
|
+
ep.status = 'error';
|
|
160
|
+
ep.error = 'endpoint unreachable';
|
|
161
|
+
if (ep.pollTimer) { clearInterval(ep.pollTimer); ep.pollTimer = null; }
|
|
162
|
+
this._broadcastStatus();
|
|
163
|
+
this._scheduleEndpointRetry(ep);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_scheduleEndpointRetry(ep) {
|
|
167
|
+
if (this.destroyed) return;
|
|
168
|
+
ep.retryTimer = setTimeout(() => {
|
|
169
|
+
ep.backoffMs = Math.min(ep.backoffMs * 2, BACKOFF_MAX_MS);
|
|
170
|
+
this._handshake(ep);
|
|
171
|
+
}, ep.backoffMs);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async _pollSessions(ep) {
|
|
175
|
+
const list = await this._fetch(ep, '/sessions');
|
|
176
|
+
if (!Array.isArray(list)) return;
|
|
177
|
+
for (const info of list) {
|
|
178
|
+
const id = info.session;
|
|
179
|
+
if (!id || typeof id !== 'string') continue;
|
|
180
|
+
let s = ep.sessions.get(id);
|
|
181
|
+
if (!s) {
|
|
182
|
+
s = {
|
|
183
|
+
id,
|
|
184
|
+
started: info.started ?? null,
|
|
185
|
+
live: !!info.live,
|
|
186
|
+
ws: null,
|
|
187
|
+
lastSeq: 0,
|
|
188
|
+
ring: [],
|
|
189
|
+
overflow: 0,
|
|
190
|
+
unknownKinds: {},
|
|
191
|
+
reconnectTimer: null,
|
|
192
|
+
};
|
|
193
|
+
ep.sessions.set(id, s);
|
|
194
|
+
}
|
|
195
|
+
// §12: `live` means a turn is in flight; sessions persist between turns
|
|
196
|
+
// and events can start at any moment — stay attached regardless.
|
|
197
|
+
s.live = !!info.live;
|
|
198
|
+
if (!s.ws) this._watchSession(ep, s);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
_watchSession(ep, s) {
|
|
203
|
+
if (this.destroyed) return;
|
|
204
|
+
const since = s.lastSeq > 0 ? `?since=ev-${String(s.lastSeq).padStart(6, '0')}` : '';
|
|
205
|
+
const wsUrl = `${ep.url.replace(/^http/, 'ws')}/ws/session/${encodeURIComponent(s.id)}${since}`;
|
|
206
|
+
const ws = new WebSocket(wsUrl);
|
|
207
|
+
s.ws = ws;
|
|
208
|
+
|
|
209
|
+
ws.on('message', (data) => {
|
|
210
|
+
let envelope;
|
|
211
|
+
try { envelope = JSON.parse(data.toString()); } catch { return; }
|
|
212
|
+
const seq = seqOf(envelope.id);
|
|
213
|
+
// Dedup on ring-buffer replay after reconnect — ids are monotonic.
|
|
214
|
+
if (seq !== null && seq <= s.lastSeq) return;
|
|
215
|
+
if (seq !== null) s.lastSeq = seq;
|
|
216
|
+
if (s.ring.length >= this.ringSize) {
|
|
217
|
+
s.ring.shift();
|
|
218
|
+
// The ring bounds memory, not delivery: overflow is counted, never
|
|
219
|
+
// silent, and every event still broadcasts — mirrors the runtime.
|
|
220
|
+
s.overflow += 1;
|
|
221
|
+
}
|
|
222
|
+
s.ring.push(envelope);
|
|
223
|
+
if (envelope.kind && !KNOWN_KINDS.includes(envelope.kind)) {
|
|
224
|
+
s.unknownKinds[envelope.kind] = (s.unknownKinds[envelope.kind] || 0) + 1;
|
|
225
|
+
}
|
|
226
|
+
// Verbatim passthrough — the envelope is the contract, GROOVE adds
|
|
227
|
+
// routing metadata around it, never inside it.
|
|
228
|
+
this.daemon.broadcast({ type: 'axom:event', endpoint: ep.name, session: s.id, envelope });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const onGone = () => {
|
|
232
|
+
if (s.ws !== ws) return;
|
|
233
|
+
s.ws = null;
|
|
234
|
+
// Identity check, not name check: after configure() replaces an
|
|
235
|
+
// endpoint, a late close from the old socket must not re-arm a
|
|
236
|
+
// reconnect loop on the orphaned object — destroy() could never reach
|
|
237
|
+
// it (this exact leak wedged a test worker into an infinite loop).
|
|
238
|
+
if (this.destroyed || this.endpoints.get(ep.name) !== ep) return;
|
|
239
|
+
s.reconnectTimer = setTimeout(() => this._watchSession(ep, s), ep.backoffMs);
|
|
240
|
+
};
|
|
241
|
+
ws.on('close', onGone);
|
|
242
|
+
ws.on('error', onGone);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// §12 message verb: starts a turn (interrupt steers, stop halts). Status is
|
|
246
|
+
// part of the contract (202 accepted / 409 busy / 413 too_long) so this
|
|
247
|
+
// returns {status, body} verbatim instead of throwing on non-2xx.
|
|
248
|
+
async message(endpointName, sessionId, text) {
|
|
249
|
+
const ep = this._requireEndpoint(endpointName);
|
|
250
|
+
const res = await fetch(`${ep.url}/session/${encodeURIComponent(sessionId)}/message`, {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: { 'Content-Type': 'application/json' },
|
|
253
|
+
body: JSON.stringify({ text }),
|
|
254
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
255
|
+
});
|
|
256
|
+
const body = await res.json().catch(() => ({}));
|
|
257
|
+
// First message creates the session (caller-chosen id) — poll now so the
|
|
258
|
+
// WS attaches before the turn's first events age out of nothing.
|
|
259
|
+
if (res.status === 202) this._pollSessions(ep).catch(() => {});
|
|
260
|
+
return { status: res.status, body };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async interrupt(endpointName, sessionId, text) {
|
|
264
|
+
const ep = this._requireEndpoint(endpointName);
|
|
265
|
+
return this._fetch(ep, `/session/${encodeURIComponent(sessionId)}/interrupt`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: { 'Content-Type': 'application/json' },
|
|
268
|
+
body: JSON.stringify({ text }),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async stop(endpointName, sessionId) {
|
|
273
|
+
const ep = this._requireEndpoint(endpointName);
|
|
274
|
+
return this._fetch(ep, `/session/${encodeURIComponent(sessionId)}/stop`, {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: { 'Content-Type': 'application/json' },
|
|
277
|
+
body: JSON.stringify({}),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
_requireEndpoint(name) {
|
|
282
|
+
// Single-endpoint convenience: an omitted name resolves iff unambiguous.
|
|
283
|
+
if (!name && this.endpoints.size === 1) return this.endpoints.values().next().value;
|
|
284
|
+
const ep = this.endpoints.get(name);
|
|
285
|
+
if (!ep) throw new Error(`No Axom endpoint named "${name}"`);
|
|
286
|
+
return ep;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
events(endpointName, sessionId, sinceSeq = 0) {
|
|
290
|
+
const ep = this._requireEndpoint(endpointName);
|
|
291
|
+
const s = ep.sessions.get(sessionId);
|
|
292
|
+
if (!s) throw new Error(`No session "${sessionId}" on endpoint "${ep.name}"`);
|
|
293
|
+
const events = sinceSeq > 0 ? s.ring.filter((e) => (seqOf(e.id) || 0) > sinceSeq) : s.ring;
|
|
294
|
+
return { session: s.id, overflow: s.overflow, events };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
status() {
|
|
298
|
+
return {
|
|
299
|
+
endpoints: [...this.endpoints.values()].map((ep) => ({
|
|
300
|
+
name: ep.name,
|
|
301
|
+
url: ep.url,
|
|
302
|
+
status: ep.status,
|
|
303
|
+
error: ep.error,
|
|
304
|
+
about: ep.about,
|
|
305
|
+
drift: ep.drift,
|
|
306
|
+
sessions: [...ep.sessions.values()].map((s) => ({
|
|
307
|
+
session: s.id,
|
|
308
|
+
started: s.started,
|
|
309
|
+
live: s.live,
|
|
310
|
+
watching: !!s.ws,
|
|
311
|
+
lastEventId: s.lastSeq > 0 ? `ev-${String(s.lastSeq).padStart(6, '0')}` : null,
|
|
312
|
+
buffered: s.ring.length,
|
|
313
|
+
overflow: s.overflow,
|
|
314
|
+
unknownKinds: s.unknownKinds,
|
|
315
|
+
})),
|
|
316
|
+
})),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
_broadcastStatus() {
|
|
321
|
+
this.daemon.broadcast({ type: 'axom:status', data: this.status() });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_teardownEndpoint(ep) {
|
|
325
|
+
if (ep.pollTimer) clearInterval(ep.pollTimer);
|
|
326
|
+
if (ep.retryTimer) clearTimeout(ep.retryTimer);
|
|
327
|
+
for (const s of ep.sessions.values()) {
|
|
328
|
+
if (s.reconnectTimer) clearTimeout(s.reconnectTimer);
|
|
329
|
+
// terminate, not close: close() on a CONNECTING socket defers teardown
|
|
330
|
+
// until the connect completes — a live handle that can outlast us.
|
|
331
|
+
if (s.ws) { try { s.ws.terminate(); } catch { /* already gone */ } s.ws = null; }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
destroy() {
|
|
336
|
+
this.destroyed = true;
|
|
337
|
+
for (const ep of this.endpoints.values()) this._teardownEndpoint(ep);
|
|
338
|
+
this.endpoints.clear();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// GROOVE — Axom Installer (manifest-driven)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Installs the Axom runtime + models against an install manifest per contract
|
|
5
|
+
// §11. The GUI (and this module) NEVER hardcodes a source — dev vs launch is
|
|
6
|
+
// purely a manifest swap. Models are sha256-verified before anything is
|
|
7
|
+
// configured; a failed verify configures nothing.
|
|
8
|
+
//
|
|
9
|
+
// Manifest shape (§11):
|
|
10
|
+
// {runtime: {kind: "pip"|"wheel-url"|"tarball", ref, sha256},
|
|
11
|
+
// models: [{file, url, sha256, bytes}], min_version}
|
|
12
|
+
|
|
13
|
+
import { execFile } from 'child_process';
|
|
14
|
+
import { createHash } from 'crypto';
|
|
15
|
+
import { createWriteStream } from 'fs';
|
|
16
|
+
import { mkdir, rename, rm } from 'fs/promises';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
import { pipeline } from 'stream/promises';
|
|
20
|
+
import { Transform } from 'stream';
|
|
21
|
+
import { AXOM_REQUIREMENTS } from './axom-server.js';
|
|
22
|
+
|
|
23
|
+
export class AxomInstaller {
|
|
24
|
+
constructor(daemon, opts = {}) {
|
|
25
|
+
this.daemon = daemon;
|
|
26
|
+
this.pipCommand = opts.pipCommand || ['python3', '-m', 'pip'];
|
|
27
|
+
this.totalRamGbOverride = opts.totalRamGb; // tests inject; prod reads os
|
|
28
|
+
this.status = { phase: 'idle', file: null, receivedBytes: 0, totalBytes: 0, error: null };
|
|
29
|
+
this._running = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getStatus() {
|
|
33
|
+
return { ...this.status };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_update(patch) {
|
|
37
|
+
this.status = { ...this.status, ...patch };
|
|
38
|
+
this.daemon.broadcast({ type: 'axom:install:progress', data: this.getStatus() });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async install(manifestUrl) {
|
|
42
|
+
const url = manifestUrl || this.daemon.config?.axom?.manifestUrl;
|
|
43
|
+
if (!url) throw new Error('no install manifest configured (axom.manifestUrl)');
|
|
44
|
+
if (this._running) throw new Error('an install is already running');
|
|
45
|
+
// Same hardware floor as the instance manager — never download 4GB of
|
|
46
|
+
// weights onto a machine that can't safely run them. Manifest min_ram_gb
|
|
47
|
+
// (when present) is the authority; the static floor is the fallback.
|
|
48
|
+
if (!this.daemon.config?.axom?.allowUnderspec) {
|
|
49
|
+
const totalRamGb = this.totalRamGbOverride ?? os.totalmem() / 2 ** 30;
|
|
50
|
+
if (totalRamGb < AXOM_REQUIREMENTS.minRamGb) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`This machine has ${Math.round(totalRamGb)}GB RAM — below Axom's ${AXOM_REQUIREMENTS.minRamGb}GB floor for local inference. `
|
|
53
|
+
+ 'Connect to a remote Axom endpoint instead.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
this._running = true;
|
|
58
|
+
try {
|
|
59
|
+
this._update({ phase: 'manifest', file: null, receivedBytes: 0, totalBytes: 0, error: null });
|
|
60
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
|
61
|
+
if (!res.ok) throw new Error(`manifest fetch failed: HTTP ${res.status}`);
|
|
62
|
+
const manifest = await res.json();
|
|
63
|
+
if (!Array.isArray(manifest.models)) throw new Error('manifest has no models list');
|
|
64
|
+
|
|
65
|
+
if (manifest.runtime) await this._installRuntime(manifest.runtime);
|
|
66
|
+
|
|
67
|
+
const modelDir = this.daemon.axomServer?._modelDir()
|
|
68
|
+
|| join(this.daemon.grooveDir, 'axom', 'models');
|
|
69
|
+
await mkdir(modelDir, { recursive: true });
|
|
70
|
+
for (const model of manifest.models) {
|
|
71
|
+
await this._downloadVerified(model, modelDir);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this._update({ phase: 'done', file: null });
|
|
75
|
+
this.daemon.audit.log('axom.install.complete', { models: manifest.models.length });
|
|
76
|
+
return { ok: true, models: manifest.models.length };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
this._update({ phase: 'error', error: err.message });
|
|
79
|
+
throw err;
|
|
80
|
+
} finally {
|
|
81
|
+
this._running = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async _installRuntime(runtime) {
|
|
86
|
+
this._update({ phase: 'runtime', file: runtime.ref || null });
|
|
87
|
+
if (runtime.kind === 'pip') {
|
|
88
|
+
const [cmd, ...base] = this.pipCommand;
|
|
89
|
+
await new Promise((resolve, reject) => {
|
|
90
|
+
execFile(cmd, [...base, 'install', runtime.ref], { timeout: 600000 }, (err, _out, stderr) => {
|
|
91
|
+
if (err) reject(new Error(`pip install failed: ${stderr?.slice(-400) || err.message}`));
|
|
92
|
+
else resolve();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Honest v0 boundary — no silent no-op for kinds we don't handle yet.
|
|
98
|
+
throw new Error(`runtime kind "${runtime.kind}" is not supported by this GROOVE version`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async _downloadVerified({ file, url, sha256, bytes }, modelDir) {
|
|
102
|
+
if (!file || !/^[a-zA-Z0-9._-]+$/.test(file)) throw new Error(`unsafe model filename: ${file}`);
|
|
103
|
+
if (!sha256) throw new Error(`manifest entry ${file} has no sha256 — refusing unverifiable download`);
|
|
104
|
+
const finalPath = join(modelDir, file);
|
|
105
|
+
const tmpPath = `${finalPath}.part`;
|
|
106
|
+
this._update({ phase: 'models', file, receivedBytes: 0, totalBytes: bytes || 0 });
|
|
107
|
+
|
|
108
|
+
const res = await fetch(url);
|
|
109
|
+
if (!res.ok || !res.body) throw new Error(`download failed for ${file}: HTTP ${res.status}`);
|
|
110
|
+
|
|
111
|
+
const hash = createHash('sha256');
|
|
112
|
+
let received = 0;
|
|
113
|
+
let lastBroadcast = 0;
|
|
114
|
+
const meter = new Transform({
|
|
115
|
+
transform: (chunk, _enc, cb) => {
|
|
116
|
+
hash.update(chunk);
|
|
117
|
+
received += chunk.length;
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
if (now - lastBroadcast > 500) { // progress, throttled
|
|
120
|
+
lastBroadcast = now;
|
|
121
|
+
this._update({ receivedBytes: received });
|
|
122
|
+
}
|
|
123
|
+
cb(null, chunk);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await pipeline(res.body, meter, createWriteStream(tmpPath));
|
|
129
|
+
const digest = hash.digest('hex');
|
|
130
|
+
if (digest !== sha256.toLowerCase()) {
|
|
131
|
+
throw new Error(`sha256 mismatch for ${file}: expected ${sha256}, got ${digest}`);
|
|
132
|
+
}
|
|
133
|
+
await rename(tmpPath, finalPath);
|
|
134
|
+
this._update({ receivedBytes: received });
|
|
135
|
+
} catch (err) {
|
|
136
|
+
await rm(tmpPath, { force: true });
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|