@reactive-skills/runtime 0.1.0 → 0.2.0
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/LICENSE +658 -21
- package/README.md +3 -2
- package/dist/cli/dev.js +14 -10
- package/dist/cli/index.js +30 -20
- package/dist/core/event-store.js +81 -81
- package/dist/core/fsm-engine.d.ts +1 -0
- package/dist/core/fsm-engine.js +3 -0
- package/dist/core/migration.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp/server.js +33 -4
- package/dist/sync/cli.js +32 -32
- package/dist/telemetry/server.d.ts +31 -0
- package/dist/telemetry/server.js +275 -0
- package/dist/telemetry/types.d.ts +57 -0
- package/dist/telemetry/types.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
export class TelemetryServer {
|
|
4
|
+
server = null;
|
|
5
|
+
eventStore;
|
|
6
|
+
fsmEngine;
|
|
7
|
+
port;
|
|
8
|
+
host;
|
|
9
|
+
heartbeatIntervalMs;
|
|
10
|
+
skillName;
|
|
11
|
+
startTime = Date.now();
|
|
12
|
+
activeClients = new Set();
|
|
13
|
+
activeSockets = new Set();
|
|
14
|
+
unsubscribeEventStore;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.eventStore = options.eventStore;
|
|
17
|
+
this.fsmEngine = options.fsmEngine;
|
|
18
|
+
this.port = options.port ?? 4242;
|
|
19
|
+
this.host = options.host ?? '127.0.0.1';
|
|
20
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 15000;
|
|
21
|
+
this.skillName = options.skillName ?? (options.fsmEngine ? options.fsmEngine.getManifest().name : undefined);
|
|
22
|
+
}
|
|
23
|
+
getPort() {
|
|
24
|
+
if (!this.server)
|
|
25
|
+
return this.port;
|
|
26
|
+
const addr = this.server.address();
|
|
27
|
+
if (typeof addr === 'object' && addr !== null) {
|
|
28
|
+
return addr.port;
|
|
29
|
+
}
|
|
30
|
+
return this.port;
|
|
31
|
+
}
|
|
32
|
+
getUrl() {
|
|
33
|
+
return `http://${this.host}:${this.getPort()}`;
|
|
34
|
+
}
|
|
35
|
+
async start() {
|
|
36
|
+
if (this.server) {
|
|
37
|
+
return { port: this.getPort(), url: this.getUrl() };
|
|
38
|
+
}
|
|
39
|
+
this.server = http.createServer((req, res) => {
|
|
40
|
+
this.handleRequest(req, res);
|
|
41
|
+
});
|
|
42
|
+
this.server.on('connection', (socket) => {
|
|
43
|
+
this.activeSockets.add(socket);
|
|
44
|
+
socket.on('close', () => {
|
|
45
|
+
this.activeSockets.delete(socket);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
// Subscribe to EventStore updates to broadcast to all connected SSE clients
|
|
49
|
+
this.unsubscribeEventStore = this.eventStore.subscribe((event) => {
|
|
50
|
+
this.broadcastEvent(event);
|
|
51
|
+
});
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
this.server.once('error', reject);
|
|
54
|
+
this.server.listen(this.port, this.host, () => {
|
|
55
|
+
this.server.removeListener('error', reject);
|
|
56
|
+
const actualPort = this.getPort();
|
|
57
|
+
resolve({ port: actualPort, url: this.getUrl() });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async stop() {
|
|
62
|
+
if (this.unsubscribeEventStore) {
|
|
63
|
+
this.unsubscribeEventStore();
|
|
64
|
+
this.unsubscribeEventStore = undefined;
|
|
65
|
+
}
|
|
66
|
+
for (const client of this.activeClients) {
|
|
67
|
+
try {
|
|
68
|
+
client.end();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// ignore client close errors
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
this.activeClients.clear();
|
|
75
|
+
for (const socket of this.activeSockets) {
|
|
76
|
+
try {
|
|
77
|
+
socket.destroy();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// ignore socket destruction errors
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.activeSockets.clear();
|
|
84
|
+
if (this.server) {
|
|
85
|
+
const serverToClose = this.server;
|
|
86
|
+
this.server = null;
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
serverToClose.close((err) => {
|
|
89
|
+
if (err)
|
|
90
|
+
reject(err);
|
|
91
|
+
else
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
setCorsHeaders(res) {
|
|
98
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
99
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
100
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
101
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Last-Event-ID, Cache-Control');
|
|
102
|
+
}
|
|
103
|
+
handleRequest(req, res) {
|
|
104
|
+
this.setCorsHeaders(res);
|
|
105
|
+
if (req.method === 'OPTIONS') {
|
|
106
|
+
res.writeHead(204);
|
|
107
|
+
res.end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const hostHeader = req.headers.host || `${this.host}:${this.port}`;
|
|
111
|
+
const parsedUrl = new URL(req.url || '/', `http://${hostHeader}`);
|
|
112
|
+
const pathname = parsedUrl.pathname;
|
|
113
|
+
if (req.method === 'GET' && (pathname === '/health' || pathname === '/status')) {
|
|
114
|
+
this.handleHealth(res);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (req.method === 'GET' && pathname === '/state') {
|
|
118
|
+
this.handleState(res);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (req.method === 'GET' && pathname === '/events/history') {
|
|
122
|
+
this.handleEventsHistory(parsedUrl, res);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (req.method === 'GET' && pathname === '/events') {
|
|
126
|
+
this.handleSseEvents(req, parsedUrl, res);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (req.method === 'POST' && pathname === '/signal') {
|
|
130
|
+
this.handleSignal(req, res);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
134
|
+
res.end(JSON.stringify({ error: `Not found: ${pathname}` }));
|
|
135
|
+
}
|
|
136
|
+
handleHealth(res) {
|
|
137
|
+
const payload = {
|
|
138
|
+
status: 'ok',
|
|
139
|
+
skillName: this.skillName,
|
|
140
|
+
latestSeq: this.eventStore.getLatestSequence(),
|
|
141
|
+
uptimeSeconds: Math.floor((Date.now() - this.startTime) / 1000),
|
|
142
|
+
};
|
|
143
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
144
|
+
res.end(JSON.stringify(payload));
|
|
145
|
+
}
|
|
146
|
+
handleState(res) {
|
|
147
|
+
const latestSeq = this.eventStore.getLatestSequence();
|
|
148
|
+
const snapshot = this.eventStore.getLatestSnapshot();
|
|
149
|
+
const activeState = this.fsmEngine ? this.fsmEngine.getCurrentState() : snapshot?.state;
|
|
150
|
+
const context = this.fsmEngine ? this.fsmEngine.getContext() : snapshot?.context;
|
|
151
|
+
const payload = {
|
|
152
|
+
skillName: this.skillName,
|
|
153
|
+
latestSeq,
|
|
154
|
+
activeState,
|
|
155
|
+
context,
|
|
156
|
+
snapshot,
|
|
157
|
+
};
|
|
158
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
159
|
+
res.end(JSON.stringify(payload));
|
|
160
|
+
}
|
|
161
|
+
handleEventsHistory(url, res) {
|
|
162
|
+
const sinceSeqParam = url.searchParams.get('sinceSeq');
|
|
163
|
+
const limitParam = url.searchParams.get('limit');
|
|
164
|
+
const sinceSeq = sinceSeqParam !== null ? parseInt(sinceSeqParam, 10) : undefined;
|
|
165
|
+
const limit = limitParam !== null ? parseInt(limitParam, 10) : undefined;
|
|
166
|
+
let events;
|
|
167
|
+
if (sinceSeq !== undefined && !isNaN(sinceSeq)) {
|
|
168
|
+
events = this.eventStore.getSince(sinceSeq);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
events = this.eventStore.getAll();
|
|
172
|
+
}
|
|
173
|
+
if (limit !== undefined && !isNaN(limit) && limit > 0) {
|
|
174
|
+
events = events.slice(-limit);
|
|
175
|
+
}
|
|
176
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
177
|
+
res.end(JSON.stringify({ count: events.length, events }));
|
|
178
|
+
}
|
|
179
|
+
handleSseEvents(req, url, res) {
|
|
180
|
+
res.writeHead(200, {
|
|
181
|
+
'Content-Type': 'text/event-stream',
|
|
182
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
183
|
+
'Connection': 'keep-alive',
|
|
184
|
+
'X-Accel-Buffering': 'no',
|
|
185
|
+
});
|
|
186
|
+
this.activeClients.add(res);
|
|
187
|
+
// Initial greeting / connect event
|
|
188
|
+
res.write(`event: connected\ndata: ${JSON.stringify({ skillName: this.skillName, connectedAt: new Date().toISOString() })}\n\n`);
|
|
189
|
+
// Determine initial backlog sequence
|
|
190
|
+
const sinceSeqParam = url.searchParams.get('sinceSeq') || req.headers['last-event-id'];
|
|
191
|
+
if (sinceSeqParam) {
|
|
192
|
+
const sinceSeq = parseInt(sinceSeqParam, 10);
|
|
193
|
+
if (!isNaN(sinceSeq)) {
|
|
194
|
+
const backlog = this.eventStore.getSince(sinceSeq);
|
|
195
|
+
for (const event of backlog) {
|
|
196
|
+
this.sendSseEvent(res, event);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Keepalive heartbeat
|
|
201
|
+
const heartbeatTimer = setInterval(() => {
|
|
202
|
+
try {
|
|
203
|
+
res.write(': heartbeat\n\n');
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
clearInterval(heartbeatTimer);
|
|
207
|
+
}
|
|
208
|
+
}, this.heartbeatIntervalMs);
|
|
209
|
+
req.on('close', () => {
|
|
210
|
+
clearInterval(heartbeatTimer);
|
|
211
|
+
this.activeClients.delete(res);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
sendSseEvent(client, event) {
|
|
215
|
+
try {
|
|
216
|
+
client.write(`id: ${event.seq}\n`);
|
|
217
|
+
client.write(`event: signal_event\n`);
|
|
218
|
+
client.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
this.activeClients.delete(client);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
broadcastEvent(event) {
|
|
225
|
+
for (const client of this.activeClients) {
|
|
226
|
+
this.sendSseEvent(client, event);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
handleSignal(req, res) {
|
|
230
|
+
let rawBody = '';
|
|
231
|
+
req.setEncoding('utf8');
|
|
232
|
+
req.on('data', (chunk) => {
|
|
233
|
+
rawBody += chunk;
|
|
234
|
+
if (rawBody.length > 1024 * 1024) {
|
|
235
|
+
// 1MB safety guard
|
|
236
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
237
|
+
res.end(JSON.stringify({ error: 'Payload too large' }));
|
|
238
|
+
req.destroy();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
req.on('end', async () => {
|
|
242
|
+
try {
|
|
243
|
+
const parsed = JSON.parse(rawBody || '{}');
|
|
244
|
+
if (!parsed.signal) {
|
|
245
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
246
|
+
res.end(JSON.stringify({ error: 'Missing required field: signal' }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (this.fsmEngine) {
|
|
250
|
+
const transition = await this.fsmEngine.handleSignal(parsed.signal, parsed.payload || {});
|
|
251
|
+
const response = {
|
|
252
|
+
success: true,
|
|
253
|
+
transition,
|
|
254
|
+
};
|
|
255
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
256
|
+
res.end(JSON.stringify(response));
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// If no FSM engine attached, append signal directly to event store
|
|
260
|
+
const event = this.eventStore.append(parsed.signal, parsed.payload || {}, { source: 'telemetry_bridge' });
|
|
261
|
+
const response = {
|
|
262
|
+
success: true,
|
|
263
|
+
event: event,
|
|
264
|
+
};
|
|
265
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
266
|
+
res.end(JSON.stringify(response));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
271
|
+
res.end(JSON.stringify({ error: err.message || 'Signal dispatch failure' }));
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { EventStore } from '../core/event-store.js';
|
|
2
|
+
import { FSMEngine } from '../core/fsm-engine.js';
|
|
3
|
+
import { SignalEvent } from '../core/types.js';
|
|
4
|
+
export interface TelemetryServerOptions {
|
|
5
|
+
/**
|
|
6
|
+
* EventStore instance to observe
|
|
7
|
+
*/
|
|
8
|
+
eventStore: EventStore;
|
|
9
|
+
/**
|
|
10
|
+
* Optional FSMEngine instance for executing state transitions on HITL signals
|
|
11
|
+
*/
|
|
12
|
+
fsmEngine?: FSMEngine;
|
|
13
|
+
/**
|
|
14
|
+
* Port to listen on (0 for ephemeral port, default: 4242)
|
|
15
|
+
*/
|
|
16
|
+
port?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Host to bind to (default: '127.0.0.1')
|
|
19
|
+
*/
|
|
20
|
+
host?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Interval for SSE keep-alive heartbeats in ms (default: 15000)
|
|
23
|
+
*/
|
|
24
|
+
heartbeatIntervalMs?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Skill identifier for health/metadata reporting
|
|
27
|
+
*/
|
|
28
|
+
skillName?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface TelemetryHealthResponse {
|
|
31
|
+
status: 'ok';
|
|
32
|
+
skillName?: string;
|
|
33
|
+
latestSeq: number;
|
|
34
|
+
uptimeSeconds: number;
|
|
35
|
+
}
|
|
36
|
+
export interface TelemetryStateResponse {
|
|
37
|
+
skillName?: string;
|
|
38
|
+
latestSeq: number;
|
|
39
|
+
activeState?: string;
|
|
40
|
+
context?: Record<string, any>;
|
|
41
|
+
snapshot?: {
|
|
42
|
+
seq: number;
|
|
43
|
+
state: string;
|
|
44
|
+
context: Record<string, any>;
|
|
45
|
+
} | null;
|
|
46
|
+
}
|
|
47
|
+
export interface TelemetrySignalRequest {
|
|
48
|
+
signal: string;
|
|
49
|
+
payload?: Record<string, any>;
|
|
50
|
+
context?: Record<string, any>;
|
|
51
|
+
}
|
|
52
|
+
export interface TelemetrySignalResponse {
|
|
53
|
+
success: boolean;
|
|
54
|
+
event?: SignalEvent;
|
|
55
|
+
transition?: any;
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reactive-skills/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Reactive Skills Architecture (RSA) core runtime — FSM engine, event store, guard evaluator, projection engine, MCP server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|