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.
package/README.md ADDED
@@ -0,0 +1,517 @@
1
+ # mrmd-monitor
2
+
3
+ A headless Yjs peer that monitors and executes code in mrmd notebooks, ensuring long-running executions survive browser disconnects.
4
+
5
+ ## Why This Exists
6
+
7
+ ### The Problem
8
+
9
+ In the original mrmd architecture:
10
+ - Browsers connect directly to MRP runtimes (mrmd-python, etc.)
11
+ - Browser writes execution output to Y.Text (the document)
12
+ - mrmd-sync handles Yjs synchronization and file persistence
13
+
14
+ This works great, but has one issue: **if a browser disconnects during a long-running execution, the connection to the runtime is lost and output stops flowing to the document.**
15
+
16
+ ### Failed Approach: Hub Architecture
17
+
18
+ We tried making mrmd-sync a "hub" that:
19
+ - Routes all execution through the server
20
+ - Writes output directly to Y.Text from server-side
21
+ - Manages runtime connections centrally
22
+
23
+ This failed because:
24
+ 1. **y-codemirror binding conflicts** - The binding expects changes to come from Yjs peers, not server-side manipulation
25
+ 2. **Position finding is fragile** - Searching for `\`\`\`output:exec-123` markers breaks with concurrent edits
26
+ 3. **Tight coupling** - Mixing sync logic with execution logic creates complexity
27
+ 4. **Single point of failure** - If hub crashes, both sync AND execution die
28
+
29
+ ### New Approach: Monitor as Yjs Peer
30
+
31
+ Instead of making the server special, we create a **headless Yjs client** that:
32
+ - Connects to mrmd-sync as a regular peer (just like browsers)
33
+ - Monitors execution requests
34
+ - Connects to MRP runtimes
35
+ - Writes output to Y.Text (through the normal Yjs sync flow)
36
+
37
+ ```
38
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐
39
+ │ Browser A │ │ Browser B │ │ mrmd-monitor │
40
+ │ (editor) │ │ (editor) │ │ (headless peer) │
41
+ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘
42
+ │ │ │
43
+ │ Yjs sync (all equal peers) │
44
+ └─────────────────┬─────────────────────────┘
45
+
46
+
47
+ ┌────────────────┐
48
+ │ mrmd-sync │
49
+ │ │
50
+ │ • Yjs provider│
51
+ │ • File sync │
52
+ └────────────────┘
53
+
54
+
55
+ notebook.md
56
+ ```
57
+
58
+ **Key insight:** The monitor writes to Y.Text exactly like a browser would. mrmd-sync doesn't know or care that it's special.
59
+
60
+ ---
61
+
62
+ ## Architecture
63
+
64
+ ### Components
65
+
66
+ ```
67
+ mrmd-monitor/
68
+ ├── src/
69
+ │ ├── index.js # Main entry, exports
70
+ │ ├── monitor.js # RuntimeMonitor class
71
+ │ ├── execution.js # Execution handling, MRP client
72
+ │ ├── document.js # Y.Text manipulation (output blocks)
73
+ │ └── coordination.js # Y.Map protocol for browser/monitor coordination
74
+ ├── bin/
75
+ │ └── cli.js # CLI entry point
76
+ ├── package.json
77
+ └── README.md
78
+ ```
79
+
80
+ ### Data Flow
81
+
82
+ ```
83
+ 1. Browser wants to execute code
84
+
85
+
86
+ 2. Browser writes to Y.Map('executions'):
87
+ {
88
+ "exec-123": {
89
+ status: "requested",
90
+ code: "print('hello')",
91
+ language: "python",
92
+ runtimeUrl: "http://localhost:8000/mrp/v1",
93
+ requestedBy: <clientId>,
94
+ requestedAt: <timestamp>
95
+ }
96
+ }
97
+
98
+
99
+ 3. Monitor observes Y.Map change
100
+
101
+
102
+ 4. Monitor claims execution:
103
+ Y.Map.set("exec-123", { ...existing, status: "claimed", claimedBy: <monitorId> })
104
+
105
+
106
+ 5. Browser sees claim, creates output block in Y.Text:
107
+ ```python
108
+ print('hello')
109
+ ```
110
+
111
+ ```output:exec-123
112
+ ```
113
+
114
+
115
+ 6. Browser confirms output block ready:
116
+ Y.Map.set("exec-123", { ...existing, status: "ready", outputBlockReady: true })
117
+
118
+
119
+ 7. Monitor connects to MRP runtime (SSE streaming)
120
+
121
+
122
+ 8. Monitor writes output to Y.Text (finds output block, appends)
123
+ Y.Map.set("exec-123", { ...existing, status: "running" })
124
+
125
+
126
+ 9. Output syncs to all browsers via Yjs
127
+
128
+
129
+ 10. Execution completes:
130
+ Y.Map.set("exec-123", { ...existing, status: "completed", result: {...} })
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Coordination Protocol
136
+
137
+ ### Y.Map('executions') Schema
138
+
139
+ ```javascript
140
+ {
141
+ "exec-<id>": {
142
+ // Identity
143
+ id: "exec-<id>",
144
+ cellId: "cell-<id>", // Optional: which cell this is for
145
+
146
+ // Request (set by browser)
147
+ code: "print('hello')",
148
+ language: "python",
149
+ runtimeUrl: "http://localhost:8000/mrp/v1",
150
+ session: "default", // MRP session ID
151
+
152
+ // Coordination
153
+ status: "requested" | "claimed" | "ready" | "running" | "completed" | "error" | "cancelled",
154
+ requestedBy: <clientId>, // Browser that requested
155
+ requestedAt: <timestamp>,
156
+ claimedBy: <clientId>, // Monitor that claimed (or null)
157
+ claimedAt: <timestamp>,
158
+
159
+ // Output block coordination
160
+ outputBlockReady: false, // Browser sets true when output block exists
161
+ outputPosition: { // Yjs RelativePosition for output insertion
162
+ type: <typeId>,
163
+ item: <itemId>,
164
+ assoc: 0
165
+ },
166
+
167
+ // Runtime state
168
+ startedAt: <timestamp>,
169
+ completedAt: <timestamp>,
170
+
171
+ // Stdin coordination
172
+ stdinRequest: null | {
173
+ prompt: "Enter name: ",
174
+ password: false,
175
+ requestedAt: <timestamp>
176
+ },
177
+ stdinResponse: null | {
178
+ text: "Alice\n",
179
+ respondedAt: <timestamp>
180
+ },
181
+
182
+ // Result
183
+ result: null | <any>, // Final execution result
184
+ error: null | {
185
+ type: "NameError",
186
+ message: "name 'foo' is not defined",
187
+ traceback: [...]
188
+ },
189
+
190
+ // Rich outputs (stored here, rendered by browser)
191
+ displayData: [
192
+ {
193
+ mimeType: "image/png",
194
+ data: "base64...", // Small outputs inline
195
+ assetId: null // Or reference to asset
196
+ }
197
+ ]
198
+ }
199
+ }
200
+ ```
201
+
202
+ ### Status State Machine
203
+
204
+ ```
205
+ ┌─────────────┐
206
+ │ requested │ ← Browser creates
207
+ └──────┬──────┘
208
+
209
+ Monitor claims│
210
+
211
+ ┌─────────────┐
212
+ │ claimed │
213
+ └──────┬──────┘
214
+
215
+ Browser creates │
216
+ output block │
217
+
218
+ ┌─────────────┐
219
+ │ ready │ ← Browser sets outputBlockReady=true
220
+ └──────┬──────┘
221
+
222
+ Monitor starts │
223
+ MRP execution │
224
+
225
+ ┌─────────────┐
226
+ │ running │ ← Monitor streaming output
227
+ └──────┬──────┘
228
+
229
+ ┌────────────┼────────────┐
230
+ │ │ │
231
+ ▼ ▼ ▼
232
+ ┌───────────┐ ┌───────────┐ ┌───────────┐
233
+ │ completed │ │ error │ │ cancelled │
234
+ └───────────┘ └───────────┘ └───────────┘
235
+ ```
236
+
237
+ ### Browser Responsibilities
238
+
239
+ 1. **Request execution:**
240
+ - Generate unique execId
241
+ - Write to Y.Map with status="requested"
242
+ - Include code, language, runtimeUrl
243
+
244
+ 2. **Create output block:**
245
+ - Wait for status="claimed"
246
+ - Insert `\`\`\`output:<execId>\n\`\`\`` in Y.Text after code cell
247
+ - Store relative position in Y.Map
248
+ - Set status="ready", outputBlockReady=true
249
+
250
+ 3. **Handle stdin:**
251
+ - Watch for stdinRequest in Y.Map
252
+ - Show input UI to user
253
+ - Write stdinResponse to Y.Map
254
+
255
+ 4. **Render output:**
256
+ - Output appears in Y.Text via Yjs sync (monitor writes it)
257
+ - Rich outputs (images) in displayData, render via widgets
258
+
259
+ ### Monitor Responsibilities
260
+
261
+ 1. **Watch for requests:**
262
+ - Observe Y.Map('executions')
263
+ - Look for status="requested"
264
+
265
+ 2. **Claim execution:**
266
+ - Set status="claimed", claimedBy=<myClientId>
267
+ - Only one monitor should claim (first wins via Yjs)
268
+
269
+ 3. **Wait for output block:**
270
+ - Watch for status="ready", outputBlockReady=true
271
+ - Get outputPosition from Y.Map
272
+
273
+ 4. **Execute:**
274
+ - Connect to MRP runtime via SSE
275
+ - Set status="running"
276
+ - Stream output to Y.Text at outputPosition
277
+ - Handle stdin requests (set stdinRequest, wait for stdinResponse)
278
+
279
+ 5. **Complete:**
280
+ - Set status="completed" or "error"
281
+ - Store result/error in Y.Map
282
+ - Store displayData for rich outputs
283
+
284
+ ---
285
+
286
+ ## Y.Text Output Writing
287
+
288
+ ### Finding the Output Block
289
+
290
+ ```javascript
291
+ function findOutputBlock(ytext, execId) {
292
+ const text = ytext.toString();
293
+ const marker = '```output:' + execId;
294
+ const markerStart = text.indexOf(marker);
295
+
296
+ if (markerStart === -1) return null;
297
+
298
+ // Find the newline after marker
299
+ const contentStart = text.indexOf('\n', markerStart) + 1;
300
+
301
+ // Find the closing ```
302
+ const closingBackticks = text.indexOf('\n```', contentStart);
303
+
304
+ return {
305
+ markerStart,
306
+ contentStart,
307
+ contentEnd: closingBackticks === -1 ? text.length : closingBackticks,
308
+ };
309
+ }
310
+ ```
311
+
312
+ ### Appending Output
313
+
314
+ ```javascript
315
+ function appendOutput(ytext, execId, content) {
316
+ const block = findOutputBlock(ytext, execId);
317
+ if (!block) {
318
+ console.warn('Output block not found for', execId);
319
+ return false;
320
+ }
321
+
322
+ // Insert just before the closing ```
323
+ ytext.insert(block.contentEnd, content);
324
+ return true;
325
+ }
326
+ ```
327
+
328
+ ### Using Relative Positions (Better)
329
+
330
+ Instead of searching by text, use Yjs RelativePosition:
331
+
332
+ ```javascript
333
+ // Browser stores position when creating output block
334
+ const outputStart = /* position after ```output:exec-123\n */;
335
+ const relPos = Y.createRelativePositionFromTypeIndex(ytext, outputStart);
336
+
337
+ // Store in Y.Map
338
+ execMap.set(execId, {
339
+ ...existing,
340
+ outputPosition: Y.relativePositionToJSON(relPos)
341
+ });
342
+
343
+ // Monitor uses position
344
+ const relPos = Y.createRelativePositionFromJSON(exec.outputPosition);
345
+ const absPos = Y.createAbsolutePositionFromRelativePosition(relPos, ydoc);
346
+ if (absPos) {
347
+ ytext.insert(absPos.index, content);
348
+ }
349
+ ```
350
+
351
+ ---
352
+
353
+ ## MRP Client
354
+
355
+ The monitor connects to MRP runtimes using the same protocol as browsers:
356
+
357
+ ```javascript
358
+ async function executeStreaming(runtimeUrl, code, language, options = {}) {
359
+ const response = await fetch(`${runtimeUrl}/execute/stream`, {
360
+ method: 'POST',
361
+ headers: { 'Content-Type': 'application/json' },
362
+ body: JSON.stringify({
363
+ code,
364
+ session: options.session || 'default',
365
+ storeHistory: true,
366
+ }),
367
+ });
368
+
369
+ // Parse SSE stream
370
+ const reader = response.body.getReader();
371
+ const decoder = new TextDecoder();
372
+
373
+ let currentEvent = null;
374
+
375
+ while (true) {
376
+ const { done, value } = await reader.read();
377
+ if (done) break;
378
+
379
+ const text = decoder.decode(value);
380
+ for (const line of text.split('\n')) {
381
+ if (line.startsWith('event: ')) {
382
+ currentEvent = line.slice(7);
383
+ } else if (line.startsWith('data: ')) {
384
+ const data = JSON.parse(line.slice(6));
385
+
386
+ switch (currentEvent) {
387
+ case 'stdout':
388
+ options.onStdout?.(data.content, data.accumulated);
389
+ break;
390
+ case 'stderr':
391
+ options.onStderr?.(data.content, data.accumulated);
392
+ break;
393
+ case 'stdin_request':
394
+ options.onStdinRequest?.(data);
395
+ break;
396
+ case 'display':
397
+ options.onDisplay?.(data);
398
+ break;
399
+ case 'result':
400
+ options.onResult?.(data);
401
+ break;
402
+ case 'error':
403
+ options.onError?.(data);
404
+ break;
405
+ }
406
+ }
407
+ }
408
+ }
409
+ }
410
+ ```
411
+
412
+ ---
413
+
414
+ ## CLI Usage
415
+
416
+ ```bash
417
+ # Basic usage - connect to mrmd-sync and monitor executions
418
+ mrmd-monitor ws://localhost:4444
419
+
420
+ # With options
421
+ mrmd-monitor --doc notebook.md ws://localhost:4444
422
+
423
+ # Monitor specific document
424
+ mrmd-monitor --doc "projects/analysis.md" ws://localhost:4444
425
+ ```
426
+
427
+ ### Options
428
+
429
+ | Option | Description |
430
+ |--------|-------------|
431
+ | `--doc <path>` | Document to monitor (default: all documents) |
432
+ | `--log-level <level>` | Log level: debug, info, warn, error |
433
+ | `--name <name>` | Monitor name for Awareness |
434
+
435
+ ---
436
+
437
+ ## Implementation Plan
438
+
439
+ ### Phase 1: Basic Execution
440
+
441
+ 1. [ ] Connect to mrmd-sync as Yjs peer
442
+ 2. [ ] Observe Y.Map('executions')
443
+ 3. [ ] Claim executions with status="requested"
444
+ 4. [ ] Wait for outputBlockReady
445
+ 5. [ ] Connect to MRP runtime, stream output to Y.Text
446
+ 6. [ ] Set completion status
447
+
448
+ ### Phase 2: Stdin Support
449
+
450
+ 1. [ ] Detect stdin_request from MRP
451
+ 2. [ ] Set stdinRequest in Y.Map
452
+ 3. [ ] Watch for stdinResponse
453
+ 4. [ ] Send input to MRP runtime
454
+
455
+ ### Phase 3: Rich Outputs
456
+
457
+ 1. [ ] Handle displayData from MRP
458
+ 2. [ ] Store small outputs inline in Y.Map
459
+ 3. [ ] Large outputs: store as assets (future)
460
+
461
+ ### Phase 4: Robustness
462
+
463
+ 1. [ ] Handle monitor disconnect/reconnect
464
+ 2. [ ] Handle runtime disconnect
465
+ 3. [ ] Timeout for stuck executions
466
+ 4. [ ] Multiple monitors (coordination)
467
+
468
+ ---
469
+
470
+ ## Comparison with Hub Approach
471
+
472
+ | Aspect | Hub (mrmd-sync) | Monitor (peer) |
473
+ |--------|-----------------|----------------|
474
+ | Complexity | High (all-in-one) | Low (separated) |
475
+ | Y.Text writes | Server-side (tricky) | Peer-side (natural) |
476
+ | Failure isolation | Poor | Good |
477
+ | Scaling | Hard | Easy (multiple monitors) |
478
+ | mrmd-sync changes | Major | None |
479
+ | y-codemirror compat | Issues | Works naturally |
480
+
481
+ ---
482
+
483
+ ## FAQ
484
+
485
+ ### Why not just keep execution in the browser?
486
+
487
+ That works fine for short executions. But:
488
+ - Long-running ML training (hours)
489
+ - User closes laptop
490
+ - Browser crashes/refreshes
491
+
492
+ Output stops flowing. Monitor ensures it continues.
493
+
494
+ ### Why a separate process instead of in mrmd-sync?
495
+
496
+ 1. **Separation of concerns** - Sync is sync, execution is execution
497
+ 2. **Failure isolation** - Monitor crash doesn't affect sync
498
+ 3. **Scaling** - Can run multiple monitors
499
+ 4. **Simplicity** - mrmd-sync stays simple
500
+
501
+ ### What if monitor crashes during execution?
502
+
503
+ Execution keeps running on MRP runtime. When monitor restarts:
504
+ - It sees status="running" in Y.Map
505
+ - It can reconnect to MRP (if runtime supports resume)
506
+ - Or mark as error and let user re-run
507
+
508
+ ### What about multiple monitors?
509
+
510
+ First one to set status="claimed" wins (Yjs handles concurrency).
511
+ Other monitors see it's claimed and skip.
512
+
513
+ ### Can browser execute directly without monitor?
514
+
515
+ Yes! The original architecture still works. Monitor is additive.
516
+ Browser can execute directly for quick things.
517
+ Monitor is for "fire and forget" long-running executions.
package/bin/cli.js ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * mrmd-monitor CLI
5
+ *
6
+ * Headless Yjs peer for monitoring and executing code in mrmd notebooks.
7
+ *
8
+ * Usage:
9
+ * mrmd-monitor ws://localhost:4444
10
+ * mrmd-monitor --doc notebook.md ws://localhost:4444
11
+ */
12
+
13
+ import { RuntimeMonitor } from '../src/monitor.js';
14
+
15
+ // Parse arguments
16
+ const args = process.argv.slice(2);
17
+
18
+ const options = {
19
+ doc: null,
20
+ logLevel: 'info',
21
+ name: 'mrmd-monitor',
22
+ };
23
+
24
+ let syncUrl = null;
25
+
26
+ function printHelp() {
27
+ console.log(`
28
+ mrmd-monitor - Headless Yjs peer for execution monitoring
29
+
30
+ Usage:
31
+ mrmd-monitor [options] <sync-url>
32
+
33
+ Arguments:
34
+ sync-url WebSocket URL for mrmd-sync (e.g., ws://localhost:4444)
35
+
36
+ Options:
37
+ --doc <path> Document to monitor (default: first document)
38
+ --name <name> Monitor name for Awareness (default: mrmd-monitor)
39
+ --log-level <level> Log level: debug, info, warn, error (default: info)
40
+ --help, -h Show this help
41
+
42
+ Examples:
43
+ mrmd-monitor ws://localhost:4444
44
+ mrmd-monitor --doc notebook.md ws://localhost:4444
45
+ mrmd-monitor --log-level debug ws://localhost:4444
46
+
47
+ The monitor connects to mrmd-sync as a Yjs peer and:
48
+ - Watches for execution requests in Y.Map('executions')
49
+ - Claims and executes requests via MRP runtimes
50
+ - Writes output to Y.Text (the document)
51
+ - Handles stdin requests from runtimes
52
+ `);
53
+ }
54
+
55
+ // Parse arguments
56
+ for (let i = 0; i < args.length; i++) {
57
+ const arg = args[i];
58
+
59
+ if (arg === '--help' || arg === '-h') {
60
+ printHelp();
61
+ process.exit(0);
62
+ } else if (arg === '--doc') {
63
+ options.doc = args[++i];
64
+ if (!options.doc) {
65
+ console.error('Error: --doc requires a path');
66
+ process.exit(1);
67
+ }
68
+ } else if (arg === '--name') {
69
+ options.name = args[++i];
70
+ if (!options.name) {
71
+ console.error('Error: --name requires a value');
72
+ process.exit(1);
73
+ }
74
+ } else if (arg === '--log-level') {
75
+ options.logLevel = args[++i];
76
+ if (!['debug', 'info', 'warn', 'error'].includes(options.logLevel)) {
77
+ console.error('Error: --log-level must be debug, info, warn, or error');
78
+ process.exit(1);
79
+ }
80
+ } else if (!arg.startsWith('-')) {
81
+ syncUrl = arg;
82
+ } else {
83
+ console.error(`Unknown option: ${arg}`);
84
+ console.error('Run with --help for usage');
85
+ process.exit(1);
86
+ }
87
+ }
88
+
89
+ // Validate
90
+ if (!syncUrl) {
91
+ console.error('Error: sync-url is required');
92
+ console.error('Run with --help for usage');
93
+ process.exit(1);
94
+ }
95
+
96
+ // Ensure URL has protocol
97
+ if (!syncUrl.startsWith('ws://') && !syncUrl.startsWith('wss://')) {
98
+ syncUrl = 'ws://' + syncUrl;
99
+ }
100
+
101
+ // Document path - if not specified, use a default
102
+ const docPath = options.doc || 'default';
103
+
104
+ // Logging
105
+ const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
106
+ const minLevel = LOG_LEVELS[options.logLevel] ?? 1;
107
+
108
+ function log(entry) {
109
+ try {
110
+ const parsed = JSON.parse(entry);
111
+ if (LOG_LEVELS[parsed.level] >= minLevel) {
112
+ const time = parsed.timestamp.split('T')[1].split('.')[0];
113
+ const level = parsed.level.toUpperCase().padEnd(5);
114
+ const msg = parsed.message;
115
+ const extra = Object.keys(parsed)
116
+ .filter(k => !['timestamp', 'level', 'component', 'message'].includes(k))
117
+ .map(k => `${k}=${JSON.stringify(parsed[k])}`)
118
+ .join(' ');
119
+
120
+ console.log(`[${time}] ${level} ${msg}${extra ? ' ' + extra : ''}`);
121
+ }
122
+ } catch {
123
+ console.log(entry);
124
+ }
125
+ }
126
+
127
+ // Start monitor
128
+ console.log('');
129
+ console.log('\x1b[36m%s\x1b[0m', ' mrmd-monitor');
130
+ console.log(' ────────────');
131
+ console.log(` Sync: ${syncUrl}`);
132
+ console.log(` Document: ${docPath}`);
133
+ console.log(` Name: ${options.name}`);
134
+ console.log('');
135
+
136
+ const monitor = new RuntimeMonitor(syncUrl, docPath, {
137
+ name: options.name,
138
+ log,
139
+ });
140
+
141
+ // Handle shutdown
142
+ let shuttingDown = false;
143
+
144
+ async function shutdown(signal) {
145
+ if (shuttingDown) return;
146
+ shuttingDown = true;
147
+
148
+ console.log('');
149
+ log(JSON.stringify({
150
+ timestamp: new Date().toISOString(),
151
+ level: 'info',
152
+ message: `Received ${signal}, shutting down...`,
153
+ }));
154
+
155
+ monitor.disconnect();
156
+
157
+ // Give time for cleanup
158
+ setTimeout(() => {
159
+ process.exit(0);
160
+ }, 500);
161
+ }
162
+
163
+ process.on('SIGINT', () => shutdown('SIGINT'));
164
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
165
+
166
+ // Connect
167
+ monitor.connect()
168
+ .then(() => {
169
+ log(JSON.stringify({
170
+ timestamp: new Date().toISOString(),
171
+ level: 'info',
172
+ message: 'Monitor ready, waiting for execution requests...',
173
+ }));
174
+ })
175
+ .catch((err) => {
176
+ console.error('Failed to connect:', err.message);
177
+ process.exit(1);
178
+ });