@testdriverai/runner 7.8.0-canary.10
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/45-allow-colord.pkla +6 -0
- package/README.md +1 -0
- package/Xauthority +0 -0
- package/focusWindow.ps1 +123 -0
- package/getActiveWindow.ps1 +70 -0
- package/index.js +556 -0
- package/lib/ably-service.js +537 -0
- package/lib/automation-bridge.js +85 -0
- package/lib/automation.js +786 -0
- package/lib/automation.js.bak +882 -0
- package/lib/pyautogui-local.js +229 -0
- package/network.ps1 +18 -0
- package/package.json +43 -0
- package/sandbox-agent.js +266 -0
- package/scripts-desktop/control_window.sh +59 -0
- package/scripts-desktop/launch_chrome.sh +3 -0
- package/scripts-desktop/launch_chrome_for_testing.sh +9 -0
- package/scripts-desktop/start-desktop.sh +161 -0
- package/wallpaper.png +0 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ably-service.js — Ably-based command listener for sandbox agents
|
|
3
|
+
*
|
|
4
|
+
* Runs on the sandbox machine (Windows EC2, self-hosted runner, etc.).
|
|
5
|
+
* Subscribes to an Ably commands channel, dispatches commands to the
|
|
6
|
+
* Automation module, and publishes responses back.
|
|
7
|
+
*
|
|
8
|
+
* Replaces the WebSocket server in pyautogui-cli.py. Uses a single
|
|
9
|
+
* Ably channel per session with event-name-based multiplexing:
|
|
10
|
+
* - "command" event: receives commands from SDK/API
|
|
11
|
+
* - "response" event: publishes command results
|
|
12
|
+
* - "control" event: control messages (keepalive, status)
|
|
13
|
+
* - "file" event: screenshot/file data (base64 or S3 references)
|
|
14
|
+
*
|
|
15
|
+
* The Ably token and channel name are provided by the API during
|
|
16
|
+
* sandbox provisioning.
|
|
17
|
+
*
|
|
18
|
+
* Screenshots are uploaded to S3 and the S3 key is returned instead of
|
|
19
|
+
* base64 data (Ably has a 64KB message limit).
|
|
20
|
+
*/
|
|
21
|
+
const Ably = require('ably');
|
|
22
|
+
const Sentry = require('@sentry/node');
|
|
23
|
+
const http = require('http');
|
|
24
|
+
const https = require('https');
|
|
25
|
+
const { EventEmitter } = require('events');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the local runner version from package.json.
|
|
29
|
+
*/
|
|
30
|
+
function getLocalVersion() {
|
|
31
|
+
try {
|
|
32
|
+
if (process.env.RUNNER_VERSION) return process.env.RUNNER_VERSION;
|
|
33
|
+
const pkg = require('../package.json');
|
|
34
|
+
return pkg.version;
|
|
35
|
+
} catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── S3 Upload Helper ────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Upload a screenshot to S3 via presigned URL from the API
|
|
42
|
+
*
|
|
43
|
+
* @param {string} apiRoot - API base URL
|
|
44
|
+
* @param {string} apiKey - Team API key
|
|
45
|
+
* @param {string} sandboxId - Sandbox ID
|
|
46
|
+
* @param {string} base64 - Base64-encoded image data
|
|
47
|
+
* @param {string} label - Label for the screenshot (e.g., 'screenshot', 'before', 'after')
|
|
48
|
+
* @returns {Promise<{ s3Key: string, fileName: string } | null>}
|
|
49
|
+
*/
|
|
50
|
+
async function uploadToS3(apiRoot, apiKey, sandboxId, base64, label = 'screenshot') {
|
|
51
|
+
if (!base64 || !apiRoot || !apiKey) return null;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const fileName = `${label}-${Date.now()}.png`;
|
|
55
|
+
|
|
56
|
+
// Get presigned upload URL from API
|
|
57
|
+
const uploadUrlResponse = await httpPost(apiRoot, '/api/v7/runner/upload-url', {
|
|
58
|
+
apiKey,
|
|
59
|
+
sandboxId,
|
|
60
|
+
fileName,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!uploadUrlResponse || !uploadUrlResponse.uploadUrl) {
|
|
64
|
+
console.warn('[ably-service] No upload URL returned for screenshot');
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Upload to S3 via presigned URL
|
|
69
|
+
const pngBuffer = Buffer.from(base64, 'base64');
|
|
70
|
+
const url = new URL(uploadUrlResponse.uploadUrl);
|
|
71
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
72
|
+
|
|
73
|
+
await new Promise((resolve, reject) => {
|
|
74
|
+
const req = transport.request(url, {
|
|
75
|
+
method: 'PUT',
|
|
76
|
+
headers: {
|
|
77
|
+
'Content-Type': 'image/png',
|
|
78
|
+
'Content-Length': pngBuffer.length,
|
|
79
|
+
},
|
|
80
|
+
}, (res) => {
|
|
81
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
82
|
+
resolve();
|
|
83
|
+
} else {
|
|
84
|
+
let data = '';
|
|
85
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
86
|
+
res.on('end', () => reject(new Error(`S3 upload failed (${res.statusCode}): ${data}`)));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
req.on('error', reject);
|
|
90
|
+
req.write(pngBuffer);
|
|
91
|
+
req.end();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { s3Key: uploadUrlResponse.s3Key, fileName, size: pngBuffer.length };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[ably-service] Screenshot upload failed (${label}): ${err.message}`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* HTTP POST helper
|
|
103
|
+
*/
|
|
104
|
+
async function httpPost(apiRoot, path, body) {
|
|
105
|
+
const url = new URL(path, apiRoot);
|
|
106
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
107
|
+
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const req = transport.request(url, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
},
|
|
114
|
+
}, (res) => {
|
|
115
|
+
let data = '';
|
|
116
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
117
|
+
res.on('end', () => {
|
|
118
|
+
try {
|
|
119
|
+
resolve(JSON.parse(data));
|
|
120
|
+
} catch {
|
|
121
|
+
resolve(null);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
req.on('error', reject);
|
|
126
|
+
req.write(JSON.stringify(body));
|
|
127
|
+
req.end();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Ably Service Class ──────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
class AblyService extends EventEmitter {
|
|
134
|
+
/**
|
|
135
|
+
* @param {object} options
|
|
136
|
+
* @param {object} options.automation - Automation instance (has dispatch method)
|
|
137
|
+
* @param {string} options.ablyToken - Token or tokenRequest from the API
|
|
138
|
+
* @param {string} options.channel - Single session channel name
|
|
139
|
+
* @param {string} options.sandboxId - This sandbox's ID
|
|
140
|
+
* @param {string} [options.clientId] - Ably client ID (default: 'sandbox-{sandboxId}')
|
|
141
|
+
* @param {string} [options.apiRoot] - API base URL (for S3 uploads)
|
|
142
|
+
* @param {string} [options.apiKey] - Team API key (for S3 uploads)
|
|
143
|
+
*/
|
|
144
|
+
constructor(options) {
|
|
145
|
+
super();
|
|
146
|
+
this._automation = options.automation;
|
|
147
|
+
this._ablyToken = options.ablyToken;
|
|
148
|
+
this._channelName = options.channel;
|
|
149
|
+
this._sandboxId = options.sandboxId;
|
|
150
|
+
this._clientId = options.clientId || `agent-${options.sandboxId}`;
|
|
151
|
+
this._apiRoot = options.apiRoot;
|
|
152
|
+
this._apiKey = options.apiKey;
|
|
153
|
+
this._updateInfo = options.updateInfo || null;
|
|
154
|
+
|
|
155
|
+
this._ably = null;
|
|
156
|
+
this._sessionChannel = null;
|
|
157
|
+
this._connected = false;
|
|
158
|
+
this._statsInterval = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Connect to Ably and start listening for commands.
|
|
163
|
+
*/
|
|
164
|
+
async connect() {
|
|
165
|
+
const self = this; // Capture this for use in callbacks
|
|
166
|
+
this.emit('log', `Connecting to Ably as ${this._clientId}...`);
|
|
167
|
+
|
|
168
|
+
this._ably = new Ably.Realtime({
|
|
169
|
+
authCallback: (tokenParams, callback) => {
|
|
170
|
+
callback(null, this._ablyToken);
|
|
171
|
+
},
|
|
172
|
+
clientId: this._clientId,
|
|
173
|
+
disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
|
|
174
|
+
suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
|
|
175
|
+
logHandler: (msg) => {
|
|
176
|
+
const text = typeof msg === 'string' ? msg : String(msg);
|
|
177
|
+
const isError = /error/i.test(text) || /\[Level 1\]/i.test(text);
|
|
178
|
+
if (isError) {
|
|
179
|
+
Sentry.withScope((scope) => {
|
|
180
|
+
scope.setTag('ably.client', 'runner');
|
|
181
|
+
scope.setTag('sandbox.id', self._sandboxId);
|
|
182
|
+
scope.setContext('ably_log', { raw: text.slice(0, 2000) });
|
|
183
|
+
Sentry.captureMessage(`[Ably runner] ${text.slice(0, 500)}`, 'error');
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
Sentry.addBreadcrumb({
|
|
187
|
+
category: 'ably.log',
|
|
188
|
+
message: text.slice(0, 1000),
|
|
189
|
+
level: isError ? 'error' : 'info',
|
|
190
|
+
data: { client: 'runner', sandboxId: self._sandboxId },
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
logLevel: 2,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Wait for connection
|
|
197
|
+
await new Promise((resolve, reject) => {
|
|
198
|
+
this._ably.connection.on('connected', () => {
|
|
199
|
+
this._connected = true;
|
|
200
|
+
resolve();
|
|
201
|
+
});
|
|
202
|
+
this._ably.connection.on('failed', () => {
|
|
203
|
+
reject(new Error('Ably connection failed'));
|
|
204
|
+
});
|
|
205
|
+
setTimeout(() => {
|
|
206
|
+
reject(new Error('Ably connection timeout (30s)'));
|
|
207
|
+
}, 30000);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
this.emit('log', 'Ably connected');
|
|
211
|
+
|
|
212
|
+
// Get session channel
|
|
213
|
+
this._sessionChannel = this._ably.channels.get(this._channelName);
|
|
214
|
+
|
|
215
|
+
this.emit('log', `Ably session channel initialized: ${this._channelName}`);
|
|
216
|
+
|
|
217
|
+
// ─── Periodic stats logging ────────────────────────────────────────────
|
|
218
|
+
this._statsInterval = setInterval(() => {
|
|
219
|
+
const connState = this._ably ? this._ably.connection.state : 'no-client';
|
|
220
|
+
const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
|
|
221
|
+
this.emit('log', `[stats] connection=${connState} | sandbox=${this._sandboxId} | channel=${chState}`);
|
|
222
|
+
}, 10000);
|
|
223
|
+
|
|
224
|
+
// Forward debug logs from automation to SDK (only when debug mode is enabled)
|
|
225
|
+
this._debugMode = false;
|
|
226
|
+
this._automation.on('log', (message) => {
|
|
227
|
+
if (!this._debugMode) return;
|
|
228
|
+
this._sendResponse({
|
|
229
|
+
type: 'runner.log',
|
|
230
|
+
level: 'info',
|
|
231
|
+
message,
|
|
232
|
+
timestamp: Date.now(),
|
|
233
|
+
}).catch(() => {}); // best-effort
|
|
234
|
+
});
|
|
235
|
+
this._automation.on('warn', (message) => {
|
|
236
|
+
if (!this._debugMode) return;
|
|
237
|
+
this._sendResponse({
|
|
238
|
+
type: 'runner.log',
|
|
239
|
+
level: 'warn',
|
|
240
|
+
message,
|
|
241
|
+
timestamp: Date.now(),
|
|
242
|
+
}).catch(() => {}); // best-effort
|
|
243
|
+
});
|
|
244
|
+
this._automation.on('error', (message) => {
|
|
245
|
+
if (!this._debugMode) return;
|
|
246
|
+
this._sendResponse({
|
|
247
|
+
type: 'runner.log',
|
|
248
|
+
level: 'error',
|
|
249
|
+
message: typeof message === 'string' ? message : message.message || String(message),
|
|
250
|
+
timestamp: Date.now(),
|
|
251
|
+
}).catch(() => {}); // best-effort
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Forward exec streaming chunks to SDK
|
|
255
|
+
this._automation.on('exec.output', ({ requestId, chunk }) => {
|
|
256
|
+
this._sendResponse({
|
|
257
|
+
type: 'exec.output',
|
|
258
|
+
requestId,
|
|
259
|
+
chunk,
|
|
260
|
+
timestamp: Date.now(),
|
|
261
|
+
}).catch(() => {}); // best-effort, don't block exec
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Subscribe to commands
|
|
265
|
+
this._sessionChannel.subscribe('command', async (msg) => {
|
|
266
|
+
const message = msg.data;
|
|
267
|
+
if (!message) return;
|
|
268
|
+
|
|
269
|
+
const requestId = message.requestId;
|
|
270
|
+
const type = message.type;
|
|
271
|
+
|
|
272
|
+
this.emit('log', `Command received: ${type} (requestId=${requestId})`);
|
|
273
|
+
|
|
274
|
+
// Per-command timeout: use message.timeout if provided, else default 120s
|
|
275
|
+
// Prevents hanging forever if screenshot capture or S3 upload stalls
|
|
276
|
+
const commandTimeout = (message.timeout && message.timeout > 0)
|
|
277
|
+
? message.timeout + 5000 // Add 5s buffer over SDK-side timeout
|
|
278
|
+
: 120000;
|
|
279
|
+
|
|
280
|
+
// Wrap dispatch in Sentry distributed trace context
|
|
281
|
+
const sentryTrace = message.sentryTrace;
|
|
282
|
+
const baggage = message.baggage;
|
|
283
|
+
const executeCommand = async () => {
|
|
284
|
+
try {
|
|
285
|
+
let result = await Promise.race([
|
|
286
|
+
this._automation.dispatch(type, message),
|
|
287
|
+
new Promise((_, reject) =>
|
|
288
|
+
setTimeout(() => reject(new Error(
|
|
289
|
+
`Command '${type}' timed out on runner after ${commandTimeout}ms`
|
|
290
|
+
)), commandTimeout)
|
|
291
|
+
),
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
// Screenshots are now handled by automation.js (returns { s3Key })
|
|
295
|
+
// No need to check type here - just pass through the result
|
|
296
|
+
|
|
297
|
+
await this._sendResponse({
|
|
298
|
+
requestId,
|
|
299
|
+
type: `${type}.reply`,
|
|
300
|
+
result,
|
|
301
|
+
success: true,
|
|
302
|
+
});
|
|
303
|
+
this.emit('log', `Command completed: ${type} (requestId=${requestId})`);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
this.emit('log', `Command failed: ${type} — ${err.message}`);
|
|
306
|
+
Sentry.captureException(err);
|
|
307
|
+
await this._sendResponse({
|
|
308
|
+
requestId,
|
|
309
|
+
type: `${type}.reply`,
|
|
310
|
+
error: true,
|
|
311
|
+
errorMessage: err.message,
|
|
312
|
+
success: false,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
if (sentryTrace) {
|
|
318
|
+
await Sentry.continueTrace({ sentryTrace, baggage }, () => {
|
|
319
|
+
return Sentry.startSpan(
|
|
320
|
+
{ name: `runner.command.${type}`, op: 'runner.dispatch' },
|
|
321
|
+
executeCommand,
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
await executeCommand();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ─── Ably connection state monitoring → Sentry ─────────────────────────
|
|
330
|
+
this._ably.connection.on((stateChange) => {
|
|
331
|
+
const { current, previous, reason, retryIn } = stateChange;
|
|
332
|
+
const reasonMsg = reason ? (reason.message || reason.code || String(reason)) : undefined;
|
|
333
|
+
|
|
334
|
+
Sentry.addBreadcrumb({
|
|
335
|
+
category: 'ably.connection',
|
|
336
|
+
message: `runner: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}`,
|
|
337
|
+
level: current === 'connected' ? 'info' : 'warning',
|
|
338
|
+
data: { client: 'runner', from: previous, to: current, reason: reasonMsg, retryIn, sandboxId: this._sandboxId },
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Preserve original behavior
|
|
342
|
+
if (current === 'disconnected') {
|
|
343
|
+
this._connected = false;
|
|
344
|
+
this.emit('log', `Ably connection: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}${retryIn ? ' (retryIn=' + retryIn + 'ms)' : ''}`);
|
|
345
|
+
this.emit('log', 'Ably disconnected — will auto-reconnect');
|
|
346
|
+
} else if (current === 'connected' && previous !== 'initialized') {
|
|
347
|
+
if (!this._connected) {
|
|
348
|
+
this._connected = true;
|
|
349
|
+
this.emit('log', `Ably connection: ${previous} → ${current}`);
|
|
350
|
+
this.emit('log', 'Ably reconnected');
|
|
351
|
+
}
|
|
352
|
+
} else if (current === 'failed') {
|
|
353
|
+
this._connected = false;
|
|
354
|
+
this.emit('log', `Ably connection: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}`);
|
|
355
|
+
this.emit('error', new Error('Ably connection failed'));
|
|
356
|
+
} else if (current === 'suspended') {
|
|
357
|
+
this._connected = false;
|
|
358
|
+
this.emit('log', `Ably connection: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}`);
|
|
359
|
+
this.emit('log', 'Ably suspended — connection lost for extended period, will keep retrying');
|
|
360
|
+
} else if (current === 'closed') {
|
|
361
|
+
this._connected = false;
|
|
362
|
+
this.emit('log', `Ably connection: ${previous} → ${current}`);
|
|
363
|
+
this.emit('disconnected');
|
|
364
|
+
} else {
|
|
365
|
+
this.emit('log', `Ably connection: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Capture exceptions for bad states
|
|
369
|
+
if (current === 'failed' || current === 'suspended' || (current === 'disconnected' && reason)) {
|
|
370
|
+
Sentry.withScope((scope) => {
|
|
371
|
+
scope.setTag('ably.client', 'runner');
|
|
372
|
+
scope.setTag('ably.state', current);
|
|
373
|
+
scope.setTag('sandbox.id', this._sandboxId);
|
|
374
|
+
scope.setContext('ably_connection', { from: previous, to: current, reason: reasonMsg, retryIn });
|
|
375
|
+
const err = reason instanceof Error ? reason : new Error('Ably connection state error');
|
|
376
|
+
err.name = 'AblyConnectionError';
|
|
377
|
+
Sentry.captureException(err);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ─── Ably channel state monitoring → Sentry ───────────────────────────
|
|
383
|
+
const sessionCh = this._sessionChannel;
|
|
384
|
+
if (sessionCh) {
|
|
385
|
+
sessionCh.on((stateChange) => {
|
|
386
|
+
const { current, previous, reason } = stateChange;
|
|
387
|
+
const reasonMsg = reason ? (reason.message || reason.code || String(reason)) : undefined;
|
|
388
|
+
|
|
389
|
+
Sentry.addBreadcrumb({
|
|
390
|
+
category: 'ably.channel',
|
|
391
|
+
message: `runner [session]: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}`,
|
|
392
|
+
level: current === 'attached' ? 'info' : 'warning',
|
|
393
|
+
data: { client: 'runner', channel: sessionCh.name, from: previous, to: current, reason: reasonMsg, sandboxId: this._sandboxId },
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
this.emit('log', `Ably channel [session]: ${previous} → ${current}${reasonMsg ? ' — ' + reasonMsg : ''}`);
|
|
397
|
+
|
|
398
|
+
if (current === 'failed' || current === 'suspended') {
|
|
399
|
+
Sentry.withScope((scope) => {
|
|
400
|
+
scope.setTag('ably.client', 'runner');
|
|
401
|
+
scope.setTag('ably.channel', sessionCh.name);
|
|
402
|
+
scope.setTag('ably.channel_state', current);
|
|
403
|
+
scope.setTag('sandbox.id', this._sandboxId);
|
|
404
|
+
scope.setContext('ably_channel', { channel: sessionCh.name, from: previous, to: current, reason: reasonMsg, sandboxId: this._sandboxId });
|
|
405
|
+
const err = reason instanceof Error ? reason : new Error('Ably channel state error');
|
|
406
|
+
err.name = 'AblyChannelError';
|
|
407
|
+
Sentry.captureException(err);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Subscribe to control messages
|
|
414
|
+
this._sessionChannel.subscribe('control', async (msg) => {
|
|
415
|
+
const message = msg.data;
|
|
416
|
+
if (!message) return;
|
|
417
|
+
|
|
418
|
+
this.emit('log', `Control message: ${message.type}`);
|
|
419
|
+
|
|
420
|
+
if (message.type === 'disconnect' || message.type === 'end-session') {
|
|
421
|
+
this.emit('log', 'Received disconnect request');
|
|
422
|
+
this.emit('disconnected');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (message.type === 'keepalive') {
|
|
426
|
+
await this.sendControl({ type: 'keepalive.ack' });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (message.type === 'debug') {
|
|
430
|
+
this._debugMode = !!message.enabled;
|
|
431
|
+
this.emit('log', `Debug mode ${this._debugMode ? 'enabled' : 'disabled'}`);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
this.emit('log', 'Listening for commands on Ably');
|
|
436
|
+
|
|
437
|
+
// Signal readiness to SDK — commands sent before this would be lost
|
|
438
|
+
const readyPayload = {
|
|
439
|
+
type: 'runner.ready',
|
|
440
|
+
os: 'windows',
|
|
441
|
+
sandboxId: this._sandboxId,
|
|
442
|
+
runnerVersion: getLocalVersion() || 'unknown',
|
|
443
|
+
timestamp: Date.now(),
|
|
444
|
+
};
|
|
445
|
+
if (this._updateInfo) {
|
|
446
|
+
readyPayload.update = {
|
|
447
|
+
status: this._updateInfo.status,
|
|
448
|
+
localVersion: this._updateInfo.localVersion,
|
|
449
|
+
remoteVersion: this._updateInfo.remoteVersion,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
await this._sessionChannel.publish('control', readyPayload);
|
|
453
|
+
this.emit('log', 'Published runner.ready signal');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Send a response on the session channel.
|
|
458
|
+
*/
|
|
459
|
+
async _sendResponse(message) {
|
|
460
|
+
if (!this._sessionChannel) {
|
|
461
|
+
this.emit('log', 'Warning: session channel not available');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
this.emit('log', `Publishing response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
467
|
+
await this._sessionChannel.publish('response', message);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
this.emit('log', `Failed to publish response: ${err.message}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Send a file/screenshot reference on the session channel.
|
|
475
|
+
*/
|
|
476
|
+
async sendFile(message) {
|
|
477
|
+
if (!this._sessionChannel) return;
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
this.emit('log', `Publishing file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
481
|
+
await this._sessionChannel.publish('file', message);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
this.emit('log', `Failed to publish file: ${err.message}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Send a control message.
|
|
489
|
+
*/
|
|
490
|
+
async sendControl(message) {
|
|
491
|
+
if (!this._sessionChannel) return;
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
this.emit('log', `Publishing control: type=${message.type || 'unknown'}`);
|
|
495
|
+
await this._sessionChannel.publish('control', message);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
this.emit('log', `Failed to publish control: ${err.message}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Check if connected to Ably.
|
|
503
|
+
*/
|
|
504
|
+
isConnected() {
|
|
505
|
+
return this._connected && this._ably && this._ably.connection.state === 'connected';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Disconnect from Ably and clean up.
|
|
510
|
+
*/
|
|
511
|
+
async close() {
|
|
512
|
+
this.emit('log', 'Closing Ably service...');
|
|
513
|
+
|
|
514
|
+
if (this._statsInterval) {
|
|
515
|
+
clearInterval(this._statsInterval);
|
|
516
|
+
this._statsInterval = null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
if (this._sessionChannel) this._sessionChannel.detach();
|
|
521
|
+
} catch {}
|
|
522
|
+
|
|
523
|
+
if (this._ably) {
|
|
524
|
+
try {
|
|
525
|
+
this._ably.close();
|
|
526
|
+
} catch {}
|
|
527
|
+
this._ably = null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
this._connected = false;
|
|
531
|
+
this._sessionChannel = null;
|
|
532
|
+
|
|
533
|
+
this.emit('log', 'Ably service closed');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
module.exports = { AblyService };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* automation-bridge.js — Direct in-process automation bridge
|
|
3
|
+
*
|
|
4
|
+
* Replaces pyautogui-local.js. Instead of spawning a Python process and
|
|
5
|
+
* connecting via WebSocket, this bridge imports the Automation module
|
|
6
|
+
* directly and calls dispatch() in-process.
|
|
7
|
+
*
|
|
8
|
+
* Same public interface as PyAutoGUIBridge:
|
|
9
|
+
* - extends EventEmitter (emits 'log', 'error')
|
|
10
|
+
* - start() → ready
|
|
11
|
+
* - stop()
|
|
12
|
+
* - sendCommand(command, data) → Promise<result>
|
|
13
|
+
*/
|
|
14
|
+
const { EventEmitter } = require('events');
|
|
15
|
+
const { Automation } = require('./automation');
|
|
16
|
+
|
|
17
|
+
class AutomationBridge extends EventEmitter {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
super();
|
|
20
|
+
this._automation = null;
|
|
21
|
+
this._timeout = options.commandTimeout || 30000; // 30s default
|
|
22
|
+
this.started = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Initialize the automation module.
|
|
27
|
+
* No subprocess or network connection needed — just instantiate the class.
|
|
28
|
+
*/
|
|
29
|
+
async start() {
|
|
30
|
+
this.emit('log', 'Starting automation bridge (in-process)');
|
|
31
|
+
|
|
32
|
+
this._automation = new Automation();
|
|
33
|
+
this.started = true;
|
|
34
|
+
|
|
35
|
+
this.emit('log', 'Automation bridge ready');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Execute a command via the automation module.
|
|
40
|
+
* @param {string} command - Command name (e.g. 'click', 'screenshot', 'exec')
|
|
41
|
+
* @param {object} data - Command parameters
|
|
42
|
+
* @returns {Promise<*>} Result value
|
|
43
|
+
*/
|
|
44
|
+
async sendCommand(command, data = {}) {
|
|
45
|
+
if (!this.started || !this._automation) {
|
|
46
|
+
throw new Error('Automation bridge not started');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Calculate timeout: use per-command timeout if provided, otherwise default
|
|
50
|
+
const timeoutMs = (data && data.timeout && data.timeout > 0)
|
|
51
|
+
? (data.timeout * 1000) + 5000 // data.timeout is in seconds for exec; add 5s buffer
|
|
52
|
+
: this._timeout;
|
|
53
|
+
|
|
54
|
+
// Wrap dispatch in a timeout
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
reject(new Error(`Automation command timed out: ${command} (${timeoutMs}ms)`));
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
|
|
60
|
+
this._automation.dispatch(command, data)
|
|
61
|
+
.then((result) => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
resolve(result);
|
|
64
|
+
})
|
|
65
|
+
.catch((err) => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
reject(err);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Stop the automation bridge and clean up resources.
|
|
74
|
+
*/
|
|
75
|
+
stop() {
|
|
76
|
+
this.started = false;
|
|
77
|
+
if (this._automation) {
|
|
78
|
+
this._automation.cleanup();
|
|
79
|
+
this._automation = null;
|
|
80
|
+
}
|
|
81
|
+
this.emit('log', 'Automation bridge stopped');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { AutomationBridge };
|