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 +517 -0
- package/bin/cli.js +178 -0
- package/package.json +32 -0
- package/src/coordination.js +358 -0
- package/src/document.js +187 -0
- package/src/execution.js +261 -0
- package/src/index.js +14 -0
- package/src/monitor.js +420 -0
- package/src/terminal.js +294 -0
package/src/execution.js
ADDED
|
@@ -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
|
+
}
|