groove-dev 0.27.197 → 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 +2 -0
- 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/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +11 -0
- 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 +1 -1
- 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/axom.js +196 -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/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-Da2nbd6M.js → index-b9dKN6cq.js} +259 -228
- 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 -0
- package/node_modules/@groove-dev/gui/src/app.css +53 -0
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -0
- 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/settings.jsx +178 -3
- 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 +2 -0
- 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/federation.js +6 -0
- package/packages/daemon/src/index.js +11 -0
- 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 +1 -1
- package/packages/daemon/src/providers/axom.js +66 -0
- package/packages/daemon/src/providers/index.js +2 -0
- package/packages/daemon/src/routes/axom.js +196 -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-Da2nbd6M.js → index-b9dKN6cq.js} +259 -228
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +2 -0
- package/packages/gui/src/app.css +53 -0
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
- package/packages/gui/src/stores/groove.js +19 -0
- 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/settings.jsx +178 -3
- package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +0 -1
- package/packages/gui/dist/assets/index-zmrIbwNm.css +0 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
3
|
import { MAX_EXCHANGES } from '../innerchat.js';
|
|
4
|
+
import { validatePeer, parsePeerRef } from '../innerchat-relay.js';
|
|
5
|
+
import { saveConfig } from '../firstrun.js';
|
|
4
6
|
|
|
5
7
|
// Agents know each other by name, not id. Exact matches win first so
|
|
6
8
|
// `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
|
|
@@ -22,14 +24,16 @@ function resolveAgent(daemon, ref) {
|
|
|
22
24
|
return partial.length === 1 ? partial[0] : null;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
function resolveParties(daemon, req, res) {
|
|
28
|
-
const { from, to, message } = req.body || {};
|
|
29
|
-
|
|
27
|
+
// The calling agent is always local. Writes a 404 and returns null on miss.
|
|
28
|
+
function resolveFrom(daemon, from, res) {
|
|
30
29
|
const fromAgent = resolveAgent(daemon, from);
|
|
31
30
|
if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
|
|
31
|
+
return fromAgent;
|
|
32
|
+
}
|
|
32
33
|
|
|
34
|
+
// Resolve a LOCAL to/message pair, or write the appropriate 400/404 and return
|
|
35
|
+
// null. Used only when `to` is a plain (non-peer) name.
|
|
36
|
+
function resolveLocalTo(daemon, fromAgent, to, message, res) {
|
|
33
37
|
const toAgent = resolveAgent(daemon, to);
|
|
34
38
|
if (!toAgent) {
|
|
35
39
|
const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
|
|
@@ -42,32 +46,63 @@ function resolveParties(daemon, req, res) {
|
|
|
42
46
|
}
|
|
43
47
|
return null;
|
|
44
48
|
}
|
|
45
|
-
|
|
46
49
|
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
47
50
|
res.status(400).json({ error: 'message is required' }); return null;
|
|
48
51
|
}
|
|
49
|
-
return {
|
|
52
|
+
return { toAgent, message: message.trim() };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Confirm a relay request is from a configured, signature-verified peer.
|
|
56
|
+
// Returns the peer record, or writes a 403 and returns null.
|
|
57
|
+
function verifyRelayCaller(daemon, body, res) {
|
|
58
|
+
const { payload, signature } = body || {};
|
|
59
|
+
if (!payload || typeof payload !== 'object' || !signature) {
|
|
60
|
+
res.status(400).json({ error: 'signed relay envelope required ({ payload, signature })' });
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const fromDaemonId = payload.fromDaemonId;
|
|
64
|
+
const peer = daemon.innerchat._peerByDaemonId(fromDaemonId);
|
|
65
|
+
if (!peer) {
|
|
66
|
+
res.status(403).json({ error: `Unknown daemon "${String(fromDaemonId).slice(0, 16)}" — not a configured InnerChat peer.` });
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!daemon.federation.verify(fromDaemonId, payload, signature)) {
|
|
70
|
+
res.status(403).json({ error: 'Signature verification failed.' });
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return peer;
|
|
50
74
|
}
|
|
51
75
|
|
|
52
76
|
export function registerInnerChatRoutes(app, daemon) {
|
|
53
77
|
/**
|
|
54
78
|
* Ask another agent a question and BLOCK until it answers.
|
|
55
79
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* interface negotiation, where the finite exchange budget keeps both sides
|
|
59
|
-
* writing decision-dense messages.
|
|
80
|
+
* `to` may be a local name or `name@peer`. A peer target is relayed to the
|
|
81
|
+
* configured peer daemon; the reply rides the still-open response back.
|
|
60
82
|
*/
|
|
61
83
|
app.post('/api/innerchat/ask', async (req, res) => {
|
|
62
84
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
85
|
+
const { from, to, message } = req.body || {};
|
|
86
|
+
const fromAgent = resolveFrom(daemon, from, res);
|
|
87
|
+
if (!fromAgent) return;
|
|
65
88
|
|
|
66
89
|
// Held open until the target answers — see the class doc in innerchat.js.
|
|
67
90
|
req.setTimeout(0);
|
|
68
91
|
res.setTimeout(0);
|
|
69
92
|
|
|
70
|
-
|
|
93
|
+
if (daemon.innerchat.isRemoteRef(to)) {
|
|
94
|
+
const { name, alias } = parsePeerRef(to);
|
|
95
|
+
if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
|
|
96
|
+
const result = await daemon.innerchat.askRemote(fromAgent, name, alias, String(message).trim(), {
|
|
97
|
+
timeoutMs: req.body?.timeoutMs,
|
|
98
|
+
});
|
|
99
|
+
return res.json({ from: `${name}@${alias}`, reply: result.reply, threadId: result.threadId, remote: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
|
|
103
|
+
if (!parties) return;
|
|
104
|
+
|
|
105
|
+
const result = await daemon.innerchat.ask(fromAgent.id, parties.toAgent.id, parties.message, {
|
|
71
106
|
timeoutMs: req.body?.timeoutMs,
|
|
72
107
|
});
|
|
73
108
|
|
|
@@ -86,17 +121,34 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
86
121
|
});
|
|
87
122
|
|
|
88
123
|
/**
|
|
89
|
-
* Send a message WITHOUT blocking
|
|
90
|
-
* target
|
|
91
|
-
*
|
|
92
|
-
* where waiting out a timeout would waste the turn.
|
|
124
|
+
* Send a message WITHOUT blocking. `to` may be local or `name@peer`. For a
|
|
125
|
+
* peer target the reply (if any) is queued on the peer and picked up by this
|
|
126
|
+
* daemon's outbox poll, then routed back to the sender.
|
|
93
127
|
*/
|
|
94
128
|
app.post('/api/innerchat/tell', async (req, res) => {
|
|
95
129
|
try {
|
|
96
|
-
const
|
|
130
|
+
const { from, to, message } = req.body || {};
|
|
131
|
+
const fromAgent = resolveFrom(daemon, from, res);
|
|
132
|
+
if (!fromAgent) return;
|
|
133
|
+
|
|
134
|
+
if (daemon.innerchat.isRemoteRef(to)) {
|
|
135
|
+
const { name, alias } = parsePeerRef(to);
|
|
136
|
+
if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
|
|
137
|
+
const result = await daemon.innerchat.tellRemote(fromAgent, name, alias, String(message).trim());
|
|
138
|
+
return res.json({
|
|
139
|
+
ok: true,
|
|
140
|
+
to: `${name}@${alias}`,
|
|
141
|
+
delivered: result.delivered,
|
|
142
|
+
threadId: result.threadId,
|
|
143
|
+
remote: true,
|
|
144
|
+
note: `Message relayed to ${name}@${alias}. Its reply, if any, will be routed back to you — you can end your turn.`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
|
|
97
149
|
if (!parties) return;
|
|
98
150
|
|
|
99
|
-
const result = await daemon.innerchat.tell(
|
|
151
|
+
const result = await daemon.innerchat.tell(fromAgent.id, parties.toAgent.id, parties.message, {
|
|
100
152
|
threadId: req.body?.threadId,
|
|
101
153
|
});
|
|
102
154
|
|
|
@@ -115,6 +167,86 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
115
167
|
}
|
|
116
168
|
});
|
|
117
169
|
|
|
170
|
+
// ── Cross-daemon relay endpoints (peer daemon → this daemon) ──
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Receive a relayed ask/tell from a peer daemon. Signature-verified and
|
|
174
|
+
* restricted to configured peers. Resolves the target locally and delivers
|
|
175
|
+
* with a guest identity. For 'ask' the response is held open until the local
|
|
176
|
+
* agent answers.
|
|
177
|
+
*/
|
|
178
|
+
app.post('/api/innerchat/relay', async (req, res) => {
|
|
179
|
+
const peer = verifyRelayCaller(daemon, req.body, res);
|
|
180
|
+
if (!peer) return;
|
|
181
|
+
const p = req.body.payload;
|
|
182
|
+
if (p.type !== 'innerchat.relay' || (p.kind !== 'ask' && p.kind !== 'tell')) {
|
|
183
|
+
return res.status(400).json({ error: 'invalid relay payload' });
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
req.setTimeout(0);
|
|
187
|
+
res.setTimeout(0);
|
|
188
|
+
const result = await daemon.innerchat.receiveRelay({
|
|
189
|
+
fromName: p.fromName,
|
|
190
|
+
fromRole: p.fromRole,
|
|
191
|
+
fromDaemonId: p.fromDaemonId,
|
|
192
|
+
peerAlias: peer.alias,
|
|
193
|
+
toName: p.toName,
|
|
194
|
+
message: p.message,
|
|
195
|
+
kind: p.kind,
|
|
196
|
+
threadId: p.threadId,
|
|
197
|
+
timeoutMs: p.timeoutMs,
|
|
198
|
+
});
|
|
199
|
+
res.json(result);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const body = { error: err.message };
|
|
202
|
+
if (err.availableAgents) body.availableAgents = err.availableAgents;
|
|
203
|
+
res.status(404).json(body);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Drain this daemon's outbox of async (tell) replies queued for the calling
|
|
209
|
+
* peer. Signature-verified; returns and removes only that peer's entries.
|
|
210
|
+
*/
|
|
211
|
+
app.post('/api/innerchat/relay/outbox', (req, res) => {
|
|
212
|
+
const peer = verifyRelayCaller(daemon, req.body, res);
|
|
213
|
+
if (!peer) return;
|
|
214
|
+
if (req.body.payload.type !== 'innerchat.outbox') {
|
|
215
|
+
return res.status(400).json({ error: 'invalid outbox payload' });
|
|
216
|
+
}
|
|
217
|
+
res.json({ entries: daemon.innerchat.drainOutbox(req.body.payload.fromDaemonId) });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── Peer configuration (alias ↔ url ↔ daemonId) ──────────────
|
|
221
|
+
|
|
222
|
+
app.get('/api/innerchat/peers', (req, res) => {
|
|
223
|
+
res.json({ peers: daemon.innerchat._peers() });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
app.post('/api/innerchat/peers', (req, res) => {
|
|
227
|
+
const entry = { alias: req.body?.alias, url: req.body?.url, daemonId: req.body?.daemonId };
|
|
228
|
+
const err = validatePeer(entry);
|
|
229
|
+
if (err) return res.status(400).json({ error: err });
|
|
230
|
+
entry.url = entry.url.replace(/\/+$/, '');
|
|
231
|
+
|
|
232
|
+
const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
|
|
233
|
+
const next = peers.filter((p) => p.alias !== entry.alias);
|
|
234
|
+
next.push(entry);
|
|
235
|
+
daemon.config.innerchatPeers = next;
|
|
236
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
237
|
+
daemon.audit.log('innerchat.peer.set', { alias: entry.alias, url: entry.url });
|
|
238
|
+
res.json({ peers: next });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
app.delete('/api/innerchat/peers/:alias', (req, res) => {
|
|
242
|
+
const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
|
|
243
|
+
const next = peers.filter((p) => p.alias !== req.params.alias);
|
|
244
|
+
daemon.config.innerchatPeers = next;
|
|
245
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
246
|
+
daemon.audit.log('innerchat.peer.remove', { alias: req.params.alias });
|
|
247
|
+
res.json({ peers: next });
|
|
248
|
+
});
|
|
249
|
+
|
|
118
250
|
app.get('/api/innerchat/threads', (req, res) => {
|
|
119
251
|
const { agentId } = req.query;
|
|
120
252
|
res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// GROOVE — Axom Connector Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Runs the connector against a mock bridge speaking the Axom provider
|
|
5
|
+
// protocol (GROOVE ⇄ Axom Integration Contract v0). The mock is contract-
|
|
6
|
+
// faithful: /about with kinds, /sessions, per-session WS with ?since replay,
|
|
7
|
+
// interrupt with 2000-char truncation, sticky idempotent stop.
|
|
8
|
+
|
|
9
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { createServer } from 'http';
|
|
12
|
+
import { WebSocketServer } from 'ws';
|
|
13
|
+
import { AxomConnector, KNOWN_KINDS, validateEndpoint } from '../src/axom-connector.js';
|
|
14
|
+
|
|
15
|
+
function envelope(seq, kind, payload = {}, session = 's-test0001') {
|
|
16
|
+
return {
|
|
17
|
+
id: `ev-${String(seq).padStart(6, '0')}`,
|
|
18
|
+
ts: 1753000000 + seq,
|
|
19
|
+
session,
|
|
20
|
+
firing_id: null,
|
|
21
|
+
step: null,
|
|
22
|
+
kind,
|
|
23
|
+
payload,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Contract-faithful mock of `axom serve`'s bridge surface.
|
|
28
|
+
class MockBridge {
|
|
29
|
+
constructor({ kinds = [...KNOWN_KINDS], sessions } = {}) {
|
|
30
|
+
this.kinds = kinds;
|
|
31
|
+
this.sessions = sessions || { 's-test0001': { started: 1753000000, live: true, events: [] } };
|
|
32
|
+
this.interrupts = [];
|
|
33
|
+
this.stops = [];
|
|
34
|
+
this.sinceSeen = []; // ?since values observed on WS connects
|
|
35
|
+
this.sockets = new Set();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async start(port = 0) {
|
|
39
|
+
this.server = createServer((req, res) => this._http(req, res));
|
|
40
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
41
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
42
|
+
const url = new URL(req.url, 'http://localhost');
|
|
43
|
+
const match = url.pathname.match(/^\/ws\/session\/([^/]+)$/);
|
|
44
|
+
const session = match && this.sessions[decodeURIComponent(match[1])];
|
|
45
|
+
if (!session) { socket.destroy(); return; }
|
|
46
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
47
|
+
this.sockets.add(ws);
|
|
48
|
+
ws.on('close', () => this.sockets.delete(ws));
|
|
49
|
+
const since = url.searchParams.get('since');
|
|
50
|
+
this.sinceSeen.push(since);
|
|
51
|
+
// Ring replay: everything after `since`, then live.
|
|
52
|
+
const from = since ? parseInt(since.slice(3), 10) : 0;
|
|
53
|
+
for (const e of session.events) {
|
|
54
|
+
if (parseInt(e.id.slice(3), 10) > from) ws.send(JSON.stringify(e));
|
|
55
|
+
}
|
|
56
|
+
session.socket = ws;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
this.server.once('error', reject);
|
|
61
|
+
this.server.listen(port, '127.0.0.1', resolve);
|
|
62
|
+
});
|
|
63
|
+
this.url = `http://127.0.0.1:${this.server.address().port}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
emit(sessionId, env) {
|
|
67
|
+
const session = this.sessions[sessionId];
|
|
68
|
+
session.events.push(env);
|
|
69
|
+
if (session.socket && session.socket.readyState === 1) {
|
|
70
|
+
session.socket.send(JSON.stringify(env));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_http(req, res) {
|
|
75
|
+
const url = new URL(req.url, 'http://localhost');
|
|
76
|
+
const json = (code, body) => {
|
|
77
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
78
|
+
res.end(JSON.stringify(body));
|
|
79
|
+
};
|
|
80
|
+
if (req.method === 'GET' && url.pathname === '/about') {
|
|
81
|
+
return json(200, {
|
|
82
|
+
name: 'axom', version: '0.1.0', family: 'phi4mini-s1',
|
|
83
|
+
record: { benchmark: 'HumanEval+', 'pass@1': '73.78%' },
|
|
84
|
+
chassis: { model: 'phi4mini_stock_q8.gguf', loaded: true },
|
|
85
|
+
leaves: [{ name: 'chat', loaded: true }, { name: 'agentic', loaded: true }],
|
|
86
|
+
narrator: 'template',
|
|
87
|
+
kinds: this.kinds,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (req.method === 'GET' && url.pathname === '/sessions') {
|
|
91
|
+
return json(200, Object.entries(this.sessions).map(([id, s]) => ({
|
|
92
|
+
session: id, started: s.started, live: s.live,
|
|
93
|
+
})));
|
|
94
|
+
}
|
|
95
|
+
const interrupt = url.pathname.match(/^\/session\/([^/]+)\/interrupt$/);
|
|
96
|
+
const stop = url.pathname.match(/^\/session\/([^/]+)\/stop$/);
|
|
97
|
+
const message = url.pathname.match(/^\/session\/([^/]+)\/message$/);
|
|
98
|
+
if (req.method === 'POST' && (interrupt || stop || message)) {
|
|
99
|
+
let body = '';
|
|
100
|
+
req.on('data', (c) => { body += c; });
|
|
101
|
+
req.on('end', () => {
|
|
102
|
+
if (interrupt) {
|
|
103
|
+
const { text } = JSON.parse(body || '{}');
|
|
104
|
+
this.interrupts.push({ session: interrupt[1], text });
|
|
105
|
+
return json(200, { id: `int-${this.interrupts.length}`, truncated: text.length > 2000 });
|
|
106
|
+
}
|
|
107
|
+
if (message) {
|
|
108
|
+
// §12 semantics: caller-chosen id, first message creates; one turn
|
|
109
|
+
// at a time; hard max with 413, never truncation.
|
|
110
|
+
const id = decodeURIComponent(message[1]);
|
|
111
|
+
const { text } = JSON.parse(body || '{}');
|
|
112
|
+
if (text.length > 32768) return json(413, { error: 'too_long', max: 32768 });
|
|
113
|
+
let session = this.sessions[id];
|
|
114
|
+
if (!session) { session = this.sessions[id] = { started: Date.now() / 1000, live: false, events: [] }; }
|
|
115
|
+
if (session.live) return json(409, { error: 'busy' });
|
|
116
|
+
session.live = true;
|
|
117
|
+
return json(202, { session: id, accepted: true });
|
|
118
|
+
}
|
|
119
|
+
this.stops.push({ session: stop[1] });
|
|
120
|
+
return json(200, { ok: true });
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
json(404, { error: 'not found' });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async close() {
|
|
128
|
+
for (const ws of this.sockets) ws.terminate();
|
|
129
|
+
this.wss?.close();
|
|
130
|
+
this.server.closeAllConnections?.();
|
|
131
|
+
await new Promise((r) => this.server.close(r));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fakeDaemon() {
|
|
136
|
+
const broadcasts = [];
|
|
137
|
+
return {
|
|
138
|
+
broadcasts,
|
|
139
|
+
config: { axom: { endpoints: [] } },
|
|
140
|
+
broadcast: (m) => broadcasts.push(m),
|
|
141
|
+
audit: { log() {} },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function waitFor(predicate, ms = 3000) {
|
|
146
|
+
const deadline = Date.now() + ms;
|
|
147
|
+
while (Date.now() < deadline) {
|
|
148
|
+
if (predicate()) return;
|
|
149
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
150
|
+
}
|
|
151
|
+
throw new Error('waitFor timed out');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe('AxomConnector', () => {
|
|
155
|
+
let bridge, daemon, connector;
|
|
156
|
+
|
|
157
|
+
beforeEach(async () => {
|
|
158
|
+
bridge = new MockBridge();
|
|
159
|
+
await bridge.start();
|
|
160
|
+
daemon = fakeDaemon();
|
|
161
|
+
connector = new AxomConnector(daemon, { backoffBaseMs: 50, sessionPollMs: 100 });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterEach(async () => {
|
|
165
|
+
connector.destroy();
|
|
166
|
+
await bridge.close();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
function connect(name = 'local') {
|
|
170
|
+
connector.configure([{ name, url: bridge.url }]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
it('handshakes via /about and reports a clean schema match', async () => {
|
|
174
|
+
connect();
|
|
175
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
176
|
+
const ep = connector.status().endpoints[0];
|
|
177
|
+
assert.equal(ep.about.family, 'phi4mini-s1');
|
|
178
|
+
assert.equal(ep.about.narrator, 'template');
|
|
179
|
+
assert.deepEqual(ep.drift, { novel: [], missing: [] });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('surfaces schema drift in both directions without dropping anything', async () => {
|
|
183
|
+
await bridge.close();
|
|
184
|
+
bridge = new MockBridge({ kinds: [...KNOWN_KINDS.filter((k) => k !== 'leaf_swap'), 'brand_new_kind'] });
|
|
185
|
+
await bridge.start();
|
|
186
|
+
connect();
|
|
187
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
188
|
+
const ep = connector.status().endpoints[0];
|
|
189
|
+
assert.deepEqual(ep.drift.novel, ['brand_new_kind']);
|
|
190
|
+
assert.deepEqual(ep.drift.missing, ['leaf_swap']);
|
|
191
|
+
assert.equal(ep.status, 'connected'); // drift is surfaced, never fatal
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('discovers live sessions and passes envelopes through verbatim', async () => {
|
|
195
|
+
connect();
|
|
196
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
197
|
+
const env = envelope(1, 'pipeline_start', { firing_id: 'f-1' });
|
|
198
|
+
bridge.emit('s-test0001', env);
|
|
199
|
+
await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:event'));
|
|
200
|
+
const msg = daemon.broadcasts.find((b) => b.type === 'axom:event');
|
|
201
|
+
assert.equal(msg.endpoint, 'local');
|
|
202
|
+
assert.equal(msg.session, 's-test0001');
|
|
203
|
+
assert.deepEqual(msg.envelope, env); // byte-faithful passthrough
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('tolerates unknown kinds — passed through and counted, never dropped', async () => {
|
|
207
|
+
connect();
|
|
208
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
209
|
+
bridge.emit('s-test0001', envelope(1, 'kind_from_the_future', { x: 1 }));
|
|
210
|
+
await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:event'));
|
|
211
|
+
assert.equal(daemon.broadcasts.find((b) => b.type === 'axom:event').envelope.kind, 'kind_from_the_future');
|
|
212
|
+
const s = connector.status().endpoints[0].sessions[0];
|
|
213
|
+
assert.equal(s.unknownKinds.kind_from_the_future, 1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('resumes with ?since after reconnect and dedups the ring replay', async () => {
|
|
217
|
+
connect();
|
|
218
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
219
|
+
bridge.emit('s-test0001', envelope(1, 'pipeline_start'));
|
|
220
|
+
bridge.emit('s-test0001', envelope(2, 'thought', { text: 'hm' }));
|
|
221
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 2);
|
|
222
|
+
|
|
223
|
+
// Kill the socket server-side; connector should reconnect with since=ev-000002.
|
|
224
|
+
bridge.sessions['s-test0001'].socket.terminate();
|
|
225
|
+
await waitFor(() => bridge.sinceSeen.length === 2);
|
|
226
|
+
assert.equal(bridge.sinceSeen[1], 'ev-000002');
|
|
227
|
+
|
|
228
|
+
// Replayed history must not re-broadcast; a genuinely new event must.
|
|
229
|
+
bridge.emit('s-test0001', envelope(3, 'resolution'));
|
|
230
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 3);
|
|
231
|
+
const seqs = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.id);
|
|
232
|
+
assert.deepEqual(seqs, ['ev-000001', 'ev-000002', 'ev-000003']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('proxies interrupt verbatim including the runtime truncation flag', async () => {
|
|
236
|
+
connect();
|
|
237
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
238
|
+
const short = await connector.interrupt('local', 's-test0001', 'skip the auth module');
|
|
239
|
+
assert.deepEqual(short, { id: 'int-1', truncated: false });
|
|
240
|
+
const long = await connector.interrupt('local', 's-test0001', 'x'.repeat(2500));
|
|
241
|
+
assert.equal(long.truncated, true); // the runtime is the truncation authority
|
|
242
|
+
assert.equal(bridge.interrupts.length, 2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('proxies stop and resolves the endpoint when unambiguous', async () => {
|
|
246
|
+
connect();
|
|
247
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
248
|
+
// Omitted endpoint name resolves with exactly one endpoint configured.
|
|
249
|
+
const result = await connector.stop(undefined, 's-test0001');
|
|
250
|
+
assert.deepEqual(result, { ok: true });
|
|
251
|
+
assert.deepEqual(bridge.stops, [{ session: 's-test0001' }]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('counts ring overflow instead of dropping silently', async () => {
|
|
255
|
+
connector.destroy();
|
|
256
|
+
connector = new AxomConnector(daemon, { ringSize: 2, backoffBaseMs: 50, sessionPollMs: 100 });
|
|
257
|
+
connect();
|
|
258
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
259
|
+
for (let i = 1; i <= 5; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
260
|
+
await waitFor(() => connector.status().endpoints[0].sessions[0].overflow === 3);
|
|
261
|
+
const s = connector.status().endpoints[0].sessions[0];
|
|
262
|
+
assert.equal(s.buffered, 2);
|
|
263
|
+
assert.equal(s.overflow, 3);
|
|
264
|
+
// All five still broadcast — the ring bounds memory, not delivery.
|
|
265
|
+
assert.equal(daemon.broadcasts.filter((b) => b.type === 'axom:event').length, 5);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('serves buffered events with a since cursor for GUI backfill', async () => {
|
|
269
|
+
connect();
|
|
270
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
271
|
+
for (let i = 1; i <= 3; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
272
|
+
await waitFor(() => connector.status().endpoints[0].sessions[0].buffered === 3);
|
|
273
|
+
const all = connector.events('local', 's-test0001');
|
|
274
|
+
assert.equal(all.events.length, 3);
|
|
275
|
+
const tail = connector.events('local', 's-test0001', 2);
|
|
276
|
+
assert.deepEqual(tail.events.map((e) => e.id), ['ev-000003']);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('isolates concurrent sessions — streams, backfill cursors, and rings never cross', async () => {
|
|
280
|
+
connector.destroy();
|
|
281
|
+
await bridge.close();
|
|
282
|
+
bridge = new MockBridge({
|
|
283
|
+
sessions: {
|
|
284
|
+
's-alpha': { started: 1, live: true, events: [] },
|
|
285
|
+
's-beta': { started: 2, live: true, events: [] },
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
await bridge.start();
|
|
289
|
+
connector = new AxomConnector(daemon, { ringSize: 2, backoffBaseMs: 50, sessionPollMs: 100 });
|
|
290
|
+
connect();
|
|
291
|
+
await waitFor(() => {
|
|
292
|
+
const sessions = connector.status().endpoints[0]?.sessions || [];
|
|
293
|
+
return sessions.length === 2 && sessions.every((s) => s.watching);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
bridge.emit('s-alpha', envelope(1, 'pipeline_start', {}, 's-alpha'));
|
|
297
|
+
bridge.emit('s-beta', envelope(1, 'thought', { text: 'b' }, 's-beta'));
|
|
298
|
+
for (let i = 2; i <= 4; i++) bridge.emit('s-alpha', envelope(i, 'thought', {}, 's-alpha'));
|
|
299
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 5);
|
|
300
|
+
|
|
301
|
+
// Broadcast routing: every envelope tagged with its own session, only.
|
|
302
|
+
for (const b of daemon.broadcasts.filter((x) => x.type === 'axom:event')) {
|
|
303
|
+
assert.equal(b.envelope.session, b.session);
|
|
304
|
+
}
|
|
305
|
+
// Backfill cursors never leak across sessions.
|
|
306
|
+
const alpha = connector.events('local', 's-alpha');
|
|
307
|
+
const beta = connector.events('local', 's-beta');
|
|
308
|
+
assert.ok(alpha.events.every((e) => e.session === 's-alpha'));
|
|
309
|
+
assert.deepEqual(beta.events.map((e) => e.session), ['s-beta']);
|
|
310
|
+
// Rings overflow independently: alpha (4 events, ring 2) overflowed, beta did not.
|
|
311
|
+
assert.equal(alpha.overflow, 2);
|
|
312
|
+
assert.equal(beta.overflow, 0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('passes a second stop through cleanly — sticky idempotence is preserved end to end', async () => {
|
|
316
|
+
connect();
|
|
317
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
318
|
+
const first = await connector.stop('local', 's-test0001');
|
|
319
|
+
const second = await connector.stop('local', 's-test0001');
|
|
320
|
+
// The runtime's stop is sticky-idempotent; the proxy must neither error,
|
|
321
|
+
// dedupe, nor add semantics — both calls land, both return {ok}.
|
|
322
|
+
assert.deepEqual(first, { ok: true });
|
|
323
|
+
assert.deepEqual(second, { ok: true });
|
|
324
|
+
assert.equal(bridge.stops.length, 2);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('survives a WS drop mid-flow with no gap and no duplicate when the producer never paused', async () => {
|
|
328
|
+
connect();
|
|
329
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
330
|
+
for (let i = 1; i <= 3; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
331
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 3);
|
|
332
|
+
|
|
333
|
+
// Drop the socket while the producer keeps firing into the runtime ring.
|
|
334
|
+
bridge.sessions['s-test0001'].socket.terminate();
|
|
335
|
+
for (let i = 4; i <= 6; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
|
|
336
|
+
|
|
337
|
+
// Reconnect replays 4-6 from the ring via ?since, then goes live for 7-8.
|
|
338
|
+
await waitFor(() => bridge.sinceSeen.length === 2);
|
|
339
|
+
bridge.emit('s-test0001', envelope(7, 'thought'));
|
|
340
|
+
bridge.emit('s-test0001', envelope(8, 'resolution'));
|
|
341
|
+
await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 8);
|
|
342
|
+
const ids = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.id);
|
|
343
|
+
assert.deepEqual(ids, Array.from({ length: 8 }, (_, i) => `ev-${String(i + 1).padStart(6, '0')}`));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('message creates a caller-chosen session (202) and the connector attaches to it', async () => {
|
|
347
|
+
connect();
|
|
348
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
349
|
+
const result = await connector.message('local', 's-mine-0001', 'hello axom');
|
|
350
|
+
assert.equal(result.status, 202);
|
|
351
|
+
assert.deepEqual(result.body, { session: 's-mine-0001', accepted: true });
|
|
352
|
+
// The post-202 poll discovers and watches the new session.
|
|
353
|
+
await waitFor(() => {
|
|
354
|
+
const s = connector.status().endpoints[0].sessions.find((x) => x.session === 's-mine-0001');
|
|
355
|
+
return s && s.watching;
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('mirrors §12 contract statuses verbatim — 409 busy, 413 too_long', async () => {
|
|
360
|
+
connect();
|
|
361
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
362
|
+
await connector.message('local', 's-busy', 'first turn');
|
|
363
|
+
const busy = await connector.message('local', 's-busy', 'second turn');
|
|
364
|
+
assert.equal(busy.status, 409);
|
|
365
|
+
assert.equal(busy.body.error, 'busy');
|
|
366
|
+
const tooLong = await connector.message('local', 's-long', 'x'.repeat(40000));
|
|
367
|
+
assert.equal(tooLong.status, 413);
|
|
368
|
+
assert.equal(tooLong.body.max, 32768);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('stays attached to a session between turns (live:false still watched)', async () => {
|
|
372
|
+
connect();
|
|
373
|
+
await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
|
|
374
|
+
bridge.sessions['s-test0001'].live = false;
|
|
375
|
+
// Force a poll cycle; the watch must not drop on live:false (§12: events
|
|
376
|
+
// for the next turn can start at any moment).
|
|
377
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
378
|
+
const s = connector.status().endpoints[0].sessions.find((x) => x.session === 's-test0001');
|
|
379
|
+
assert.equal(s.live, false);
|
|
380
|
+
assert.equal(s.watching, true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('reports an unreachable endpoint honestly and recovers by retry', async () => {
|
|
384
|
+
const deadUrl = bridge.url;
|
|
385
|
+
const port = Number(new URL(deadUrl).port);
|
|
386
|
+
await bridge.close();
|
|
387
|
+
connector.configure([{ name: 'local', url: deadUrl }]);
|
|
388
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'error');
|
|
389
|
+
assert.ok(connector.status().endpoints[0].error);
|
|
390
|
+
|
|
391
|
+
// Bring a bridge back on the same port; backoff retry should reconnect.
|
|
392
|
+
bridge = new MockBridge();
|
|
393
|
+
await bridge.start(port);
|
|
394
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected', 5000);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('validateEndpoint', () => {
|
|
399
|
+
it('accepts a clean http endpoint', () => {
|
|
400
|
+
assert.equal(validateEndpoint({ name: 'local', url: 'http://127.0.0.1:8737' }), null);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('rejects bad names, schemes, and embedded credentials', () => {
|
|
404
|
+
assert.ok(validateEndpoint({ name: 'bad name!', url: 'http://127.0.0.1:8737' }));
|
|
405
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'ftp://127.0.0.1' }));
|
|
406
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'javascript:alert(1)' }));
|
|
407
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'http://user:pass@host:8737' }));
|
|
408
|
+
assert.ok(validateEndpoint({ name: 'x', url: 'not a url' }));
|
|
409
|
+
assert.ok(validateEndpoint({ name: 'x' }));
|
|
410
|
+
assert.ok(validateEndpoint(null));
|
|
411
|
+
});
|
|
412
|
+
});
|