@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.
@@ -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 };