@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.
@@ -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.1.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",