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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "mrmd-monitor",
3
+ "version": "0.1.0",
4
+ "description": "Headless Yjs peer for monitoring and executing code in mrmd notebooks",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "mrmd-monitor": "./bin/cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node bin/cli.js",
12
+ "dev": "node --watch bin/cli.js"
13
+ },
14
+ "keywords": [
15
+ "mrmd",
16
+ "yjs",
17
+ "collaborative",
18
+ "notebook",
19
+ "execution",
20
+ "monitor"
21
+ ],
22
+ "author": "",
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "yjs": "^13.6.0",
26
+ "y-websocket": "^2.0.0",
27
+ "lib0": "^0.2.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ }
32
+ }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Coordination Protocol
3
+ *
4
+ * Defines the Y.Map schema and protocol for browser/monitor coordination.
5
+ *
6
+ * @module mrmd-monitor/coordination
7
+ */
8
+
9
+ import * as Y from 'yjs';
10
+
11
+ /**
12
+ * Execution status values
13
+ */
14
+ export const EXECUTION_STATUS = {
15
+ /** Browser has requested execution */
16
+ REQUESTED: 'requested',
17
+ /** Monitor has claimed the execution */
18
+ CLAIMED: 'claimed',
19
+ /** Browser has created output block, ready to start */
20
+ READY: 'ready',
21
+ /** Monitor is streaming output from runtime */
22
+ RUNNING: 'running',
23
+ /** Execution completed successfully */
24
+ COMPLETED: 'completed',
25
+ /** Execution failed with error */
26
+ ERROR: 'error',
27
+ /** Execution was cancelled */
28
+ CANCELLED: 'cancelled',
29
+ };
30
+
31
+ /**
32
+ * @typedef {Object} ExecutionRequest
33
+ * @property {string} id - Unique execution ID
34
+ * @property {string} [cellId] - Cell ID this execution is for
35
+ * @property {string} code - Code to execute
36
+ * @property {string} language - Language identifier
37
+ * @property {string} runtimeUrl - MRP runtime URL
38
+ * @property {string} [session] - MRP session ID
39
+ * @property {string} status - Current status
40
+ * @property {number} requestedBy - Client ID that requested
41
+ * @property {number} requestedAt - Timestamp
42
+ * @property {number} [claimedBy] - Client ID that claimed
43
+ * @property {number} [claimedAt] - Timestamp
44
+ * @property {boolean} outputBlockReady - Whether output block exists in Y.Text
45
+ * @property {Object} [outputPosition] - Yjs RelativePosition JSON
46
+ * @property {number} [startedAt] - Timestamp
47
+ * @property {number} [completedAt] - Timestamp
48
+ * @property {Object} [stdinRequest] - Pending stdin request
49
+ * @property {Object} [stdinResponse] - Response to stdin request
50
+ * @property {*} [result] - Execution result
51
+ * @property {Object} [error] - Execution error
52
+ * @property {Array} [displayData] - Rich outputs
53
+ */
54
+
55
+ /**
56
+ * Coordination protocol for browser/monitor communication via Y.Map
57
+ */
58
+ export class CoordinationProtocol {
59
+ /**
60
+ * @param {Y.Doc} ydoc - Yjs document
61
+ * @param {number} clientId - This client's ID
62
+ */
63
+ constructor(ydoc, clientId) {
64
+ /** @type {Y.Doc} */
65
+ this.ydoc = ydoc;
66
+
67
+ /** @type {number} */
68
+ this.clientId = clientId;
69
+
70
+ /** @type {Y.Map} */
71
+ this.executions = ydoc.getMap('executions');
72
+
73
+ /** @type {Set<Function>} */
74
+ this._observers = new Set();
75
+ }
76
+
77
+ /**
78
+ * Generate unique execution ID
79
+ * @returns {string}
80
+ */
81
+ static generateExecId() {
82
+ return `exec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
83
+ }
84
+
85
+ /**
86
+ * Request execution (called by browser)
87
+ *
88
+ * @param {Object} options
89
+ * @param {string} options.code
90
+ * @param {string} options.language
91
+ * @param {string} options.runtimeUrl
92
+ * @param {string} [options.session]
93
+ * @param {string} [options.cellId]
94
+ * @returns {string} execId
95
+ */
96
+ requestExecution({ code, language, runtimeUrl, session = 'default', cellId }) {
97
+ const execId = CoordinationProtocol.generateExecId();
98
+
99
+ this.executions.set(execId, {
100
+ id: execId,
101
+ cellId,
102
+ code,
103
+ language,
104
+ runtimeUrl,
105
+ session,
106
+ status: EXECUTION_STATUS.REQUESTED,
107
+ requestedBy: this.clientId,
108
+ requestedAt: Date.now(),
109
+ claimedBy: null,
110
+ claimedAt: null,
111
+ outputBlockReady: false,
112
+ outputPosition: null,
113
+ startedAt: null,
114
+ completedAt: null,
115
+ stdinRequest: null,
116
+ stdinResponse: null,
117
+ result: null,
118
+ error: null,
119
+ displayData: [],
120
+ });
121
+
122
+ return execId;
123
+ }
124
+
125
+ /**
126
+ * Claim execution (called by monitor)
127
+ *
128
+ * @param {string} execId
129
+ * @returns {boolean} true if claimed successfully
130
+ */
131
+ claimExecution(execId) {
132
+ const exec = this.executions.get(execId);
133
+ if (!exec) return false;
134
+
135
+ // Only claim if status is REQUESTED and not already claimed
136
+ if (exec.status !== EXECUTION_STATUS.REQUESTED) return false;
137
+ if (exec.claimedBy !== null) return false;
138
+
139
+ // Claim it
140
+ this.executions.set(execId, {
141
+ ...exec,
142
+ status: EXECUTION_STATUS.CLAIMED,
143
+ claimedBy: this.clientId,
144
+ claimedAt: Date.now(),
145
+ });
146
+
147
+ return true;
148
+ }
149
+
150
+ /**
151
+ * Mark output block as ready (called by browser)
152
+ *
153
+ * @param {string} execId
154
+ * @param {Object} outputPosition - Yjs RelativePosition as JSON
155
+ */
156
+ setOutputBlockReady(execId, outputPosition) {
157
+ const exec = this.executions.get(execId);
158
+ if (!exec) return;
159
+
160
+ this.executions.set(execId, {
161
+ ...exec,
162
+ status: EXECUTION_STATUS.READY,
163
+ outputBlockReady: true,
164
+ outputPosition,
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Mark execution as running (called by monitor)
170
+ *
171
+ * @param {string} execId
172
+ */
173
+ setRunning(execId) {
174
+ const exec = this.executions.get(execId);
175
+ if (!exec) return;
176
+
177
+ this.executions.set(execId, {
178
+ ...exec,
179
+ status: EXECUTION_STATUS.RUNNING,
180
+ startedAt: Date.now(),
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Mark execution as completed (called by monitor)
186
+ *
187
+ * @param {string} execId
188
+ * @param {Object} options
189
+ * @param {*} [options.result]
190
+ * @param {Array} [options.displayData]
191
+ */
192
+ setCompleted(execId, { result, displayData } = {}) {
193
+ const exec = this.executions.get(execId);
194
+ if (!exec) return;
195
+
196
+ this.executions.set(execId, {
197
+ ...exec,
198
+ status: EXECUTION_STATUS.COMPLETED,
199
+ completedAt: Date.now(),
200
+ result: result ?? null,
201
+ displayData: displayData ?? exec.displayData,
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Mark execution as error (called by monitor)
207
+ *
208
+ * @param {string} execId
209
+ * @param {Object} error
210
+ */
211
+ setError(execId, error) {
212
+ const exec = this.executions.get(execId);
213
+ if (!exec) return;
214
+
215
+ this.executions.set(execId, {
216
+ ...exec,
217
+ status: EXECUTION_STATUS.ERROR,
218
+ completedAt: Date.now(),
219
+ error,
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Request stdin input (called by monitor)
225
+ *
226
+ * @param {string} execId
227
+ * @param {Object} request
228
+ * @param {string} request.prompt
229
+ * @param {boolean} [request.password]
230
+ */
231
+ requestStdin(execId, { prompt, password = false }) {
232
+ const exec = this.executions.get(execId);
233
+ if (!exec) return;
234
+
235
+ this.executions.set(execId, {
236
+ ...exec,
237
+ stdinRequest: {
238
+ prompt,
239
+ password,
240
+ requestedAt: Date.now(),
241
+ },
242
+ stdinResponse: null,
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Respond to stdin request (called by browser)
248
+ *
249
+ * @param {string} execId
250
+ * @param {string} text
251
+ */
252
+ respondStdin(execId, text) {
253
+ const exec = this.executions.get(execId);
254
+ if (!exec) return;
255
+
256
+ this.executions.set(execId, {
257
+ ...exec,
258
+ stdinResponse: {
259
+ text,
260
+ respondedAt: Date.now(),
261
+ },
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Clear stdin request after processing (called by monitor)
267
+ *
268
+ * @param {string} execId
269
+ */
270
+ clearStdinRequest(execId) {
271
+ const exec = this.executions.get(execId);
272
+ if (!exec) return;
273
+
274
+ this.executions.set(execId, {
275
+ ...exec,
276
+ stdinRequest: null,
277
+ stdinResponse: null,
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Add display data (called by monitor)
283
+ *
284
+ * @param {string} execId
285
+ * @param {Object} display
286
+ * @param {string} display.mimeType
287
+ * @param {string} [display.data]
288
+ * @param {string} [display.assetId]
289
+ */
290
+ addDisplayData(execId, display) {
291
+ const exec = this.executions.get(execId);
292
+ if (!exec) return;
293
+
294
+ this.executions.set(execId, {
295
+ ...exec,
296
+ displayData: [...(exec.displayData || []), display],
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Get execution by ID
302
+ *
303
+ * @param {string} execId
304
+ * @returns {ExecutionRequest|undefined}
305
+ */
306
+ getExecution(execId) {
307
+ return this.executions.get(execId);
308
+ }
309
+
310
+ /**
311
+ * Get all executions with a specific status
312
+ *
313
+ * @param {string} status
314
+ * @returns {ExecutionRequest[]}
315
+ */
316
+ getExecutionsByStatus(status) {
317
+ const results = [];
318
+ this.executions.forEach((exec, id) => {
319
+ if (exec.status === status) {
320
+ results.push(exec);
321
+ }
322
+ });
323
+ return results;
324
+ }
325
+
326
+ /**
327
+ * Observe execution changes
328
+ *
329
+ * @param {Function} callback - Called with (execId, execution, changeType)
330
+ * @returns {Function} Unsubscribe function
331
+ */
332
+ observe(callback) {
333
+ const observer = (event) => {
334
+ event.changes.keys.forEach((change, key) => {
335
+ const exec = this.executions.get(key);
336
+ callback(key, exec, change.action);
337
+ });
338
+ };
339
+
340
+ this.executions.observe(observer);
341
+ this._observers.add(observer);
342
+
343
+ return () => {
344
+ this.executions.unobserve(observer);
345
+ this._observers.delete(observer);
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Clean up observers
351
+ */
352
+ destroy() {
353
+ for (const observer of this._observers) {
354
+ this.executions.unobserve(observer);
355
+ }
356
+ this._observers.clear();
357
+ }
358
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Document Writer
3
+ *
4
+ * Handles Y.Text manipulation for writing execution output.
5
+ *
6
+ * @module mrmd-monitor/document
7
+ */
8
+
9
+ import * as Y from 'yjs';
10
+
11
+ /**
12
+ * Writer for execution output to Y.Text
13
+ */
14
+ export class DocumentWriter {
15
+ /**
16
+ * @param {Y.Doc} ydoc - Yjs document
17
+ * @param {string} [textName='content'] - Name of the Y.Text
18
+ */
19
+ constructor(ydoc, textName = 'content') {
20
+ /** @type {Y.Doc} */
21
+ this.ydoc = ydoc;
22
+
23
+ /** @type {Y.Text} */
24
+ this.ytext = ydoc.getText(textName);
25
+ }
26
+
27
+ /**
28
+ * Find output block by execId
29
+ *
30
+ * Searches for ```output:<execId> marker in the document.
31
+ *
32
+ * @param {string} execId
33
+ * @returns {{markerStart: number, contentStart: number, contentEnd: number}|null}
34
+ */
35
+ findOutputBlock(execId) {
36
+ const text = this.ytext.toString();
37
+ const marker = '```output:' + execId;
38
+ const markerStart = text.indexOf(marker);
39
+
40
+ if (markerStart === -1) return null;
41
+
42
+ // Find the newline after marker
43
+ const newlineAfterMarker = text.indexOf('\n', markerStart);
44
+ if (newlineAfterMarker === -1) return null;
45
+
46
+ const contentStart = newlineAfterMarker + 1;
47
+
48
+ // Find the closing ``` - it's the first ``` after contentStart
49
+ // Output blocks don't contain nested code, so this is safe
50
+ const closingPos = text.indexOf('```', contentStart);
51
+ const contentEnd = closingPos === -1 ? text.length : closingPos;
52
+
53
+ return {
54
+ markerStart,
55
+ contentStart,
56
+ contentEnd,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Append content to output block
62
+ *
63
+ * @param {string} execId
64
+ * @param {string} content
65
+ * @returns {boolean} true if successful
66
+ */
67
+ appendOutput(execId, content) {
68
+ const block = this.findOutputBlock(execId);
69
+ if (!block) {
70
+ console.warn(`[DocumentWriter] Output block not found for ${execId}`);
71
+ return false;
72
+ }
73
+
74
+ // Insert just before the closing ```
75
+ this.ytext.insert(block.contentEnd, content);
76
+ return true;
77
+ }
78
+
79
+ /**
80
+ * Replace all content in output block
81
+ *
82
+ * @param {string} execId
83
+ * @param {string} content
84
+ * @returns {boolean} true if successful
85
+ */
86
+ replaceOutput(execId, content) {
87
+ const block = this.findOutputBlock(execId);
88
+ if (!block) {
89
+ console.warn(`[DocumentWriter] Output block not found for ${execId}`);
90
+ return false;
91
+ }
92
+
93
+ // Ensure content ends with newline so closing ``` stays on its own line
94
+ const normalizedContent = content && !content.endsWith('\n') ? content + '\n' : content;
95
+
96
+ // Use transaction for atomic delete+insert
97
+ this.ydoc.transact(() => {
98
+ // Delete existing content
99
+ const existingLength = block.contentEnd - block.contentStart;
100
+ if (existingLength > 0) {
101
+ this.ytext.delete(block.contentStart, existingLength);
102
+ }
103
+
104
+ // Insert new content
105
+ if (normalizedContent) {
106
+ this.ytext.insert(block.contentStart, normalizedContent);
107
+ }
108
+ });
109
+
110
+ return true;
111
+ }
112
+
113
+ /**
114
+ * Create relative position for output block insertion point
115
+ *
116
+ * @param {string} execId
117
+ * @returns {Object|null} RelativePosition as JSON, or null if not found
118
+ */
119
+ createOutputPosition(execId) {
120
+ const block = this.findOutputBlock(execId);
121
+ if (!block) return null;
122
+
123
+ const relPos = Y.createRelativePositionFromTypeIndex(this.ytext, block.contentStart);
124
+ return Y.relativePositionToJSON(relPos);
125
+ }
126
+
127
+ /**
128
+ * Get absolute position from stored relative position
129
+ *
130
+ * @param {Object} relPosJson - RelativePosition as JSON
131
+ * @returns {number|null} Absolute position, or null if invalid
132
+ */
133
+ getAbsolutePosition(relPosJson) {
134
+ if (!relPosJson) return null;
135
+
136
+ try {
137
+ const relPos = Y.createRelativePositionFromJSON(relPosJson);
138
+ const absPos = Y.createAbsolutePositionFromRelativePosition(relPos, this.ydoc);
139
+ return absPos?.index ?? null;
140
+ } catch (err) {
141
+ console.warn('[DocumentWriter] Failed to resolve relative position:', err.message);
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Insert at relative position
148
+ *
149
+ * @param {Object} relPosJson - RelativePosition as JSON
150
+ * @param {string} content
151
+ * @returns {boolean} true if successful
152
+ */
153
+ insertAtPosition(relPosJson, content) {
154
+ const absPos = this.getAbsolutePosition(relPosJson);
155
+ if (absPos === null) {
156
+ console.warn('[DocumentWriter] Could not resolve position');
157
+ return false;
158
+ }
159
+
160
+ this.ytext.insert(absPos, content);
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * Get current output block content
166
+ *
167
+ * @param {string} execId
168
+ * @returns {string|null}
169
+ */
170
+ getOutputContent(execId) {
171
+ const block = this.findOutputBlock(execId);
172
+ if (!block) return null;
173
+
174
+ const text = this.ytext.toString();
175
+ return text.slice(block.contentStart, block.contentEnd);
176
+ }
177
+
178
+ /**
179
+ * Check if output block exists
180
+ *
181
+ * @param {string} execId
182
+ * @returns {boolean}
183
+ */
184
+ hasOutputBlock(execId) {
185
+ return this.findOutputBlock(execId) !== null;
186
+ }
187
+ }