@reactive-skills/runtime 0.1.1 → 0.3.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 +1 -0
- package/dist/core/event-store.d.ts +8 -1
- package/dist/core/event-store.js +97 -6
- package/dist/core/fsm-engine.d.ts +9 -0
- package/dist/core/fsm-engine.js +52 -2
- package/dist/core/job-manager.d.ts +50 -0
- package/dist/core/job-manager.js +213 -0
- package/dist/core/projection-engine.d.ts +4 -1
- package/dist/core/projection-engine.js +29 -7
- package/dist/core/types.d.ts +34 -0
- package/dist/core/types.js +12 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp/server.js +69 -20
- 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
package/dist/core/types.d.ts
CHANGED
|
@@ -236,3 +236,37 @@ export declare const SkillManifestSchema: z.ZodObject<{
|
|
|
236
236
|
trigger_on?: string[] | undefined;
|
|
237
237
|
}[] | undefined;
|
|
238
238
|
}>;
|
|
239
|
+
export declare const JobStatusSchema: z.ZodEnum<["active", "completed", "failed", "archived"]>;
|
|
240
|
+
export type JobStatus = z.infer<typeof JobStatusSchema>;
|
|
241
|
+
export declare const JobMetadataSchema: z.ZodObject<{
|
|
242
|
+
id: z.ZodString;
|
|
243
|
+
name: z.ZodString;
|
|
244
|
+
skillId: z.ZodString;
|
|
245
|
+
status: z.ZodEnum<["active", "completed", "failed", "archived"]>;
|
|
246
|
+
currentState: z.ZodString;
|
|
247
|
+
parentRunId: z.ZodOptional<z.ZodString>;
|
|
248
|
+
createdAt: z.ZodString;
|
|
249
|
+
updatedAt: z.ZodString;
|
|
250
|
+
completedAt: z.ZodOptional<z.ZodString>;
|
|
251
|
+
}, "strip", z.ZodTypeAny, {
|
|
252
|
+
status: "completed" | "failed" | "active" | "archived";
|
|
253
|
+
name: string;
|
|
254
|
+
id: string;
|
|
255
|
+
skillId: string;
|
|
256
|
+
currentState: string;
|
|
257
|
+
createdAt: string;
|
|
258
|
+
updatedAt: string;
|
|
259
|
+
parentRunId?: string | undefined;
|
|
260
|
+
completedAt?: string | undefined;
|
|
261
|
+
}, {
|
|
262
|
+
status: "completed" | "failed" | "active" | "archived";
|
|
263
|
+
name: string;
|
|
264
|
+
id: string;
|
|
265
|
+
skillId: string;
|
|
266
|
+
currentState: string;
|
|
267
|
+
createdAt: string;
|
|
268
|
+
updatedAt: string;
|
|
269
|
+
parentRunId?: string | undefined;
|
|
270
|
+
completedAt?: string | undefined;
|
|
271
|
+
}>;
|
|
272
|
+
export type JobMetadata = z.infer<typeof JobMetadataSchema>;
|
package/dist/core/types.js
CHANGED
|
@@ -53,3 +53,15 @@ export const SkillManifestSchema = z.object({
|
|
|
53
53
|
trigger_on: z.array(z.string()).optional(),
|
|
54
54
|
})).optional(),
|
|
55
55
|
});
|
|
56
|
+
export const JobStatusSchema = z.enum(['active', 'completed', 'failed', 'archived']);
|
|
57
|
+
export const JobMetadataSchema = z.object({
|
|
58
|
+
id: z.string(),
|
|
59
|
+
name: z.string(),
|
|
60
|
+
skillId: z.string(),
|
|
61
|
+
status: JobStatusSchema,
|
|
62
|
+
currentState: z.string(),
|
|
63
|
+
parentRunId: z.string().optional(),
|
|
64
|
+
createdAt: z.string(),
|
|
65
|
+
updatedAt: z.string(),
|
|
66
|
+
completedAt: z.string().optional(),
|
|
67
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,10 @@ export * from './core/fsm-engine.js';
|
|
|
6
6
|
export * from './core/runtime-hooks.js';
|
|
7
7
|
export * from './core/legacy-adapter.js';
|
|
8
8
|
export * from './core/migration.js';
|
|
9
|
+
export * from './core/job-manager.js';
|
|
9
10
|
export * from './mcp/server.js';
|
|
10
11
|
export * from './sync/types.js';
|
|
11
12
|
export { runSync } from './sync/engine.js';
|
|
12
13
|
export { syncEngineCommand } from './sync/cli.js';
|
|
14
|
+
export * from './telemetry/types.js';
|
|
15
|
+
export * from './telemetry/server.js';
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,10 @@ export * from './core/fsm-engine.js';
|
|
|
6
6
|
export * from './core/runtime-hooks.js';
|
|
7
7
|
export * from './core/legacy-adapter.js';
|
|
8
8
|
export * from './core/migration.js';
|
|
9
|
+
export * from './core/job-manager.js';
|
|
9
10
|
export * from './mcp/server.js';
|
|
10
11
|
export * from './sync/types.js';
|
|
11
12
|
export { runSync } from './sync/engine.js';
|
|
12
13
|
export { syncEngineCommand } from './sync/cli.js';
|
|
14
|
+
export * from './telemetry/types.js';
|
|
15
|
+
export * from './telemetry/server.js';
|
package/dist/mcp/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { FSMEngine } from '../core/fsm-engine.js';
|
|
9
9
|
import { EventStore } from '../core/event-store.js';
|
|
10
|
+
import { JobManager } from '../core/job-manager.js';
|
|
10
11
|
import { SkillManifestSchema } from '../core/types.js';
|
|
11
12
|
export function createReactiveMcpServer(options = {}) {
|
|
12
13
|
const workspaceDir = options.workspaceDir || process.cwd();
|
|
@@ -15,7 +16,7 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
15
16
|
name: 'reactive-skills-server',
|
|
16
17
|
version: '1.0.0',
|
|
17
18
|
});
|
|
18
|
-
// Cached active engine instance per skill
|
|
19
|
+
// Cached active engine instance per skill and job
|
|
19
20
|
const engines = new Map();
|
|
20
21
|
function normalizeDeliverableName(name) {
|
|
21
22
|
const trimmed = String(name || '').trim();
|
|
@@ -27,13 +28,16 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
27
28
|
}
|
|
28
29
|
return trimmed;
|
|
29
30
|
}
|
|
30
|
-
function getEngine(skillName = defaultSkill) {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
function getEngine(skillName = defaultSkill, jobId) {
|
|
32
|
+
const jobManager = new JobManager(workspaceDir);
|
|
33
|
+
const resolvedJobId = jobId || jobManager.getActiveJobId(skillName);
|
|
34
|
+
const cacheKey = `${skillName}::${resolvedJobId}`;
|
|
35
|
+
if (engines.has(cacheKey)) {
|
|
36
|
+
const cached = engines.get(cacheKey);
|
|
33
37
|
if (fs.existsSync(cached.getSkillDir())) {
|
|
34
38
|
return cached;
|
|
35
39
|
}
|
|
36
|
-
engines.delete(
|
|
40
|
+
engines.delete(cacheKey);
|
|
37
41
|
}
|
|
38
42
|
const candidatePaths = [
|
|
39
43
|
path.resolve(workspaceDir, 'skills', skillName),
|
|
@@ -74,18 +78,26 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
74
78
|
const eventStore = new EventStore({
|
|
75
79
|
workspaceDir,
|
|
76
80
|
skillId: skillName,
|
|
81
|
+
jobId: resolvedJobId,
|
|
82
|
+
runId: resolvedJobId,
|
|
77
83
|
enableSqlite: true,
|
|
78
84
|
});
|
|
79
|
-
const engine = new FSMEngine({
|
|
80
|
-
|
|
85
|
+
const engine = new FSMEngine({
|
|
86
|
+
skillDir,
|
|
87
|
+
workspaceDir,
|
|
88
|
+
eventStore,
|
|
89
|
+
jobId: resolvedJobId,
|
|
90
|
+
});
|
|
91
|
+
engines.set(cacheKey, engine);
|
|
81
92
|
return engine;
|
|
82
93
|
}
|
|
83
94
|
// 1. TOOL: reactive_state
|
|
84
95
|
server.tool('reactive_state', 'Get current state, prompt slice, and allowed tools for the active reactive skill', {
|
|
85
96
|
skill: z.string().optional().describe('Skill name (defaults to active skill)'),
|
|
86
|
-
|
|
97
|
+
job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
|
|
98
|
+
}, async ({ skill, job_id }) => {
|
|
87
99
|
try {
|
|
88
|
-
const engine = getEngine(skill || defaultSkill);
|
|
100
|
+
const engine = getEngine(skill || defaultSkill, job_id);
|
|
89
101
|
if (engine.isBypassDetected()) {
|
|
90
102
|
return {
|
|
91
103
|
content: [
|
|
@@ -114,6 +126,7 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
114
126
|
type: 'text',
|
|
115
127
|
text: JSON.stringify({
|
|
116
128
|
skill: engine.getManifest().name,
|
|
129
|
+
job_id: engine.getJobId(),
|
|
117
130
|
activeState,
|
|
118
131
|
isWaitingForHuman: isWaiting,
|
|
119
132
|
allowedTools: slice.allowedTools,
|
|
@@ -145,15 +158,18 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
145
158
|
signal: z.string().describe('Signal name (e.g. CHECK_PASSED, CHARTER_DRAFTED)'),
|
|
146
159
|
payload: z.record(z.any()).optional().describe('Signal payload data (e.g. exit_code, file_path)'),
|
|
147
160
|
skill: z.string().optional().describe('Target skill name'),
|
|
148
|
-
|
|
161
|
+
job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
|
|
162
|
+
}, async ({ signal, payload = {}, skill, job_id }) => {
|
|
149
163
|
try {
|
|
150
|
-
const engine = getEngine(skill || defaultSkill);
|
|
164
|
+
const engine = getEngine(skill || defaultSkill, job_id);
|
|
151
165
|
const result = await engine.handleSignal(signal, payload);
|
|
152
166
|
return {
|
|
153
167
|
content: [
|
|
154
168
|
{
|
|
155
169
|
type: 'text',
|
|
156
170
|
text: JSON.stringify({
|
|
171
|
+
skill: engine.getManifest().name,
|
|
172
|
+
job_id: engine.getJobId(),
|
|
157
173
|
transitioned: result.transitioned,
|
|
158
174
|
previousState: result.previousState,
|
|
159
175
|
newState: result.newState,
|
|
@@ -175,9 +191,10 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
175
191
|
server.tool('reactive_query', 'Execute a read-only SQL query against the SQLite event store (.reactive/events.db)', {
|
|
176
192
|
sql: z.string().describe('SQL query string (e.g. SELECT * FROM events ORDER BY seq DESC LIMIT 10)'),
|
|
177
193
|
skill: z.string().optional().describe('Skill context for event store'),
|
|
178
|
-
|
|
194
|
+
job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
|
|
195
|
+
}, async ({ sql, skill, job_id }) => {
|
|
179
196
|
try {
|
|
180
|
-
const engine = getEngine(skill || defaultSkill);
|
|
197
|
+
const engine = getEngine(skill || defaultSkill, job_id);
|
|
181
198
|
const driver = engine.getEventStore().getSqliteDriver();
|
|
182
199
|
if (!driver) {
|
|
183
200
|
throw new Error('SQLite storage driver is not active.');
|
|
@@ -206,9 +223,10 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
206
223
|
sinceSeq: z.number().int().nonnegative().optional().describe('Return events after this sequence'),
|
|
207
224
|
limit: z.number().int().positive().max(1000).optional().describe('Maximum number of events'),
|
|
208
225
|
skill: z.string().optional().describe('Skill context for event store'),
|
|
209
|
-
|
|
226
|
+
job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
|
|
227
|
+
}, async ({ type, state, sinceSeq, limit, skill, job_id }) => {
|
|
210
228
|
try {
|
|
211
|
-
const engine = getEngine(skill || defaultSkill);
|
|
229
|
+
const engine = getEngine(skill || defaultSkill, job_id);
|
|
212
230
|
const rows = engine.getEventStore().query({ type, state, sinceSeq, limit });
|
|
213
231
|
return {
|
|
214
232
|
content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
|
|
@@ -268,9 +286,10 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
268
286
|
// 5. TOOL: reactive_inspect
|
|
269
287
|
server.tool('reactive_inspect', 'Inspect the full statechart, transitions, and guard criteria of a reactive skill', {
|
|
270
288
|
skill: z.string().optional().describe('Skill name to inspect'),
|
|
271
|
-
|
|
289
|
+
job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
|
|
290
|
+
}, async ({ skill, job_id }) => {
|
|
272
291
|
try {
|
|
273
|
-
const engine = getEngine(skill || defaultSkill);
|
|
292
|
+
const engine = getEngine(skill || defaultSkill, job_id);
|
|
274
293
|
const manifest = engine.getManifest();
|
|
275
294
|
return {
|
|
276
295
|
content: [
|
|
@@ -319,15 +338,16 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
319
338
|
};
|
|
320
339
|
}
|
|
321
340
|
});
|
|
322
|
-
//
|
|
341
|
+
// 7. TOOL: reactive_respond_human
|
|
323
342
|
server.tool('reactive_respond_human', 'Submit user approval or feedback to unpause a Human-in-the-Loop (HITL) gate', {
|
|
324
343
|
choice: z.string().describe('User selected choice (e.g. Approve Plan)'),
|
|
325
344
|
approved: z.boolean().optional().describe('Explicit approval boolean flag'),
|
|
326
345
|
feedback: z.string().optional().describe('Optional feedback text'),
|
|
327
346
|
skill: z.string().optional().describe('Target skill name'),
|
|
328
|
-
|
|
347
|
+
job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
|
|
348
|
+
}, async ({ choice, approved = true, feedback, skill, job_id }) => {
|
|
329
349
|
try {
|
|
330
|
-
const engine = getEngine(skill || defaultSkill);
|
|
350
|
+
const engine = getEngine(skill || defaultSkill, job_id);
|
|
331
351
|
const signalsEmitted = [];
|
|
332
352
|
let transitioned = false;
|
|
333
353
|
let deliverablesWritten = [];
|
|
@@ -427,6 +447,35 @@ export function createReactiveMcpServer(options = {}) {
|
|
|
427
447
|
};
|
|
428
448
|
}
|
|
429
449
|
});
|
|
450
|
+
// 9. TOOL: reactive_list_jobs
|
|
451
|
+
server.tool('reactive_list_jobs', 'List all historical and active execution jobs for a skill with status metadata', {
|
|
452
|
+
skill: z.string().optional().describe('Skill name (defaults to active skill)'),
|
|
453
|
+
}, async ({ skill }) => {
|
|
454
|
+
try {
|
|
455
|
+
const targetSkill = skill || defaultSkill;
|
|
456
|
+
const jobManager = new JobManager(workspaceDir);
|
|
457
|
+
const activeJobId = jobManager.getActiveJobId(targetSkill);
|
|
458
|
+
const jobs = jobManager.listJobs(targetSkill);
|
|
459
|
+
return {
|
|
460
|
+
content: [
|
|
461
|
+
{
|
|
462
|
+
type: 'text',
|
|
463
|
+
text: JSON.stringify({
|
|
464
|
+
skill: targetSkill,
|
|
465
|
+
activeJobId,
|
|
466
|
+
jobs,
|
|
467
|
+
}, null, 2),
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
return {
|
|
474
|
+
content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
|
|
475
|
+
isError: true,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
});
|
|
430
479
|
// RESOURCE 1: reactive://events
|
|
431
480
|
server.resource('reactive-events', 'reactive://events', async (uri) => {
|
|
432
481
|
const store = new EventStore({ enableSqlite: true });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { TelemetryServerOptions } from './types.js';
|
|
2
|
+
export declare class TelemetryServer {
|
|
3
|
+
private server;
|
|
4
|
+
private eventStore;
|
|
5
|
+
private fsmEngine?;
|
|
6
|
+
private port;
|
|
7
|
+
private host;
|
|
8
|
+
private heartbeatIntervalMs;
|
|
9
|
+
private skillName?;
|
|
10
|
+
private startTime;
|
|
11
|
+
private activeClients;
|
|
12
|
+
private activeSockets;
|
|
13
|
+
private unsubscribeEventStore?;
|
|
14
|
+
constructor(options: TelemetryServerOptions);
|
|
15
|
+
getPort(): number;
|
|
16
|
+
getUrl(): string;
|
|
17
|
+
start(): Promise<{
|
|
18
|
+
port: number;
|
|
19
|
+
url: string;
|
|
20
|
+
}>;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
private setCorsHeaders;
|
|
23
|
+
private handleRequest;
|
|
24
|
+
private handleHealth;
|
|
25
|
+
private handleState;
|
|
26
|
+
private handleEventsHistory;
|
|
27
|
+
private handleSseEvents;
|
|
28
|
+
private sendSseEvent;
|
|
29
|
+
private broadcastEvent;
|
|
30
|
+
private handleSignal;
|
|
31
|
+
}
|
|
@@ -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.3.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",
|