mrmd-monitor 0.1.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,261 @@
1
+ /**
2
+ * Execution Handler
3
+ *
4
+ * Handles MRP runtime connections and execution streaming.
5
+ *
6
+ * @module mrmd-monitor/execution
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} ExecutionCallbacks
11
+ * @property {function(string, string): void} [onStdout] - (chunk, accumulated)
12
+ * @property {function(string, string): void} [onStderr] - (chunk, accumulated)
13
+ * @property {function(Object): void} [onStdinRequest] - stdin request from runtime
14
+ * @property {function(Object): void} [onDisplay] - display data (images, HTML, etc.)
15
+ * @property {function(Object): void} [onResult] - final result
16
+ * @property {function(Object): void} [onError] - execution error
17
+ * @property {function(): void} [onStart] - execution started
18
+ * @property {function(): void} [onDone] - stream complete
19
+ */
20
+
21
+ /**
22
+ * MRP execution handler
23
+ */
24
+ export class ExecutionHandler {
25
+ constructor() {
26
+ /** @type {Map<string, AbortController>} */
27
+ this._activeExecutions = new Map();
28
+ }
29
+
30
+ /**
31
+ * Execute code on MRP runtime with streaming
32
+ *
33
+ * @param {string} runtimeUrl - MRP runtime base URL
34
+ * @param {string} code - Code to execute
35
+ * @param {Object} options
36
+ * @param {string} [options.session='default'] - Session ID
37
+ * @param {string} [options.execId] - Execution ID for tracking
38
+ * @param {ExecutionCallbacks} [options.callbacks] - Event callbacks
39
+ * @returns {Promise<Object>} Final result
40
+ */
41
+ async execute(runtimeUrl, code, options = {}) {
42
+ const {
43
+ session = 'default',
44
+ execId,
45
+ callbacks = {},
46
+ } = options;
47
+
48
+ // Set up abort controller
49
+ const abortController = new AbortController();
50
+ if (execId) {
51
+ this._activeExecutions.set(execId, abortController);
52
+ }
53
+
54
+ try {
55
+ const response = await fetch(`${runtimeUrl}/execute/stream`, {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify({
59
+ code,
60
+ session,
61
+ storeHistory: true,
62
+ execId, // Pass execId so mrmd-python uses the same ID for stdin coordination
63
+ }),
64
+ signal: abortController.signal,
65
+ });
66
+
67
+ if (!response.ok) {
68
+ const error = await response.text();
69
+ throw new Error(`MRP request failed: ${response.status} ${error}`);
70
+ }
71
+
72
+ callbacks.onStart?.();
73
+
74
+ // Parse SSE stream
75
+ const reader = response.body.getReader();
76
+ const decoder = new TextDecoder();
77
+
78
+ let currentEvent = null;
79
+ let buffer = '';
80
+ let finalResult = null;
81
+
82
+ while (true) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+
86
+ buffer += decoder.decode(value, { stream: true });
87
+
88
+ // Process complete lines
89
+ const lines = buffer.split('\n');
90
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
91
+
92
+ for (const line of lines) {
93
+ if (line.startsWith('event: ')) {
94
+ currentEvent = line.slice(7).trim();
95
+ } else if (line.startsWith('data: ')) {
96
+ try {
97
+ const data = JSON.parse(line.slice(6));
98
+ finalResult = this._handleEvent(currentEvent, data, callbacks) || finalResult;
99
+ } catch (err) {
100
+ console.warn('[ExecutionHandler] Failed to parse SSE data:', err.message);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ callbacks.onDone?.();
107
+ return finalResult || { success: true };
108
+
109
+ } catch (err) {
110
+ if (err.name === 'AbortError') {
111
+ return { success: false, error: { type: 'Aborted', message: 'Execution cancelled' } };
112
+ }
113
+ callbacks.onError?.({ type: 'ConnectionError', message: err.message });
114
+ throw err;
115
+
116
+ } finally {
117
+ if (execId) {
118
+ this._activeExecutions.delete(execId);
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Handle SSE event
125
+ *
126
+ * @param {string} event
127
+ * @param {Object} data
128
+ * @param {ExecutionCallbacks} callbacks
129
+ * @returns {Object|null} Result if this is the result event
130
+ */
131
+ _handleEvent(event, data, callbacks) {
132
+ switch (event) {
133
+ case 'start':
134
+ // Execution started on server
135
+ break;
136
+
137
+ case 'stdout':
138
+ callbacks.onStdout?.(data.content, data.accumulated);
139
+ break;
140
+
141
+ case 'stderr':
142
+ callbacks.onStderr?.(data.content, data.accumulated);
143
+ break;
144
+
145
+ case 'stdin_request':
146
+ callbacks.onStdinRequest?.(data);
147
+ break;
148
+
149
+ case 'display':
150
+ callbacks.onDisplay?.(data);
151
+ break;
152
+
153
+ case 'asset':
154
+ // Asset saved on server - treat similar to display
155
+ callbacks.onDisplay?.({
156
+ mimeType: data.mimeType,
157
+ assetId: data.path,
158
+ url: data.url,
159
+ });
160
+ break;
161
+
162
+ case 'result':
163
+ callbacks.onResult?.(data);
164
+ return data;
165
+
166
+ case 'error':
167
+ callbacks.onError?.(data);
168
+ return { success: false, error: data };
169
+
170
+ case 'done':
171
+ // Stream complete
172
+ break;
173
+
174
+ default:
175
+ console.log('[ExecutionHandler] Unknown event:', event, data);
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Send input to a waiting execution
183
+ *
184
+ * @param {string} runtimeUrl - MRP runtime base URL
185
+ * @param {string} session - Session ID
186
+ * @param {string} execId - Execution ID
187
+ * @param {string} text - Input text
188
+ * @returns {Promise<{accepted: boolean}>}
189
+ */
190
+ async sendInput(runtimeUrl, session, execId, text) {
191
+ const response = await fetch(`${runtimeUrl}/input`, {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify({
195
+ session,
196
+ exec_id: execId,
197
+ text,
198
+ }),
199
+ });
200
+
201
+ return response.json();
202
+ }
203
+
204
+ /**
205
+ * Interrupt a running execution
206
+ *
207
+ * @param {string} runtimeUrl - MRP runtime base URL
208
+ * @param {string} session - Session ID
209
+ * @returns {Promise<{interrupted: boolean}>}
210
+ */
211
+ async interrupt(runtimeUrl, session) {
212
+ const response = await fetch(`${runtimeUrl}/interrupt`, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json' },
215
+ body: JSON.stringify({ session }),
216
+ });
217
+
218
+ return response.json();
219
+ }
220
+
221
+ /**
222
+ * Cancel execution by execId (local abort)
223
+ *
224
+ * @param {string} execId
225
+ */
226
+ cancel(execId) {
227
+ const controller = this._activeExecutions.get(execId);
228
+ if (controller) {
229
+ controller.abort();
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Cancel all active executions
235
+ */
236
+ cancelAll() {
237
+ for (const controller of this._activeExecutions.values()) {
238
+ controller.abort();
239
+ }
240
+ this._activeExecutions.clear();
241
+ }
242
+
243
+ /**
244
+ * Check if execution is active
245
+ *
246
+ * @param {string} execId
247
+ * @returns {boolean}
248
+ */
249
+ isActive(execId) {
250
+ return this._activeExecutions.has(execId);
251
+ }
252
+
253
+ /**
254
+ * Get active execution count
255
+ *
256
+ * @returns {number}
257
+ */
258
+ get activeCount() {
259
+ return this._activeExecutions.size;
260
+ }
261
+ }
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * mrmd-monitor
3
+ *
4
+ * Headless Yjs peer for monitoring and executing code in mrmd notebooks.
5
+ * Ensures long-running executions survive browser disconnects.
6
+ *
7
+ * @module mrmd-monitor
8
+ */
9
+
10
+ export { RuntimeMonitor, createMonitor } from './monitor.js';
11
+ export { ExecutionHandler } from './execution.js';
12
+ export { DocumentWriter } from './document.js';
13
+ export { CoordinationProtocol, EXECUTION_STATUS } from './coordination.js';
14
+ export { TerminalBuffer, processTerminalOutput } from './terminal.js';
package/src/monitor.js ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Runtime Monitor
3
+ *
4
+ * Main monitor class that connects to mrmd-sync as a Yjs peer
5
+ * and handles execution requests.
6
+ *
7
+ * @module mrmd-monitor/monitor
8
+ */
9
+
10
+ import * as Y from 'yjs';
11
+ import { WebsocketProvider } from 'y-websocket';
12
+ import { CoordinationProtocol, EXECUTION_STATUS } from './coordination.js';
13
+ import { DocumentWriter } from './document.js';
14
+ import { ExecutionHandler } from './execution.js';
15
+ import { TerminalBuffer } from './terminal.js';
16
+
17
+ /**
18
+ * @typedef {Object} MonitorOptions
19
+ * @property {string} [name='mrmd-monitor'] - Monitor name for Awareness
20
+ * @property {string} [color='#10b981'] - Monitor color for Awareness
21
+ * @property {Function} [log] - Logger function
22
+ */
23
+
24
+ /**
25
+ * Runtime Monitor
26
+ *
27
+ * Connects to mrmd-sync as a Yjs peer and handles execution requests.
28
+ */
29
+ export class RuntimeMonitor {
30
+ /**
31
+ * @param {string} syncUrl - WebSocket URL for mrmd-sync
32
+ * @param {string} docPath - Document path/room name
33
+ * @param {MonitorOptions} [options]
34
+ */
35
+ constructor(syncUrl, docPath, options = {}) {
36
+ /** @type {string} */
37
+ this.syncUrl = syncUrl;
38
+
39
+ /** @type {string} */
40
+ this.docPath = docPath;
41
+
42
+ /** @type {MonitorOptions} */
43
+ this.options = {
44
+ name: 'mrmd-monitor',
45
+ color: '#10b981',
46
+ log: console.log,
47
+ ...options,
48
+ };
49
+
50
+ /** @type {Y.Doc} */
51
+ this.ydoc = new Y.Doc();
52
+
53
+ /** @type {WebsocketProvider|null} */
54
+ this.provider = null;
55
+
56
+ /** @type {CoordinationProtocol|null} */
57
+ this.coordination = null;
58
+
59
+ /** @type {DocumentWriter|null} */
60
+ this.writer = null;
61
+
62
+ /** @type {ExecutionHandler} */
63
+ this.executor = new ExecutionHandler();
64
+
65
+ /** @type {boolean} */
66
+ this._connected = false;
67
+
68
+ /** @type {boolean} */
69
+ this._synced = false;
70
+
71
+ /** @type {Function|null} */
72
+ this._unsubscribe = null;
73
+
74
+ /** @type {Set<string>} */
75
+ this._processingExecutions = new Set();
76
+ }
77
+
78
+ /**
79
+ * Log helper
80
+ * @param {string} level
81
+ * @param {string} message
82
+ * @param {Object} [data]
83
+ */
84
+ _log(level, message, data = {}) {
85
+ const entry = {
86
+ timestamp: new Date().toISOString(),
87
+ level,
88
+ component: 'monitor',
89
+ message,
90
+ ...data,
91
+ };
92
+ this.options.log(JSON.stringify(entry));
93
+ }
94
+
95
+ /**
96
+ * Connect to mrmd-sync
97
+ *
98
+ * @returns {Promise<void>} Resolves when connected and synced
99
+ */
100
+ connect() {
101
+ return new Promise((resolve, reject) => {
102
+ this._log('info', 'Connecting to sync server', { url: this.syncUrl, doc: this.docPath });
103
+
104
+ this.provider = new WebsocketProvider(this.syncUrl, this.docPath, this.ydoc, {
105
+ connect: true,
106
+ });
107
+
108
+ // Set up awareness
109
+ this.provider.awareness.setLocalStateField('user', {
110
+ name: this.options.name,
111
+ color: this.options.color,
112
+ type: 'monitor',
113
+ });
114
+
115
+ // Track connection status
116
+ this.provider.on('status', ({ status }) => {
117
+ const wasConnected = this._connected;
118
+ this._connected = status === 'connected';
119
+
120
+ if (this._connected && !wasConnected) {
121
+ this._log('info', 'Connected to sync server');
122
+ } else if (!this._connected && wasConnected) {
123
+ this._log('warn', 'Disconnected from sync server');
124
+ }
125
+ });
126
+
127
+ // Wait for sync
128
+ // Note: y-websocket 2.x uses 'sync' event with boolean parameter
129
+ this.provider.on('sync', (isSynced) => {
130
+ if (isSynced && !this._synced) {
131
+ this._synced = true;
132
+ this._log('info', 'Document synced');
133
+
134
+ // Initialize coordination and writer
135
+ this.coordination = new CoordinationProtocol(this.ydoc, this.ydoc.clientID);
136
+ this.writer = new DocumentWriter(this.ydoc);
137
+
138
+ // Start watching for execution requests
139
+ this._startWatching();
140
+
141
+ resolve();
142
+ }
143
+ });
144
+
145
+ // Handle connection errors
146
+ this.provider.on('connection-error', (err) => {
147
+ this._log('error', 'Connection error', { error: err.message });
148
+ reject(err);
149
+ });
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Start watching for execution requests
155
+ */
156
+ _startWatching() {
157
+ this._log('info', 'Starting execution watcher');
158
+
159
+ this._unsubscribe = this.coordination.observe((execId, exec, action) => {
160
+ if (!exec) return;
161
+
162
+ // Handle new requests
163
+ if (exec.status === EXECUTION_STATUS.REQUESTED) {
164
+ this._handleRequest(execId, exec);
165
+ }
166
+
167
+ // Handle ready (output block created)
168
+ if (exec.status === EXECUTION_STATUS.READY && exec.claimedBy === this.ydoc.clientID) {
169
+ this._handleReady(execId, exec);
170
+ }
171
+
172
+ // Handle stdin responses
173
+ if (exec.stdinResponse && exec.claimedBy === this.ydoc.clientID) {
174
+ this._handleStdinResponse(execId, exec);
175
+ }
176
+ });
177
+
178
+ // Also check for any existing requests we might have missed
179
+ this._checkExistingRequests();
180
+ }
181
+
182
+ /**
183
+ * Check for existing requests on startup
184
+ */
185
+ _checkExistingRequests() {
186
+ const requested = this.coordination.getExecutionsByStatus(EXECUTION_STATUS.REQUESTED);
187
+ for (const exec of requested) {
188
+ this._handleRequest(exec.id, exec);
189
+ }
190
+
191
+ // Also check for any we claimed but didn't start (e.g., after restart)
192
+ const ready = this.coordination.getExecutionsByStatus(EXECUTION_STATUS.READY);
193
+ for (const exec of ready) {
194
+ if (exec.claimedBy === this.ydoc.clientID) {
195
+ this._handleReady(exec.id, exec);
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Handle execution request
202
+ *
203
+ * @param {string} execId
204
+ * @param {Object} exec
205
+ */
206
+ _handleRequest(execId, exec) {
207
+ // Don't claim if already processing
208
+ if (this._processingExecutions.has(execId)) return;
209
+
210
+ this._log('info', 'New execution request', { execId, language: exec.language });
211
+
212
+ // Try to claim it
213
+ const claimed = this.coordination.claimExecution(execId);
214
+ if (claimed) {
215
+ this._log('info', 'Claimed execution', { execId });
216
+ this._processingExecutions.add(execId);
217
+ } else {
218
+ this._log('debug', 'Could not claim execution (already claimed)', { execId });
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Handle execution ready (output block created)
224
+ *
225
+ * @param {string} execId
226
+ * @param {Object} exec
227
+ */
228
+ async _handleReady(execId, exec) {
229
+ // Don't start twice
230
+ if (this.executor.isActive(execId)) return;
231
+
232
+ // Wait for output block to be synced to our ydoc
233
+ // The browser created the output block, but Yjs sync may not have propagated it yet
234
+ let attempts = 0;
235
+ const maxAttempts = 50; // 5 seconds max
236
+ while (!this.writer.hasOutputBlock(execId) && attempts < maxAttempts) {
237
+ await new Promise(resolve => setTimeout(resolve, 100));
238
+ attempts++;
239
+ }
240
+
241
+ if (!this.writer.hasOutputBlock(execId)) {
242
+ this._log('error', 'Output block not synced after timeout', { execId, attempts });
243
+ this.coordination.setError(execId, {
244
+ type: 'SyncError',
245
+ message: 'Output block not synced to monitor. Try again.',
246
+ });
247
+ this._processingExecutions.delete(execId);
248
+ return;
249
+ }
250
+
251
+ if (attempts > 0) {
252
+ this._log('debug', 'Waited for output block sync', { execId, attempts, ms: attempts * 100 });
253
+ }
254
+
255
+ this._log('info', 'Starting execution', { execId, language: exec.language });
256
+
257
+ // Mark as running
258
+ this.coordination.setRunning(execId);
259
+
260
+ try {
261
+ // Use TerminalBuffer to process output (handles \r, ANSI, progress bars)
262
+ const buffer = new TerminalBuffer();
263
+
264
+ await this.executor.execute(exec.runtimeUrl, exec.code, {
265
+ session: exec.session,
266
+ execId,
267
+ callbacks: {
268
+ onStdout: (chunk, accumulated) => {
269
+ // Process through terminal buffer for proper cursor/ANSI handling
270
+ buffer.write(chunk);
271
+ // Write processed output to document
272
+ this.writer.replaceOutput(execId, buffer.toString());
273
+ },
274
+
275
+ onStderr: (chunk, accumulated) => {
276
+ // Process stderr through buffer too
277
+ buffer.write(chunk);
278
+ this.writer.replaceOutput(execId, buffer.toString());
279
+ },
280
+
281
+ onStdinRequest: (request) => {
282
+ this._log('info', 'Stdin request received from runtime', {
283
+ execId,
284
+ prompt: request.prompt,
285
+ password: request.password
286
+ });
287
+ this.coordination.requestStdin(execId, {
288
+ prompt: request.prompt,
289
+ password: request.password,
290
+ });
291
+ this._log('info', 'Stdin request stored in Y.Map', { execId });
292
+ },
293
+
294
+ onDisplay: (display) => {
295
+ this._log('debug', 'Display data', { execId, mimeType: display.mimeType });
296
+ this.coordination.addDisplayData(execId, display);
297
+ },
298
+
299
+ onResult: (result) => {
300
+ this._log('info', 'Execution completed', { execId, success: result.success });
301
+ this.coordination.setCompleted(execId, {
302
+ result: result.result,
303
+ displayData: result.displayData,
304
+ });
305
+ },
306
+
307
+ onError: (error) => {
308
+ this._log('error', 'Execution error', { execId, error: error.message });
309
+ this.coordination.setError(execId, error);
310
+ },
311
+ },
312
+ });
313
+
314
+ } catch (err) {
315
+ this._log('error', 'Execution failed', { execId, error: err.message });
316
+ this.coordination.setError(execId, {
317
+ type: 'MonitorError',
318
+ message: err.message,
319
+ });
320
+
321
+ } finally {
322
+ this._processingExecutions.delete(execId);
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Handle stdin response from browser
328
+ *
329
+ * @param {string} execId
330
+ * @param {Object} exec
331
+ */
332
+ async _handleStdinResponse(execId, exec) {
333
+ if (!exec.stdinResponse) return;
334
+
335
+ this._log('info', 'Stdin response received, sending to runtime', {
336
+ execId,
337
+ text: exec.stdinResponse.text
338
+ });
339
+
340
+ try {
341
+ const result = await this.executor.sendInput(
342
+ exec.runtimeUrl,
343
+ exec.session,
344
+ execId,
345
+ exec.stdinResponse.text
346
+ );
347
+
348
+ this._log('info', 'Stdin sent to runtime', { execId, result });
349
+
350
+ // Clear the request
351
+ this.coordination.clearStdinRequest(execId);
352
+
353
+ } catch (err) {
354
+ this._log('error', 'Failed to send stdin', { execId, error: err.message });
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Disconnect from sync server
360
+ */
361
+ disconnect() {
362
+ this._log('info', 'Disconnecting');
363
+
364
+ // Cancel active executions
365
+ this.executor.cancelAll();
366
+
367
+ // Stop watching
368
+ if (this._unsubscribe) {
369
+ this._unsubscribe();
370
+ this._unsubscribe = null;
371
+ }
372
+
373
+ // Clean up coordination
374
+ if (this.coordination) {
375
+ this.coordination.destroy();
376
+ this.coordination = null;
377
+ }
378
+
379
+ // Disconnect provider
380
+ if (this.provider) {
381
+ this.provider.disconnect();
382
+ this.provider = null;
383
+ }
384
+
385
+ this._connected = false;
386
+ this._synced = false;
387
+ }
388
+
389
+ /**
390
+ * Check if connected
391
+ *
392
+ * @returns {boolean}
393
+ */
394
+ get isConnected() {
395
+ return this._connected && this._synced;
396
+ }
397
+
398
+ /**
399
+ * Get active execution count
400
+ *
401
+ * @returns {number}
402
+ */
403
+ get activeExecutions() {
404
+ return this.executor.activeCount;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Create and connect a monitor
410
+ *
411
+ * @param {string} syncUrl - WebSocket URL for mrmd-sync
412
+ * @param {string} docPath - Document path/room name
413
+ * @param {MonitorOptions} [options]
414
+ * @returns {Promise<RuntimeMonitor>}
415
+ */
416
+ export async function createMonitor(syncUrl, docPath, options = {}) {
417
+ const monitor = new RuntimeMonitor(syncUrl, docPath, options);
418
+ await monitor.connect();
419
+ return monitor;
420
+ }