testdriverai 7.8.0-test.9 → 7.9.0-test.1
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/agent/index.js +12 -0
- package/agent/lib/http.js +21 -3
- package/agent/lib/logger.js +15 -0
- package/agent/lib/provision-commands.js +176 -0
- package/agent/lib/sandbox.js +667 -118
- package/agent/lib/sdk.js +1 -20
- package/ai/skills/testdriver-find/SKILL.md +14 -20
- package/docs/_data/examples-manifest.json +46 -46
- package/docs/_scripts/extract-example-urls.js +67 -72
- package/docs/changelog.mdx +26 -0
- package/docs/docs.json +2 -1
- package/docs/v7/examples/ai.mdx +1 -1
- package/docs/v7/examples/assert.mdx +1 -1
- package/docs/v7/examples/captcha-api.mdx +1 -1
- package/docs/v7/examples/chrome-extension.mdx +1 -1
- package/docs/v7/examples/drag-and-drop.mdx +1 -1
- package/docs/v7/examples/element-not-found.mdx +1 -1
- package/docs/v7/examples/exec-output.mdx +1 -1
- package/docs/v7/examples/exec-pwsh.mdx +1 -1
- package/docs/v7/examples/focus-window.mdx +1 -1
- package/docs/v7/examples/hover-image.mdx +1 -1
- package/docs/v7/examples/hover-text.mdx +1 -1
- package/docs/v7/examples/installer.mdx +1 -1
- package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
- package/docs/v7/examples/match-image.mdx +1 -1
- package/docs/v7/examples/press-keys.mdx +1 -1
- package/docs/v7/examples/scroll-keyboard.mdx +1 -1
- package/docs/v7/examples/scroll-until-image.mdx +1 -1
- package/docs/v7/examples/scroll-until-text.mdx +1 -1
- package/docs/v7/examples/scroll.mdx +1 -1
- package/docs/v7/examples/type.mdx +1 -1
- package/docs/v7/examples/windows-installer.mdx +1 -1
- package/docs/v7/find.mdx +14 -20
- package/docs/v7/test-results-json.mdx +258 -0
- package/examples/scroll-keyboard.test.mjs +1 -1
- package/examples/scroll.test.mjs +1 -12
- package/interfaces/vitest-plugin.mjs +167 -51
- package/lib/core/Dashcam.js +18 -31
- package/lib/environments.json +8 -4
- package/lib/github-comment.mjs +58 -40
- package/lib/init-project.js +5 -67
- package/lib/resolve-channel.js +39 -10
- package/lib/sentry.js +47 -23
- package/lib/vitest/hooks.mjs +117 -20
- package/manual/exec-stream-logs.test.mjs +25 -0
- package/mcp-server/dist/server.mjs +28 -8
- package/mcp-server/src/server.ts +31 -8
- package/package.json +2 -1
- package/sdk.d.ts +4 -0
- package/sdk.js +42 -12
- package/setup/aws/install-dev-runner.sh +79 -0
- package/setup/aws/spawn-runner.sh +165 -0
- package/test-sentry-span.js +35 -0
- package/vitest.config.mjs +7 -3
- package/vitest.runner.config.mjs +33 -0
- package/docs/v7/_drafts/core.mdx +0 -458
package/agent/lib/sandbox.js
CHANGED
|
@@ -4,16 +4,14 @@ const { events } = require("../events");
|
|
|
4
4
|
const logger = require("./logger");
|
|
5
5
|
const { version } = require("../../package.json");
|
|
6
6
|
const { withRetry, getSentryTraceHeaders } = require("./sdk");
|
|
7
|
+
const sentry = require("../../lib/sentry");
|
|
7
8
|
|
|
8
9
|
const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
9
10
|
class Sandbox {
|
|
10
11
|
constructor() {
|
|
11
12
|
this._ably = null;
|
|
12
|
-
this.
|
|
13
|
-
this.
|
|
14
|
-
this._ctrlChannel = null;
|
|
15
|
-
this._filesChannel = null;
|
|
16
|
-
this._channelNames = null;
|
|
13
|
+
this._sessionChannel = null;
|
|
14
|
+
this._channelName = null;
|
|
17
15
|
this.ps = {};
|
|
18
16
|
this._execBuffers = {}; // accumulate streamed exec.output chunks per requestId
|
|
19
17
|
this.heartbeat = null;
|
|
@@ -31,6 +29,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
31
29
|
this._lastConnectParams = null;
|
|
32
30
|
this._teamId = null;
|
|
33
31
|
this._sandboxId = null;
|
|
32
|
+
this._disconnectedAt = null; // tracks when Realtime connection dropped (for timeout extension on reconnect)
|
|
34
33
|
|
|
35
34
|
// Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
|
|
36
35
|
this._publishLastTime = 0;
|
|
@@ -50,7 +49,11 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
50
49
|
);
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
|
|
52
|
+
getPublishCount() {
|
|
53
|
+
return this._publishCount;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async _initAbly(ablyToken, channelName) {
|
|
54
57
|
if (this._ably) {
|
|
55
58
|
try {
|
|
56
59
|
this._ably.close();
|
|
@@ -58,48 +61,69 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
58
61
|
/* ignore */
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
|
-
this.
|
|
64
|
+
this._channelName = channelName;
|
|
62
65
|
var self = this;
|
|
63
66
|
|
|
64
67
|
this._ably = new Ably.Realtime({
|
|
65
|
-
authCallback: function (tokenParams, callback) {
|
|
66
|
-
|
|
68
|
+
authCallback: async function (tokenParams, callback) {
|
|
69
|
+
// On initial connect Ably may supply the token directly; on renewal
|
|
70
|
+
// we must fetch a fresh one from the API (the original token will
|
|
71
|
+
// have expired, causing 40143 token.unrecognized if reused).
|
|
72
|
+
try {
|
|
73
|
+
const response = await axios({
|
|
74
|
+
method: "post",
|
|
75
|
+
url: self.apiRoot + "/api/v7/sandbox/ably-token",
|
|
76
|
+
data: { apiKey: self.apiKey, sandboxId: self._sandboxId },
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
timeout: 15000,
|
|
79
|
+
});
|
|
80
|
+
callback(null, response.data.token);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logger.warn("[ably] Token renewal failed, falling back to original token: " + (err.message || err));
|
|
83
|
+
callback(null, ablyToken);
|
|
84
|
+
}
|
|
67
85
|
},
|
|
68
86
|
clientId: "sdk-" + this._sandboxId,
|
|
87
|
+
echoMessages: false, // don't receive our own published messages
|
|
69
88
|
disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
|
|
70
89
|
suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
|
|
71
90
|
});
|
|
72
91
|
|
|
92
|
+
logger.debug(`[realtime] Connecting as sdk-${this._sandboxId}...`);
|
|
93
|
+
|
|
73
94
|
await new Promise(function (resolve, reject) {
|
|
74
95
|
self._ably.connection.on("connected", resolve);
|
|
75
96
|
self._ably.connection.on("failed", function () {
|
|
76
|
-
reject(new Error("
|
|
97
|
+
reject(new Error("Realtime connection failed"));
|
|
77
98
|
});
|
|
78
99
|
setTimeout(function () {
|
|
79
|
-
reject(new Error("
|
|
100
|
+
reject(new Error("Realtime connection timeout"));
|
|
80
101
|
}, 30000);
|
|
81
102
|
});
|
|
82
103
|
|
|
83
|
-
this.
|
|
84
|
-
this._respChannel = this._ably.channels.get(channelNames.responses);
|
|
85
|
-
this._ctrlChannel = this._ably.channels.get(channelNames.control);
|
|
86
|
-
this._filesChannel = this._ably.channels.get(channelNames.files);
|
|
104
|
+
this._sessionChannel = this._ably.channels.get(channelName);
|
|
87
105
|
|
|
88
|
-
|
|
106
|
+
logger.debug(`[realtime] Channel initialized: ${channelName}`);
|
|
107
|
+
|
|
108
|
+
// Enter presence on the session channel so the API can count connected SDK clients
|
|
89
109
|
try {
|
|
90
|
-
await this.
|
|
110
|
+
await this._sessionChannel.presence.enter({
|
|
91
111
|
sandboxId: this._sandboxId,
|
|
92
112
|
connectedAt: Date.now(),
|
|
93
113
|
});
|
|
114
|
+
logger.debug(`[realtime] Entered presence on session channel (sandbox=${this._sandboxId})`);
|
|
94
115
|
} catch (e) {
|
|
95
116
|
// Non-fatal — presence is used for concurrency counting, not critical path
|
|
96
|
-
logger.warn("Failed to enter presence on
|
|
117
|
+
logger.warn("Failed to enter presence on session channel: " + (e.message || e));
|
|
97
118
|
}
|
|
98
119
|
|
|
99
|
-
|
|
120
|
+
// Save subscription references for historyBeforeSubscribe() during discontinuity recovery
|
|
121
|
+
this._onResponseMsg = function (msg) {
|
|
100
122
|
var message = msg.data;
|
|
101
123
|
if (!message) return;
|
|
102
124
|
|
|
125
|
+
logger.debug(`[realtime] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
126
|
+
|
|
103
127
|
if (message.type === "sandbox.progress") {
|
|
104
128
|
emitter.emit(events.sandbox.progress, {
|
|
105
129
|
step: message.step,
|
|
@@ -150,31 +174,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
150
174
|
}
|
|
151
175
|
|
|
152
176
|
if (!message.requestId || !self.ps[message.requestId]) {
|
|
153
|
-
var
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
177
|
+
var pendingIds = Object.keys(self.ps);
|
|
178
|
+
var pendingSummary = pendingIds.length > 0
|
|
179
|
+
? pendingIds.map(function (rid) {
|
|
180
|
+
var e = self.ps[rid];
|
|
181
|
+
return rid + '(' + (e && e.message ? e.message.type : '?') + ')';
|
|
182
|
+
}).join(', ')
|
|
183
|
+
: 'none';
|
|
184
|
+
logger.debug(
|
|
185
|
+
'[realtime] No pending promise for requestId=' + (message.requestId || 'null') +
|
|
186
|
+
' | response type=' + (message.type || 'unknown') +
|
|
187
|
+
' | error=' + (message.error ? (message.errorMessage || 'true') : 'false') +
|
|
188
|
+
' | currently pending: [' + pendingSummary + ']'
|
|
189
|
+
);
|
|
161
190
|
return;
|
|
162
191
|
}
|
|
163
192
|
|
|
164
193
|
if (message.error) {
|
|
165
|
-
var
|
|
166
|
-
|
|
167
|
-
|
|
194
|
+
var pendingEntry = self.ps[message.requestId];
|
|
195
|
+
var pendingMessage = pendingEntry && pendingEntry.message;
|
|
196
|
+
var pendingAge = pendingEntry && pendingEntry.startTime
|
|
197
|
+
? ((Date.now() - pendingEntry.startTime) / 1000).toFixed(1) + 's'
|
|
198
|
+
: '?';
|
|
199
|
+
logger.debug(
|
|
200
|
+
'[realtime] Promise REJECTED: requestId=' + message.requestId +
|
|
201
|
+
' | type=' + (pendingMessage ? pendingMessage.type : 'unknown') +
|
|
202
|
+
' | age=' + pendingAge +
|
|
203
|
+
' | error=' + (message.errorMessage || 'Sandbox error')
|
|
204
|
+
);
|
|
168
205
|
if (!pendingMessage || pendingMessage.type !== "output") {
|
|
169
206
|
emitter.emit(events.error.sandbox, message.errorMessage);
|
|
170
207
|
}
|
|
171
208
|
var error = new Error(message.errorMessage || "Sandbox error");
|
|
172
209
|
error.responseData = message;
|
|
173
210
|
delete self._execBuffers[message.requestId];
|
|
174
|
-
|
|
211
|
+
pendingEntry.reject(error);
|
|
175
212
|
} else {
|
|
176
213
|
emitter.emit(events.sandbox.received);
|
|
177
214
|
if (self.ps[message.requestId]) {
|
|
215
|
+
var resolveEntry = self.ps[message.requestId];
|
|
216
|
+
var resolveAge = resolveEntry.startTime
|
|
217
|
+
? ((Date.now() - resolveEntry.startTime) / 1000).toFixed(1) + 's'
|
|
218
|
+
: '?';
|
|
219
|
+
logger.debug(
|
|
220
|
+
'[realtime] Promise RESOLVED: requestId=' + message.requestId +
|
|
221
|
+
' | type=' + (resolveEntry.message ? resolveEntry.message.type : 'unknown') +
|
|
222
|
+
' | age=' + resolveAge
|
|
223
|
+
);
|
|
178
224
|
// Unwrap the result from the Ably response envelope
|
|
179
225
|
// The runner sends { requestId, type, result, success }
|
|
180
226
|
// But SDK commands expect just the result object
|
|
@@ -193,42 +239,156 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
193
239
|
}
|
|
194
240
|
}
|
|
195
241
|
delete self.ps[message.requestId];
|
|
196
|
-
}
|
|
242
|
+
};
|
|
243
|
+
this._responseSubscription = await this._sessionChannel.subscribe("response", this._onResponseMsg);
|
|
197
244
|
|
|
198
|
-
this.
|
|
245
|
+
this._onFileMsg = function (msg) {
|
|
199
246
|
var message = msg.data;
|
|
200
247
|
if (!message) return;
|
|
248
|
+
logger.debug(`[realtime] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
201
249
|
if (message.requestId && self.ps[message.requestId]) {
|
|
202
250
|
emitter.emit(events.sandbox.received);
|
|
203
251
|
self.ps[message.requestId].resolve(message);
|
|
204
252
|
delete self.ps[message.requestId];
|
|
205
253
|
}
|
|
206
254
|
emitter.emit(events.sandbox.file, message);
|
|
207
|
-
}
|
|
255
|
+
};
|
|
256
|
+
this._fileSubscription = await this._sessionChannel.subscribe("file", this._onFileMsg);
|
|
208
257
|
|
|
209
|
-
this.heartbeat = setInterval(function () {}, 5000);
|
|
258
|
+
this.heartbeat = setInterval(function () { }, 5000);
|
|
210
259
|
if (this.heartbeat.unref) this.heartbeat.unref();
|
|
211
260
|
|
|
261
|
+
// ─── Periodic stats logging ────────────────────────────────────────
|
|
262
|
+
this._statsInterval = setInterval(() => {
|
|
263
|
+
const connState = this._ably ? this._ably.connection.state : 'no-client';
|
|
264
|
+
const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
|
|
265
|
+
const pendingIds = Object.keys(this.ps);
|
|
266
|
+
const pending = pendingIds.length;
|
|
267
|
+
logger.debug(`[realtime][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
|
|
268
|
+
if (pending > 0) {
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
for (const rid of pendingIds) {
|
|
271
|
+
const entry = this.ps[rid];
|
|
272
|
+
if (!entry) continue;
|
|
273
|
+
const type = entry.message ? entry.message.type : 'unknown';
|
|
274
|
+
const ageSec = ((now - (entry.startTime || now)) / 1000).toFixed(1);
|
|
275
|
+
logger.debug(`[realtime][stats] pending: requestId=${rid} | type=${type} | age=${ageSec}s`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}, 10000);
|
|
279
|
+
if (this._statsInterval.unref) this._statsInterval.unref();
|
|
280
|
+
|
|
212
281
|
this._ably.connection.on("disconnected", function () {
|
|
213
|
-
logger.
|
|
282
|
+
logger.debug("[realtime] Connection: disconnected - will auto-reconnect");
|
|
283
|
+
self._disconnectedAt = Date.now();
|
|
214
284
|
});
|
|
215
285
|
|
|
216
286
|
this._ably.connection.on("connected", function () {
|
|
217
287
|
// Log reconnection so the user knows the blip was recovered
|
|
218
|
-
logger.
|
|
288
|
+
logger.debug("[realtime] Connection: reconnected");
|
|
289
|
+
// Extend any pending command timeouts by the disconnection duration so
|
|
290
|
+
// commands whose timer was counting down while the connection was down
|
|
291
|
+
// don't get incorrectly timed out.
|
|
292
|
+
if (self._disconnectedAt) {
|
|
293
|
+
var disconnectionDurationMs = Date.now() - self._disconnectedAt;
|
|
294
|
+
self._disconnectedAt = null;
|
|
295
|
+
var pendingIds = Object.keys(self.ps);
|
|
296
|
+
if (pendingIds.length > 0) {
|
|
297
|
+
logger.debug(
|
|
298
|
+
'[realtime] Extending ' + pendingIds.length + ' pending timeout(s) by ' +
|
|
299
|
+
disconnectionDurationMs + 'ms after disconnection'
|
|
300
|
+
);
|
|
301
|
+
for (var i = 0; i < pendingIds.length; i++) {
|
|
302
|
+
var entry = self.ps[pendingIds[i]];
|
|
303
|
+
if (entry && typeof entry.extendTimeout === 'function') {
|
|
304
|
+
entry.extendTimeout(disconnectionDurationMs);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
219
309
|
});
|
|
220
310
|
|
|
221
311
|
this._ably.connection.on("suspended", function () {
|
|
222
|
-
logger.
|
|
312
|
+
logger.debug("[realtime] Connection: suspended - connection lost for extended period, will keep retrying");
|
|
223
313
|
});
|
|
224
314
|
|
|
225
315
|
this._ably.connection.on("failed", function () {
|
|
316
|
+
logger.debug("[realtime] Connection: failed");
|
|
226
317
|
self.apiSocketConnected = false;
|
|
227
318
|
self.instanceSocketConnected = false;
|
|
228
|
-
emitter.emit(events.error.sandbox, "
|
|
319
|
+
emitter.emit(events.error.sandbox, "Realtime connection failed");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ─── Channel discontinuity detection ──────────────────────────────
|
|
323
|
+
// Set up BEFORE subscribing so we catch any continuity loss during
|
|
324
|
+
// the initial attachment. Fires at the channel level, covering all
|
|
325
|
+
// message types (response, file, control).
|
|
326
|
+
this._sessionChannel.on(function (stateChange) {
|
|
327
|
+
var current = stateChange.current;
|
|
328
|
+
var previous = stateChange.previous;
|
|
329
|
+
var reason = stateChange.reason;
|
|
330
|
+
var reasonMsg = reason ? (reason.message || reason.code || String(reason)) : '';
|
|
331
|
+
|
|
332
|
+
if (current === 'attached' && stateChange.resumed === false && previous === 'attached') {
|
|
333
|
+
logger.debug('[realtime] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
|
|
334
|
+
emitter.emit(events.sandbox.progress, {
|
|
335
|
+
step: 'discontinuity',
|
|
336
|
+
message: 'Recovering missed messages after connection interruption...',
|
|
337
|
+
});
|
|
338
|
+
self._recoverFromDiscontinuity();
|
|
339
|
+
}
|
|
229
340
|
});
|
|
230
341
|
}
|
|
231
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Recover missed messages after a channel discontinuity.
|
|
345
|
+
* Uses historyBeforeSubscribe() on each subscription, which guarantees
|
|
346
|
+
* no gap between historical and live messages. Each recovered message
|
|
347
|
+
* is dispatched through the same handler that processes live messages
|
|
348
|
+
* so that pending promises are resolved/rejected correctly.
|
|
349
|
+
*/
|
|
350
|
+
async _recoverFromDiscontinuity() {
|
|
351
|
+
var subs = [
|
|
352
|
+
{ name: 'response', sub: this._responseSubscription, handler: this._onResponseMsg },
|
|
353
|
+
{ name: 'file', sub: this._fileSubscription, handler: this._onFileMsg },
|
|
354
|
+
];
|
|
355
|
+
var totalRecovered = 0;
|
|
356
|
+
for (var i = 0; i < subs.length; i++) {
|
|
357
|
+
var entry = subs[i];
|
|
358
|
+
if (!entry.sub) continue;
|
|
359
|
+
try {
|
|
360
|
+
logger.debug('[realtime] Discontinuity recovery: fetching historyBeforeSubscribe for ' + entry.name + '...');
|
|
361
|
+
var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
|
|
362
|
+
var recovered = 0;
|
|
363
|
+
while (page) {
|
|
364
|
+
// Replay each missed message through the handler so pending
|
|
365
|
+
// promises get resolved instead of timing out.
|
|
366
|
+
for (var j = 0; j < page.items.length; j++) {
|
|
367
|
+
recovered++;
|
|
368
|
+
try {
|
|
369
|
+
if (entry.handler) {
|
|
370
|
+
logger.debug('[realtime] Replaying recovered ' + entry.name + ' message (requestId=' + (page.items[j].data && page.items[j].data.requestId || 'none') + ')');
|
|
371
|
+
entry.handler(page.items[j]);
|
|
372
|
+
}
|
|
373
|
+
} catch (replayErr) {
|
|
374
|
+
logger.debug('[realtime] Error replaying recovered message: ' + (replayErr.message || replayErr));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
page = page.hasNext() ? await page.next() : null;
|
|
378
|
+
}
|
|
379
|
+
totalRecovered += recovered;
|
|
380
|
+
logger.debug('[realtime] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
|
|
381
|
+
} catch (err) {
|
|
382
|
+
logger.debug('[realtime] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (totalRecovered > 0) {
|
|
386
|
+
logger.debug('[realtime] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
|
|
387
|
+
} else {
|
|
388
|
+
logger.debug('[realtime] Discontinuity recovery: no missed messages found');
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
232
392
|
/**
|
|
233
393
|
* POST to the API with retry for transient network errors (via withRetry)
|
|
234
394
|
* and infinite polling for CONCURRENCY_LIMIT_EXCEEDED (until vitest's
|
|
@@ -266,10 +426,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
266
426
|
var elapsed = Date.now() - startTime;
|
|
267
427
|
logger.warn(
|
|
268
428
|
"Transient network error: " + (error.message || error.code) +
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
429
|
+
" — POST " + path +
|
|
430
|
+
" — retry " + attempt + "/3" +
|
|
431
|
+
" in " + (delayMs / 1000).toFixed(1) + "s" +
|
|
432
|
+
" (" + Math.round(elapsed / 1000) + "s elapsed)...",
|
|
273
433
|
);
|
|
274
434
|
},
|
|
275
435
|
});
|
|
@@ -281,10 +441,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
281
441
|
var elapsed = Date.now() - startTime;
|
|
282
442
|
logger.log(
|
|
283
443
|
"Concurrency limit reached — waiting " +
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
444
|
+
concurrencyRetryInterval / 1000 +
|
|
445
|
+
"s for a slot to become available (" +
|
|
446
|
+
Math.round(elapsed / 1000) +
|
|
447
|
+
"s elapsed)...",
|
|
288
448
|
);
|
|
289
449
|
await new Promise(function (resolve) {
|
|
290
450
|
var t = setTimeout(resolve, concurrencyRetryInterval);
|
|
@@ -333,6 +493,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
333
493
|
body.ci = message.ci;
|
|
334
494
|
if (message.ami) body.ami = message.ami;
|
|
335
495
|
if (message.instanceType) body.instanceType = message.instanceType;
|
|
496
|
+
if (message.e2bTemplateId) body.e2bTemplateId = message.e2bTemplateId;
|
|
336
497
|
if (message.keepAlive !== undefined) body.keepAlive = message.keepAlive;
|
|
337
498
|
}
|
|
338
499
|
|
|
@@ -361,62 +522,238 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
361
522
|
this._teamId = reply.teamId;
|
|
362
523
|
|
|
363
524
|
if (reply.ably && reply.ably.token) {
|
|
364
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
525
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
365
526
|
this.instanceSocketConnected = true;
|
|
366
527
|
|
|
367
528
|
// Tell the runner to enable debug log forwarding if debug mode is on
|
|
368
529
|
var debugMode =
|
|
369
530
|
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
370
|
-
if (debugMode && this.
|
|
371
|
-
this.
|
|
531
|
+
if (debugMode && this._sessionChannel) {
|
|
532
|
+
this._sessionChannel.publish("control", {
|
|
372
533
|
type: "debug",
|
|
373
534
|
enabled: true,
|
|
374
535
|
});
|
|
375
536
|
}
|
|
376
537
|
}
|
|
377
538
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
539
|
+
// ─── Handle pending slot claim (trigger.dev waitpoint flow) ────
|
|
540
|
+
// The API returned early with status: 'pending'. The SDK has now
|
|
541
|
+
// connected to Ably and entered presence (done in _initAbly above).
|
|
542
|
+
// Wait for the claim-slot task to publish slot-approved or slot-denied
|
|
543
|
+
// on the control channel, then re-call authenticate with slotApproved.
|
|
544
|
+
// On slot-denied, we poll forever (re-calling authenticate every 10s)
|
|
545
|
+
// until a slot opens, matching _httpPostWithConcurrencyRetry behavior.
|
|
546
|
+
var concurrencyRetryInterval = 10000;
|
|
547
|
+
var slotPollStart = Date.now();
|
|
548
|
+
while (reply.status === 'pending') {
|
|
549
|
+
logger.log('Slot claim pending — waiting for approval via Ably...');
|
|
550
|
+
|
|
551
|
+
var self = this;
|
|
552
|
+
var slotResolved = false;
|
|
553
|
+
var slotResolve, slotReject;
|
|
554
|
+
var slotDecisionPromise = new Promise(function (resolve, reject) {
|
|
555
|
+
slotResolve = resolve;
|
|
556
|
+
slotReject = reject;
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
var slotTimeout = setTimeout(function () {
|
|
560
|
+
if (slotResolved) return;
|
|
561
|
+
slotResolved = true;
|
|
562
|
+
try { self._sessionChannel.unsubscribe('control', onSlotControl); } catch (_) {}
|
|
563
|
+
slotReject(new Error('Slot claim timed out waiting for approval'));
|
|
564
|
+
}, 60000); // 60s timeout
|
|
565
|
+
if (slotTimeout.unref) slotTimeout.unref();
|
|
566
|
+
|
|
567
|
+
function onSlotControl(msg) {
|
|
568
|
+
var data = msg.data;
|
|
569
|
+
if (!data) return;
|
|
570
|
+
if (data.type === 'slot-approved') {
|
|
571
|
+
if (slotResolved) return;
|
|
572
|
+
slotResolved = true;
|
|
573
|
+
clearTimeout(slotTimeout);
|
|
574
|
+
try { self._sessionChannel.unsubscribe('control', onSlotControl); } catch (_) {}
|
|
575
|
+
slotResolve({ approved: true, data: data });
|
|
576
|
+
} else if (data.type === 'slot-denied') {
|
|
577
|
+
if (slotResolved) return;
|
|
578
|
+
slotResolved = true;
|
|
579
|
+
clearTimeout(slotTimeout);
|
|
580
|
+
try { self._sessionChannel.unsubscribe('control', onSlotControl); } catch (_) {}
|
|
581
|
+
slotResolve({ approved: false, data: data });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Subscribe FIRST, then check history to close the race window
|
|
586
|
+
// between presence enter (done in _initAbly) and this subscription.
|
|
587
|
+
// The claim-slot task fires in response to presence enter, so the
|
|
588
|
+
// decision may already be published by the time we get here.
|
|
589
|
+
var slotControlSub = await self._sessionChannel.subscribe('control', onSlotControl);
|
|
590
|
+
|
|
591
|
+
// Check for decisions published before this subscription was active
|
|
592
|
+
if (!slotResolved && slotControlSub) {
|
|
593
|
+
try {
|
|
594
|
+
var histPage = await slotControlSub.historyBeforeSubscribe({ limit: 10 });
|
|
595
|
+
while (histPage && !slotResolved) {
|
|
596
|
+
for (var hi = 0; hi < histPage.items.length; hi++) {
|
|
597
|
+
onSlotControl(histPage.items[hi]);
|
|
598
|
+
if (slotResolved) break;
|
|
599
|
+
}
|
|
600
|
+
histPage = (!slotResolved && histPage.hasNext()) ? await histPage.next() : null;
|
|
601
|
+
}
|
|
602
|
+
} catch (histErr) {
|
|
603
|
+
logger.warn('[slots] Failed to check history for slot decision: ' + (histErr.message || histErr));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
var slotDecision = await slotDecisionPromise;
|
|
608
|
+
|
|
609
|
+
if (!slotDecision.approved) {
|
|
610
|
+
// Slot denied — disconnect Ably and re-try the full authenticate
|
|
611
|
+
// flow after a delay, polling forever until a slot opens.
|
|
612
|
+
var elapsed = Date.now() - slotPollStart;
|
|
613
|
+
logger.log(
|
|
614
|
+
'Slot denied: ' + (slotDecision.data.message || 'concurrency limit reached') +
|
|
615
|
+
' — waiting ' + (concurrencyRetryInterval / 1000) + 's before retrying' +
|
|
616
|
+
' (' + Math.round(elapsed / 1000) + 's elapsed)...'
|
|
617
|
+
);
|
|
618
|
+
logger.log('Upgrade for more slots → https://console.testdriver.ai/checkout/team');
|
|
619
|
+
try {
|
|
620
|
+
if (this._ably) this._ably.close();
|
|
621
|
+
this._ably = null;
|
|
622
|
+
this._sessionChannel = null;
|
|
623
|
+
} catch (_) {}
|
|
624
|
+
|
|
625
|
+
await new Promise(function (resolve) {
|
|
626
|
+
var t = setTimeout(resolve, concurrencyRetryInterval);
|
|
627
|
+
if (t.unref) t.unref();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// Re-call authenticate — this goes through _httpPostWithConcurrencyRetry
|
|
631
|
+
// so transient HTTP errors are also handled. The new reply will either
|
|
632
|
+
// be 'pending' again (loop continues) or succeed directly.
|
|
633
|
+
reply = await this._httpPostWithConcurrencyRetry(
|
|
634
|
+
"/api/v7/sandbox/authenticate",
|
|
635
|
+
body,
|
|
636
|
+
timeout,
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
if (!reply.success && reply.status !== 'pending') {
|
|
640
|
+
var retryErr = new Error(
|
|
641
|
+
reply.errorMessage || "Failed to allocate sandbox",
|
|
642
|
+
);
|
|
643
|
+
retryErr.responseData = reply;
|
|
644
|
+
throw retryErr;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Re-init Ably if we got a new pending reply with fresh credentials
|
|
648
|
+
if (reply.status === 'pending' && reply.ably && reply.ably.token) {
|
|
649
|
+
this._sandboxId = reply.sandboxId;
|
|
650
|
+
this._teamId = reply.teamId;
|
|
651
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
652
|
+
this.instanceSocketConnected = true;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
continue; // loop back to wait for the next slot decision
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
logger.log('Slot approved — provisioning sandbox...');
|
|
659
|
+
|
|
660
|
+
// Re-call authenticate with slotApproved flag to trigger provisioning
|
|
661
|
+
// Keep the same sandboxId so the Ably channel stays valid
|
|
662
|
+
var provisionBody = {
|
|
663
|
+
apiKey: this.apiKey,
|
|
664
|
+
version: version,
|
|
665
|
+
os: message.os || this.os || 'linux',
|
|
666
|
+
session: sessionId,
|
|
667
|
+
apiRoot: this.apiRoot,
|
|
668
|
+
sandboxId: this._sandboxId,
|
|
669
|
+
slotApproved: true,
|
|
670
|
+
};
|
|
671
|
+
if (message.resolution) provisionBody.resolution = message.resolution;
|
|
672
|
+
if (message.ci) provisionBody.ci = message.ci;
|
|
673
|
+
if (message.ami) provisionBody.ami = message.ami;
|
|
674
|
+
if (message.instanceType) provisionBody.instanceType = message.instanceType;
|
|
675
|
+
if (message.e2bTemplateId) provisionBody.e2bTemplateId = message.e2bTemplateId;
|
|
676
|
+
if (message.keepAlive !== undefined) provisionBody.keepAlive = message.keepAlive;
|
|
677
|
+
|
|
678
|
+
reply = await this._httpPostWithConcurrencyRetry(
|
|
679
|
+
"/api/v7/sandbox/authenticate",
|
|
680
|
+
provisionBody,
|
|
681
|
+
timeout,
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
if (!reply.success) {
|
|
685
|
+
var provisionErr = new Error(
|
|
686
|
+
reply.errorMessage || "Failed to provision sandbox after approval",
|
|
687
|
+
);
|
|
688
|
+
provisionErr.responseData = reply;
|
|
689
|
+
throw provisionErr;
|
|
392
690
|
}
|
|
393
691
|
|
|
692
|
+
break; // slot approved and provisioned — exit the while loop
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (message.type === "create") {
|
|
696
|
+
// E2B (Linux) sandboxes return a url directly.
|
|
697
|
+
// We still need to wait for runner.ready since sandbox-agent.js runs inside E2B.
|
|
698
|
+
const isE2B = !!reply.url;
|
|
699
|
+
|
|
394
700
|
const runnerIp = reply.runner && reply.runner.ip;
|
|
395
701
|
const noVncPort = reply.runner && reply.runner.noVncPort;
|
|
396
702
|
const runnerVncUrl = reply.runner && reply.runner.vncUrl;
|
|
397
703
|
|
|
398
|
-
|
|
704
|
+
// Log image version info (AMI for Windows, E2B template for Linux)
|
|
705
|
+
if (reply.imageVersion) {
|
|
706
|
+
if (isE2B) {
|
|
707
|
+
logger.log('E2B image version: v' + reply.imageVersion + (reply.e2bTemplateId ? ' (template: ' + reply.e2bTemplateId + ')' : ''));
|
|
708
|
+
} else {
|
|
709
|
+
logger.log('AMI image version: v' + reply.imageVersion + (reply.amiId ? ' (ami: ' + reply.amiId + ')' : ''));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (!isE2B) {
|
|
714
|
+
logger.log(`Runner claimed — ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
|
|
715
|
+
}
|
|
399
716
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
717
|
+
// Wait for the runner agent to signal readiness before sending commands.
|
|
718
|
+
// Without this gate, commands published before the agent subscribes are lost.
|
|
719
|
+
// This applies to:
|
|
720
|
+
// - E2B Linux sandboxes (native runner agent via sandbox-agent.js)
|
|
721
|
+
// - Windows EC2 sandboxes without presence runners
|
|
722
|
+
// For presence-based Windows runners (reply.runner already set), the runner
|
|
723
|
+
// is already listening so we can skip the wait.
|
|
403
724
|
var self = this;
|
|
404
|
-
|
|
725
|
+
const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
|
|
726
|
+
if (needsReadyWait) {
|
|
405
727
|
logger.log('Waiting for runner agent to signal readiness...');
|
|
406
|
-
|
|
728
|
+
// E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot
|
|
729
|
+
// EC2 (Windows) cold starts can be slow due to AV scanning and native module loading
|
|
730
|
+
var readyTimeout = isE2B ? 300000 : 180000; // 5 min for E2B (S3+npm), 3 min for EC2
|
|
407
731
|
await new Promise(function (resolve, reject) {
|
|
408
732
|
var resolved = false;
|
|
733
|
+
var waitStart = Date.now();
|
|
409
734
|
function finish(data) {
|
|
410
735
|
if (resolved) return;
|
|
411
736
|
resolved = true;
|
|
412
737
|
clearTimeout(timer);
|
|
413
|
-
|
|
738
|
+
clearInterval(progressTimer);
|
|
739
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
414
740
|
// Update runner info if provided
|
|
415
741
|
if (data && data.os) reply.runner = reply.runner || {};
|
|
416
742
|
if (data && data.os && reply.runner) reply.runner.os = data.os;
|
|
417
743
|
if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
|
|
418
744
|
if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
|
|
745
|
+
// Persist version metadata for test result reporting
|
|
746
|
+
self._runnerVersionBefore = reply.imageVersion || null;
|
|
747
|
+
self._runnerVersionAfter = (data && data.runnerVersion) || reply.imageVersion || null;
|
|
748
|
+
self._wasUpdated = !!(data && data.runnerVersion && reply.imageVersion && data.runnerVersion !== reply.imageVersion);
|
|
419
749
|
logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
750
|
+
// Show upgrade info: if the runner's npm version differs from the baked image version,
|
|
751
|
+
// the runner was upgraded during provisioning.
|
|
752
|
+
var runnerVer = data && data.runnerVersion;
|
|
753
|
+
var imageVer = reply.imageVersion;
|
|
754
|
+
if (runnerVer && imageVer && runnerVer !== imageVer) {
|
|
755
|
+
logger.log('Runner upgraded during provisioning: v' + imageVer + ' \u2192 v' + runnerVer);
|
|
756
|
+
}
|
|
420
757
|
if (data && data.update) {
|
|
421
758
|
var u = data.update;
|
|
422
759
|
if (u.status === 'up-to-date') {
|
|
@@ -435,12 +772,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
435
772
|
var timer = setTimeout(function () {
|
|
436
773
|
if (!resolved) {
|
|
437
774
|
resolved = true;
|
|
438
|
-
|
|
439
|
-
|
|
775
|
+
clearInterval(progressTimer);
|
|
776
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
777
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms');
|
|
778
|
+
sentry.captureException(err, {
|
|
779
|
+
tags: { phase: 'runner_ready', connection_type: 'create' },
|
|
780
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId },
|
|
781
|
+
});
|
|
782
|
+
reject(err);
|
|
440
783
|
}
|
|
441
784
|
}, readyTimeout);
|
|
442
785
|
if (timer.unref) timer.unref();
|
|
443
786
|
|
|
787
|
+
// Log progress every 15s so the user knows we're still waiting
|
|
788
|
+
var progressTimer = setInterval(function () {
|
|
789
|
+
if (resolved) return;
|
|
790
|
+
var elapsed = Math.round((Date.now() - waitStart) / 1000);
|
|
791
|
+
logger.log('Still waiting for runner agent... (' + elapsed + 's elapsed, timeout=' + Math.round(readyTimeout / 1000) + 's)');
|
|
792
|
+
}, 15000);
|
|
793
|
+
|
|
444
794
|
// Listen for live runner.ready messages
|
|
445
795
|
var onCtrl;
|
|
446
796
|
onCtrl = function (msg) {
|
|
@@ -449,12 +799,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
449
799
|
finish(data);
|
|
450
800
|
}
|
|
451
801
|
};
|
|
452
|
-
self.
|
|
802
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
453
803
|
|
|
454
804
|
// Also check channel history in case runner.ready was published
|
|
455
805
|
// before we subscribed (race condition on fast-booting agents).
|
|
456
806
|
try {
|
|
457
|
-
self.
|
|
807
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
458
808
|
if (err) {
|
|
459
809
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
460
810
|
return;
|
|
@@ -476,9 +826,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
476
826
|
});
|
|
477
827
|
}
|
|
478
828
|
// Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
|
|
829
|
+
// For E2B sandboxes, use the url from the API reply.
|
|
479
830
|
// Fall back to constructing from ip + noVncPort for older runners.
|
|
480
831
|
let url;
|
|
481
|
-
if (
|
|
832
|
+
if (isE2B && reply.url) {
|
|
833
|
+
url = reply.url;
|
|
834
|
+
logger.log(`E2B sandbox ready — url=${url}`);
|
|
835
|
+
} else if (runnerVncUrl) {
|
|
482
836
|
url = runnerVncUrl;
|
|
483
837
|
logger.log(`Using runner-provided vncUrl: ${url}`);
|
|
484
838
|
} else if (runnerIp && noVncPort) {
|
|
@@ -500,35 +854,57 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
500
854
|
url: url,
|
|
501
855
|
vncPort: noVncPort || undefined,
|
|
502
856
|
runner: reply.runner,
|
|
857
|
+
// Extra metadata for test result reporting
|
|
858
|
+
amiId: reply.amiId || null,
|
|
859
|
+
e2bTemplateId: reply.e2bTemplateId || null,
|
|
860
|
+
imageVersion: reply.imageVersion || null,
|
|
861
|
+
runnerVersionBefore: this._runnerVersionBefore || reply.imageVersion || null,
|
|
862
|
+
runnerVersionAfter: this._runnerVersionAfter || reply.runner?.version || null,
|
|
863
|
+
wasUpdated: this._wasUpdated || false,
|
|
864
|
+
vncUrl: url || null,
|
|
865
|
+
channelName: this._channelName || null,
|
|
503
866
|
},
|
|
504
867
|
};
|
|
505
868
|
}
|
|
506
869
|
|
|
507
870
|
if (message.type === "direct") {
|
|
508
|
-
// If the API returned
|
|
509
|
-
//
|
|
871
|
+
// If the API returned provisioning commands and we have an instanceId,
|
|
872
|
+
// send them to the instance via SSM (client-side).
|
|
510
873
|
// This runs from the user's infrastructure where AWS permissions exist,
|
|
511
874
|
// rather than from the API server.
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
875
|
+
// NOTE: For direct connections, the user MUST provide the AWS instanceId
|
|
876
|
+
// because the API only knows the sandboxId, not the actual EC2 instance ID.
|
|
877
|
+
var instanceId = message.instanceId;
|
|
878
|
+
if (instanceId && reply.provisionCommands) {
|
|
879
|
+
// New path: API generated full provisioning commands (runner install + config + start)
|
|
880
|
+
logger.log('Provisioning instance ' + instanceId + ' via SSM (API-generated commands)...');
|
|
881
|
+
await this._sendSSMCommands(instanceId, reply.provisionCommands);
|
|
882
|
+
logger.log('Instance provisioned successfully.');
|
|
883
|
+
} else if (reply.agentConfig && instanceId) {
|
|
884
|
+
// Fallback: older API that only returns agentConfig (config-only, no runner install)
|
|
885
|
+
logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM (legacy)...');
|
|
886
|
+
await this._provisionAgentConfig(instanceId, reply.agentConfig);
|
|
515
887
|
logger.log('Agent config provisioned successfully.');
|
|
888
|
+
} else if ((reply.agentConfig || reply.provisionCommands) && !instanceId) {
|
|
889
|
+
logger.log('Warning: agentConfig/provisionCommands returned but no instanceId provided - cannot provision via SSM');
|
|
516
890
|
}
|
|
517
891
|
|
|
518
892
|
// If the API returned agent credentials (reply.agent present),
|
|
519
893
|
// wait for the runner agent to signal readiness before sending commands.
|
|
520
894
|
// Without this gate, commands published before the agent subscribes are lost.
|
|
521
895
|
var self = this;
|
|
522
|
-
if (reply.agent && this.
|
|
896
|
+
if (reply.agent && this._sessionChannel) {
|
|
523
897
|
logger.log('Waiting for runner agent to signal readiness (direct connection)...');
|
|
524
|
-
var readyTimeout =
|
|
898
|
+
var readyTimeout = 60000 * 5;
|
|
525
899
|
await new Promise(function (resolve, reject) {
|
|
526
900
|
var resolved = false;
|
|
901
|
+
var waitStart = Date.now();
|
|
527
902
|
function finish(data) {
|
|
528
903
|
if (resolved) return;
|
|
529
904
|
resolved = true;
|
|
530
905
|
clearTimeout(timer);
|
|
531
|
-
|
|
906
|
+
clearInterval(progressTimer);
|
|
907
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
532
908
|
logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
533
909
|
if (data && data.update) {
|
|
534
910
|
var u = data.update;
|
|
@@ -548,12 +924,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
548
924
|
var timer = setTimeout(function () {
|
|
549
925
|
if (!resolved) {
|
|
550
926
|
resolved = true;
|
|
551
|
-
|
|
552
|
-
|
|
927
|
+
clearInterval(progressTimer);
|
|
928
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
929
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)');
|
|
930
|
+
sentry.captureException(err, {
|
|
931
|
+
tags: { phase: 'runner_ready', connection_type: 'direct' },
|
|
932
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId, instanceId: message.instanceId },
|
|
933
|
+
});
|
|
934
|
+
reject(err);
|
|
553
935
|
}
|
|
554
936
|
}, readyTimeout);
|
|
555
937
|
if (timer.unref) timer.unref();
|
|
556
938
|
|
|
939
|
+
// Log progress every 15s so the user knows we're still waiting
|
|
940
|
+
var progressTimer = setInterval(function () {
|
|
941
|
+
if (resolved) return;
|
|
942
|
+
var elapsed = Math.round((Date.now() - waitStart) / 1000);
|
|
943
|
+
logger.log('Still waiting for runner agent... (' + elapsed + 's elapsed, timeout=' + Math.round(readyTimeout / 1000) + 's)');
|
|
944
|
+
}, 15000);
|
|
945
|
+
|
|
557
946
|
// Listen for live runner.ready messages
|
|
558
947
|
var onCtrl;
|
|
559
948
|
onCtrl = function (msg) {
|
|
@@ -562,12 +951,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
562
951
|
finish(data);
|
|
563
952
|
}
|
|
564
953
|
};
|
|
565
|
-
self.
|
|
954
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
566
955
|
|
|
567
956
|
// Also check channel history in case runner.ready was published
|
|
568
957
|
// before we subscribed (race condition on fast-booting agents).
|
|
569
958
|
try {
|
|
570
|
-
self.
|
|
959
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
571
960
|
if (err) {
|
|
572
961
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
573
962
|
return;
|
|
@@ -609,7 +998,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
609
998
|
_sendAbly(message, timeout) {
|
|
610
999
|
if (timeout === undefined) timeout = 300000;
|
|
611
1000
|
|
|
612
|
-
if (!this.
|
|
1001
|
+
if (!this._sessionChannel || !this._ably) {
|
|
613
1002
|
return Promise.reject(
|
|
614
1003
|
new Error("Sandbox not connected (no Ably client)"),
|
|
615
1004
|
);
|
|
@@ -637,7 +1026,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
637
1026
|
function onFailed() {
|
|
638
1027
|
clearTimeout(timer);
|
|
639
1028
|
self._ably.connection.off("connected", onConnected);
|
|
640
|
-
reject(new Error("
|
|
1029
|
+
reject(new Error("Realtime connection failed while waiting to send"));
|
|
641
1030
|
}
|
|
642
1031
|
self._ably.connection.once("connected", onConnected);
|
|
643
1032
|
self._ably.connection.once("failed", onFailed);
|
|
@@ -695,21 +1084,41 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
695
1084
|
|
|
696
1085
|
var requestId = message.requestId;
|
|
697
1086
|
|
|
698
|
-
|
|
1087
|
+
// timeoutId and timeoutExpiresAt are declared as vars so they can be
|
|
1088
|
+
// updated by extendTimeout() (closure mutation).
|
|
1089
|
+
var timeoutId;
|
|
1090
|
+
var timeoutExpiresAt;
|
|
1091
|
+
|
|
1092
|
+
var timeoutFn = function () {
|
|
699
1093
|
if (self.ps[requestId]) {
|
|
1094
|
+
var pendingIds = Object.keys(self.ps);
|
|
1095
|
+
var pendingSummary = pendingIds.map(function (rid) {
|
|
1096
|
+
var e = self.ps[rid];
|
|
1097
|
+
var age = e && e.startTime ? ((Date.now() - e.startTime) / 1000).toFixed(1) + 's' : '?';
|
|
1098
|
+
return rid + '(' + (e && e.message ? e.message.type : '?') + ', ' + age + ')';
|
|
1099
|
+
}).join(', ');
|
|
1100
|
+
logger.error(
|
|
1101
|
+
'[realtime] Promise TIMEOUT: requestId=' + requestId +
|
|
1102
|
+
' | type=' + message.type +
|
|
1103
|
+
' | timeout=' + timeout + 'ms' +
|
|
1104
|
+
' | all pending: [' + pendingSummary + ']'
|
|
1105
|
+
);
|
|
700
1106
|
delete self.ps[requestId];
|
|
701
1107
|
delete self._execBuffers[requestId];
|
|
702
1108
|
rejectPromise(
|
|
703
1109
|
new Error(
|
|
704
1110
|
"Sandbox message '" +
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1111
|
+
message.type +
|
|
1112
|
+
"' timed out after " +
|
|
1113
|
+
timeout +
|
|
1114
|
+
"ms",
|
|
709
1115
|
),
|
|
710
1116
|
);
|
|
711
1117
|
}
|
|
712
|
-
}
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
timeoutId = setTimeout(timeoutFn, timeout);
|
|
1121
|
+
timeoutExpiresAt = Date.now() + timeout;
|
|
713
1122
|
if (timeoutId.unref) timeoutId.unref();
|
|
714
1123
|
|
|
715
1124
|
this.ps[requestId] = {
|
|
@@ -722,15 +1131,35 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
722
1131
|
clearTimeout(timeoutId);
|
|
723
1132
|
rejectPromise(error);
|
|
724
1133
|
},
|
|
1134
|
+
/**
|
|
1135
|
+
* Extend the pending timeout by disconnectionDurationMs — called on Ably reconnect
|
|
1136
|
+
* to compensate for time spent disconnected.
|
|
1137
|
+
*/
|
|
1138
|
+
extendTimeout: function (disconnectionDurationMs) {
|
|
1139
|
+
clearTimeout(timeoutId);
|
|
1140
|
+
// Clamp remaining to 0 so a command whose timer expired during the
|
|
1141
|
+
// outage still gets the full disconnection duration as its new budget.
|
|
1142
|
+
var remaining = Math.max(0, timeoutExpiresAt - Date.now());
|
|
1143
|
+
// Minimum 5s remaining after extension to allow the response to arrive.
|
|
1144
|
+
var MIN_REMAINING_MS = 5000;
|
|
1145
|
+
var newRemaining = Math.max(remaining + disconnectionDurationMs, MIN_REMAINING_MS);
|
|
1146
|
+
timeoutExpiresAt = Date.now() + newRemaining;
|
|
1147
|
+
timeoutId = setTimeout(timeoutFn, newRemaining);
|
|
1148
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
1149
|
+
logger.log(
|
|
1150
|
+
'[realtime] Extended timeout for requestId=' + requestId +
|
|
1151
|
+
' by ' + disconnectionDurationMs + 'ms (new remaining: ' + Math.round(newRemaining / 1000) + 's)'
|
|
1152
|
+
);
|
|
1153
|
+
},
|
|
725
1154
|
message: message,
|
|
726
1155
|
startTime: Date.now(),
|
|
727
1156
|
};
|
|
728
1157
|
|
|
729
1158
|
if (message.type === "output") {
|
|
730
|
-
p.catch(function () {});
|
|
1159
|
+
p.catch(function () { });
|
|
731
1160
|
}
|
|
732
1161
|
|
|
733
|
-
this._throttledPublish(this.
|
|
1162
|
+
this._throttledPublish(this._sessionChannel, "command", message)
|
|
734
1163
|
.then(function () {
|
|
735
1164
|
emitter.emit(events.sandbox.sent, message);
|
|
736
1165
|
})
|
|
@@ -789,7 +1218,9 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
789
1218
|
this._publishWindowStart = Date.now();
|
|
790
1219
|
}
|
|
791
1220
|
|
|
792
|
-
return channel.publish(eventName, message)
|
|
1221
|
+
return channel.publish(eventName, message).then(function () {
|
|
1222
|
+
logger.debug(`[realtime] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
1223
|
+
});
|
|
793
1224
|
}
|
|
794
1225
|
|
|
795
1226
|
async auth(apiKey) {
|
|
@@ -818,7 +1249,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
818
1249
|
logger.log("Trace Report (Share When Reporting Bugs):");
|
|
819
1250
|
logger.log(
|
|
820
1251
|
"https://testdriver.sentry.io/explore/traces/trace/" +
|
|
821
|
-
|
|
1252
|
+
reply.traceId,
|
|
822
1253
|
);
|
|
823
1254
|
}
|
|
824
1255
|
|
|
@@ -861,7 +1292,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
861
1292
|
this._sandboxId = reply.sandboxId;
|
|
862
1293
|
|
|
863
1294
|
if (reply.ably && reply.ably.token) {
|
|
864
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
1295
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
865
1296
|
}
|
|
866
1297
|
|
|
867
1298
|
this.setConnectionParams({
|
|
@@ -910,38 +1341,43 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
910
1341
|
clearInterval(this.heartbeat);
|
|
911
1342
|
this.heartbeat = null;
|
|
912
1343
|
}
|
|
1344
|
+
if (this._statsInterval) {
|
|
1345
|
+
clearInterval(this._statsInterval);
|
|
1346
|
+
this._statsInterval = null;
|
|
1347
|
+
}
|
|
913
1348
|
|
|
914
1349
|
// Send end-session control message to runner before disconnecting
|
|
915
|
-
if (this.
|
|
1350
|
+
if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
|
|
916
1351
|
try {
|
|
917
|
-
|
|
1352
|
+
logger.debug('[realtime] Publishing control: type=end-session');
|
|
1353
|
+
await this._sessionChannel.publish('control', { type: 'end-session' });
|
|
918
1354
|
} catch (e) {
|
|
919
1355
|
// Ignore - best effort
|
|
920
1356
|
}
|
|
921
1357
|
}
|
|
922
1358
|
|
|
923
|
-
// Leave presence on
|
|
924
|
-
if (this.
|
|
1359
|
+
// Leave presence on session channel
|
|
1360
|
+
if (this._sessionChannel) {
|
|
925
1361
|
try {
|
|
926
|
-
|
|
1362
|
+
logger.debug('[realtime] Leaving presence on session channel');
|
|
1363
|
+
await this._sessionChannel.presence.leave();
|
|
927
1364
|
} catch (e) {
|
|
928
1365
|
// ignore - best effort, Ably will auto-leave on disconnect
|
|
929
1366
|
}
|
|
930
1367
|
}
|
|
931
1368
|
|
|
932
1369
|
try {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
this.
|
|
936
|
-
|
|
937
|
-
this._filesChannel?.detach(),
|
|
938
|
-
].filter(Boolean));
|
|
1370
|
+
logger.debug('[realtime] Detaching session channel');
|
|
1371
|
+
if (this._sessionChannel) {
|
|
1372
|
+
await this._sessionChannel.detach();
|
|
1373
|
+
}
|
|
939
1374
|
} catch (e) {
|
|
940
1375
|
/* ignore */
|
|
941
1376
|
}
|
|
942
1377
|
|
|
943
1378
|
if (this._ably) {
|
|
944
1379
|
try {
|
|
1380
|
+
logger.debug('[realtime] Closing Realtime connection');
|
|
945
1381
|
this._ably.close();
|
|
946
1382
|
} catch (e) {
|
|
947
1383
|
/* ignore */
|
|
@@ -949,11 +1385,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
949
1385
|
this._ably = null;
|
|
950
1386
|
}
|
|
951
1387
|
|
|
952
|
-
this.
|
|
953
|
-
this.
|
|
954
|
-
this._ctrlChannel = null;
|
|
955
|
-
this._filesChannel = null;
|
|
956
|
-
this._channelNames = null;
|
|
1388
|
+
this._sessionChannel = null;
|
|
1389
|
+
this._channelName = null;
|
|
957
1390
|
this.apiSocketConnected = false;
|
|
958
1391
|
this.instanceSocketConnected = false;
|
|
959
1392
|
this.authenticated = false;
|
|
@@ -962,9 +1395,66 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
962
1395
|
this.ps = {};
|
|
963
1396
|
}
|
|
964
1397
|
|
|
1398
|
+
/**
|
|
1399
|
+
* Send pre-generated PowerShell commands to an EC2 instance via AWS SSM.
|
|
1400
|
+
* The commands are generated by the API (sdk/agent/lib/provision-commands.js)
|
|
1401
|
+
* so provisioning logic lives in one place.
|
|
1402
|
+
*/
|
|
1403
|
+
async _sendSSMCommands(instanceId, commands) {
|
|
1404
|
+
const { execSync } = require('child_process');
|
|
1405
|
+
const { writeFileSync, unlinkSync } = require('fs');
|
|
1406
|
+
const { join } = require('path');
|
|
1407
|
+
const { tmpdir } = require('os');
|
|
1408
|
+
const { randomUUID } = require('crypto');
|
|
1409
|
+
|
|
1410
|
+
const region = process.env.AWS_REGION || 'us-east-2';
|
|
1411
|
+
const paramsJson = JSON.stringify({ commands: commands });
|
|
1412
|
+
const tmpFile = join(tmpdir(), 'td-provision-' + randomUUID() + '.json');
|
|
1413
|
+
writeFileSync(tmpFile, paramsJson);
|
|
1414
|
+
|
|
1415
|
+
try {
|
|
1416
|
+
const output = execSync(
|
|
1417
|
+
'aws ssm send-command --region "' + region + '" --instance-ids "' + instanceId + '" ' +
|
|
1418
|
+
'--document-name "AWS-RunPowerShellScript" ' +
|
|
1419
|
+
'--parameters file://' + tmpFile + ' --output json',
|
|
1420
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1421
|
+
);
|
|
1422
|
+
var cmdId = JSON.parse(output).Command.CommandId;
|
|
1423
|
+
logger.log('SSM command sent: ' + cmdId);
|
|
1424
|
+
|
|
1425
|
+
// Wait for the command to complete
|
|
1426
|
+
execSync(
|
|
1427
|
+
'aws ssm wait command-executed --region "' + region + '" ' +
|
|
1428
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
|
|
1429
|
+
{ encoding: 'utf-8', timeout: 300000 } // 5 min — runner install can take a while
|
|
1430
|
+
);
|
|
1431
|
+
|
|
1432
|
+
// Get the command output for debugging
|
|
1433
|
+
try {
|
|
1434
|
+
var invocationOutput = execSync(
|
|
1435
|
+
'aws ssm get-command-invocation --region "' + region + '" ' +
|
|
1436
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
|
|
1437
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1438
|
+
);
|
|
1439
|
+
var invocation = JSON.parse(invocationOutput);
|
|
1440
|
+
if (invocation.StandardOutputContent) {
|
|
1441
|
+
logger.log('SSM output:\n' + invocation.StandardOutputContent);
|
|
1442
|
+
}
|
|
1443
|
+
if (invocation.StandardErrorContent) {
|
|
1444
|
+
logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
|
|
1445
|
+
}
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
logger.warn('Could not retrieve SSM command output: ' + e.message);
|
|
1448
|
+
}
|
|
1449
|
+
} finally {
|
|
1450
|
+
try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
965
1454
|
/**
|
|
966
1455
|
* Write the agent config JSON to an EC2 instance via AWS SSM.
|
|
967
1456
|
* Runs client-side so the API doesn't need AWS permissions on user infra.
|
|
1457
|
+
* LEGACY: Used when connecting to an API that doesn't return provisionCommands.
|
|
968
1458
|
*/
|
|
969
1459
|
async _provisionAgentConfig(instanceId, agentConfig) {
|
|
970
1460
|
const { execSync } = require('child_process');
|
|
@@ -976,14 +1466,55 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
976
1466
|
const region = process.env.AWS_REGION || 'us-east-2';
|
|
977
1467
|
|
|
978
1468
|
// Write SSM parameters to a temp file to avoid shell quoting issues
|
|
1469
|
+
// Log key config details for debugging
|
|
1470
|
+
logger.log('Agent config being provisioned:');
|
|
1471
|
+
logger.log(' sandboxId: ' + agentConfig.sandboxId);
|
|
1472
|
+
logger.log(' apiRoot: ' + agentConfig.apiRoot);
|
|
1473
|
+
logger.log(' channel: ' + (agentConfig.ably?.channel || 'N/A'));
|
|
1474
|
+
logger.log(' token length: ' + (agentConfig.ably?.token ? JSON.stringify(agentConfig.ably.token).length : 0));
|
|
1475
|
+
|
|
979
1476
|
const paramsJson = JSON.stringify({
|
|
980
1477
|
commands: [
|
|
1478
|
+
// Debug: show existing state
|
|
1479
|
+
"Write-Host '=== Checking existing state ==='",
|
|
1480
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1481
|
+
"if ($task) { Write-Host \"Task exists, state: $($task.State)\" } else { Write-Host 'Task does NOT exist!' }",
|
|
1482
|
+
"if (Test-Path 'C:\\Windows\\Temp\\testdriver-agent.json') { Write-Host 'Old config:'; Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | Write-Host } else { Write-Host 'Config file does NOT exist yet' }",
|
|
1483
|
+
// Stop any running runner
|
|
1484
|
+
"Write-Host '=== Stopping runner ==='",
|
|
1485
|
+
"Stop-Process -Name node -Force -ErrorAction SilentlyContinue",
|
|
1486
|
+
"Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1487
|
+
// Write config
|
|
1488
|
+
"Write-Host '=== Writing config ==='",
|
|
981
1489
|
"$config = '" + configJson.replace(/'/g, "''") + "'",
|
|
982
1490
|
"[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
|
|
983
1491
|
"Write-Host 'Config written for sandbox " + agentConfig.sandboxId + "'",
|
|
1492
|
+
// Show what was written (redact token)
|
|
1493
|
+
"Write-Host '=== New config (token redacted) ==='",
|
|
1494
|
+
"$cfg = Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | ConvertFrom-Json",
|
|
1495
|
+
"Write-Host \"sandboxId: $($cfg.sandboxId)\"",
|
|
1496
|
+
"Write-Host \"apiRoot: $($cfg.apiRoot)\"",
|
|
1497
|
+
"Write-Host \"channel: $($cfg.ably.channel)\"",
|
|
1498
|
+
"Write-Host \"token type: $($cfg.ably.token.GetType().Name)\"",
|
|
1499
|
+
// Start the runner
|
|
1500
|
+
"Write-Host '=== Starting runner ==='",
|
|
1501
|
+
"Start-Sleep -Seconds 1",
|
|
1502
|
+
"Start-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction Stop",
|
|
1503
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent",
|
|
1504
|
+
"Write-Host \"Task state after start: $($task.State)\"",
|
|
1505
|
+
// Check if node process started
|
|
1506
|
+
"Start-Sleep -Seconds 3",
|
|
1507
|
+
"Write-Host '=== Checking runner process ==='",
|
|
1508
|
+
"$procs = Get-Process -Name node -ErrorAction SilentlyContinue",
|
|
1509
|
+
"if ($procs) { Write-Host \"Node processes: $($procs.Count)\"; $procs | ForEach-Object { Write-Host \" PID: $($_.Id), StartTime: $($_.StartTime)\" } } else { Write-Host 'No node process found!' }",
|
|
1510
|
+
// Check runner logs
|
|
1511
|
+
"Write-Host '=== Runner log (last 30 lines) ==='",
|
|
1512
|
+
"if (Test-Path 'C:\\testdriver\\logs\\sandbox-agent.log') { Get-Content 'C:\\testdriver\\logs\\sandbox-agent.log' -Tail 30 | Write-Host } else { Write-Host 'No log file found' }",
|
|
1513
|
+
"Write-Host '=== Done ==='",
|
|
984
1514
|
],
|
|
985
1515
|
});
|
|
986
|
-
const
|
|
1516
|
+
const { randomUUID } = require('crypto');
|
|
1517
|
+
const tmpFile = join(tmpdir(), 'td-provision-' + randomUUID() + '.json');
|
|
987
1518
|
writeFileSync(tmpFile, paramsJson);
|
|
988
1519
|
|
|
989
1520
|
try {
|
|
@@ -1002,6 +1533,24 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
1002
1533
|
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
|
|
1003
1534
|
{ encoding: 'utf-8', timeout: 60000 }
|
|
1004
1535
|
);
|
|
1536
|
+
|
|
1537
|
+
// Get the command output for debugging
|
|
1538
|
+
try {
|
|
1539
|
+
const invocationOutput = execSync(
|
|
1540
|
+
'aws ssm get-command-invocation --region "' + region + '" ' +
|
|
1541
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
|
|
1542
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1543
|
+
);
|
|
1544
|
+
const invocation = JSON.parse(invocationOutput);
|
|
1545
|
+
if (invocation.StandardOutputContent) {
|
|
1546
|
+
logger.log('SSM output:\n' + invocation.StandardOutputContent);
|
|
1547
|
+
}
|
|
1548
|
+
if (invocation.StandardErrorContent) {
|
|
1549
|
+
logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
|
|
1550
|
+
}
|
|
1551
|
+
} catch (e) {
|
|
1552
|
+
logger.warn('Could not retrieve SSM command output: ' + e.message);
|
|
1553
|
+
}
|
|
1005
1554
|
} finally {
|
|
1006
1555
|
try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
|
|
1007
1556
|
}
|