testdriverai 7.8.0-test.4 → 7.8.0-test.40
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/CHANGELOG.md +18 -0
- package/agent/index.js +6 -5
- package/agent/lib/commands.js +3 -2
- package/agent/lib/http.js +144 -0
- package/agent/lib/sandbox.js +326 -164
- package/agent/lib/sdk.js +4 -2
- package/agent/lib/system.js +25 -65
- package/ai/skills/testdriver-cache/SKILL.md +221 -0
- package/ai/skills/testdriver-errors/SKILL.md +246 -0
- package/ai/skills/testdriver-events/SKILL.md +356 -0
- package/ai/skills/testdriver-mcp/SKILL.md +7 -0
- package/ai/skills/testdriver-provision/SKILL.md +331 -0
- package/ai/skills/testdriver-redraw/SKILL.md +214 -0
- package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
- package/ai/skills/testdriver-screenshots/SKILL.md +184 -0
- package/docs/_data/examples-manifest.json +46 -46
- package/docs/changelog.mdx +155 -3
- package/docs/docs.json +44 -37
- package/docs/images/content/vscode/v7-chat.png +0 -0
- package/docs/images/content/vscode/v7-choose-agent.png +0 -0
- package/docs/images/content/vscode/v7-full.png +0 -0
- package/docs/images/content/vscode/v7-onboarding.png +0 -0
- package/docs/v7/cache.mdx +223 -0
- package/docs/v7/copilot/auto-healing.mdx +265 -0
- package/docs/v7/copilot/creating-tests.mdx +156 -0
- package/docs/v7/copilot/github.mdx +143 -0
- package/docs/v7/copilot/running-tests.mdx +149 -0
- package/docs/v7/copilot/setup.mdx +143 -0
- package/docs/v7/enterprise.mdx +3 -110
- package/docs/v7/errors.mdx +248 -0
- package/docs/v7/events.mdx +358 -0
- 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 +85 -0
- package/docs/v7/examples/exec-pwsh.mdx +83 -0
- package/docs/v7/examples/focus-window.mdx +62 -0
- 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/{cloud.mdx → hosted.mdx} +43 -5
- package/docs/v7/mcp.mdx +9 -0
- package/docs/v7/provision.mdx +333 -0
- package/docs/v7/quickstart.mdx +30 -2
- package/docs/v7/redraw.mdx +216 -0
- package/docs/v7/running-tests.mdx +1 -1
- package/docs/v7/screenshots.mdx +186 -0
- package/docs/v7/self-hosted.mdx +127 -44
- package/interfaces/logger.js +0 -12
- package/interfaces/vitest-plugin.mjs +53 -43
- package/lib/core/Dashcam.js +30 -23
- package/lib/environments.json +18 -0
- package/lib/github-comment.mjs +58 -40
- package/lib/resolve-channel.js +4 -3
- package/lib/sentry.js +5 -0
- package/{examples → manual}/drag-and-drop.test.mjs +1 -1
- package/mcp-server/dist/server.mjs +4 -0
- package/mcp-server/src/server.ts +5 -0
- package/package.json +3 -3
- package/sdk.js +3 -3
- package/setup/aws/install-dev-runner.sh +79 -0
- package/setup/aws/spawn-runner.sh +134 -0
- package/vitest.config.mjs +20 -32
- package/vitest.runner.config.mjs +33 -0
- /package/{examples → manual}/flake-diffthreshold-001.test.mjs +0 -0
- /package/{examples → manual}/flake-diffthreshold-01.test.mjs +0 -0
- /package/{examples → manual}/flake-diffthreshold-05.test.mjs +0 -0
- /package/{examples → manual}/flake-noredraw-cache.test.mjs +0 -0
- /package/{examples → manual}/flake-noredraw-nocache.test.mjs +0 -0
- /package/{examples → manual}/flake-redraw-cache.test.mjs +0 -0
- /package/{examples → manual}/flake-redraw-nocache.test.mjs +0 -0
- /package/{examples → manual}/flake-rocket-match.test.mjs +0 -0
- /package/{examples → manual}/flake-shared.mjs +0 -0
- /package/{examples → manual}/no-provision.test.mjs +0 -0
- /package/{examples → manual}/scroll-until-text.test.mjs +0 -0
package/agent/lib/sandbox.js
CHANGED
|
@@ -1,93 +1,17 @@
|
|
|
1
|
-
const crypto = require("crypto");
|
|
2
1
|
const Ably = require("ably");
|
|
2
|
+
const axios = require("axios");
|
|
3
3
|
const { events } = require("../events");
|
|
4
4
|
const logger = require("./logger");
|
|
5
5
|
const { version } = require("../../package.json");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if (!sessionId) return {};
|
|
9
|
-
const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
|
|
10
|
-
const spanId = crypto.randomBytes(8).toString("hex");
|
|
11
|
-
return {
|
|
12
|
-
"sentry-trace": traceId + "-" + spanId + "-1",
|
|
13
|
-
baggage:
|
|
14
|
-
"sentry-trace_id=" +
|
|
15
|
-
traceId +
|
|
16
|
-
",sentry-sample_rate=1.0,sentry-sampled=true",
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function httpPost(apiRoot, path, body, timeout) {
|
|
21
|
-
const http = require("http");
|
|
22
|
-
const https = require("https");
|
|
23
|
-
const url = new URL(apiRoot + path);
|
|
24
|
-
const transport = url.protocol === "https:" ? https : http;
|
|
25
|
-
const bodyStr = JSON.stringify(body);
|
|
26
|
-
|
|
27
|
-
return new Promise(function (resolve, reject) {
|
|
28
|
-
var timeoutId = timeout
|
|
29
|
-
? setTimeout(function () {
|
|
30
|
-
req.destroy();
|
|
31
|
-
reject(
|
|
32
|
-
new Error("HTTP request timed out after " + timeout + "ms"),
|
|
33
|
-
);
|
|
34
|
-
}, timeout)
|
|
35
|
-
: null;
|
|
36
|
-
|
|
37
|
-
var req = transport.request(
|
|
38
|
-
url,
|
|
39
|
-
{
|
|
40
|
-
method: "POST",
|
|
41
|
-
headers: {
|
|
42
|
-
"Content-Type": "application/json",
|
|
43
|
-
"Content-Length": Buffer.byteLength(bodyStr),
|
|
44
|
-
"Connection": "close",
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
function (res) {
|
|
48
|
-
var data = "";
|
|
49
|
-
res.on("data", function (chunk) {
|
|
50
|
-
data += chunk;
|
|
51
|
-
});
|
|
52
|
-
res.on("end", function () {
|
|
53
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
54
|
-
try {
|
|
55
|
-
var parsed = JSON.parse(data);
|
|
56
|
-
if (res.statusCode >= 400) {
|
|
57
|
-
var err = new Error(
|
|
58
|
-
parsed.errorMessage ||
|
|
59
|
-
parsed.message ||
|
|
60
|
-
"HTTP " + res.statusCode,
|
|
61
|
-
);
|
|
62
|
-
err.responseData = parsed;
|
|
63
|
-
reject(err);
|
|
64
|
-
} else {
|
|
65
|
-
resolve(parsed);
|
|
66
|
-
}
|
|
67
|
-
} catch (e) {
|
|
68
|
-
reject(new Error("Failed to parse API response: " + data));
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
req.on("error", function (err) {
|
|
74
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
75
|
-
reject(err);
|
|
76
|
-
});
|
|
77
|
-
req.write(bodyStr);
|
|
78
|
-
req.end();
|
|
79
|
-
});
|
|
80
|
-
}
|
|
6
|
+
const { withRetry, getSentryTraceHeaders } = require("./sdk");
|
|
7
|
+
const sentry = require("../../lib/sentry");
|
|
81
8
|
|
|
82
9
|
const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
83
10
|
class Sandbox {
|
|
84
11
|
constructor() {
|
|
85
12
|
this._ably = null;
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
88
|
-
this._ctrlChannel = null;
|
|
89
|
-
this._filesChannel = null;
|
|
90
|
-
this._channelNames = null;
|
|
13
|
+
this._sessionChannel = null;
|
|
14
|
+
this._channelName = null;
|
|
91
15
|
this.ps = {};
|
|
92
16
|
this._execBuffers = {}; // accumulate streamed exec.output chunks per requestId
|
|
93
17
|
this.heartbeat = null;
|
|
@@ -105,6 +29,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
105
29
|
this._lastConnectParams = null;
|
|
106
30
|
this._teamId = null;
|
|
107
31
|
this._sandboxId = null;
|
|
32
|
+
|
|
33
|
+
// Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
|
|
34
|
+
this._publishLastTime = 0;
|
|
35
|
+
this._publishMinIntervalMs = 25; // 40 msg/sec max, safely under Ably's 50 limit
|
|
36
|
+
this._publishCount = 0;
|
|
37
|
+
this._publishWindowStart = Date.now();
|
|
108
38
|
}
|
|
109
39
|
|
|
110
40
|
getTraceId() {
|
|
@@ -118,7 +48,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
118
48
|
);
|
|
119
49
|
}
|
|
120
50
|
|
|
121
|
-
async _initAbly(ablyToken,
|
|
51
|
+
async _initAbly(ablyToken, channelName) {
|
|
122
52
|
if (this._ably) {
|
|
123
53
|
try {
|
|
124
54
|
this._ably.close();
|
|
@@ -126,7 +56,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
126
56
|
/* ignore */
|
|
127
57
|
}
|
|
128
58
|
}
|
|
129
|
-
this.
|
|
59
|
+
this._channelName = channelName;
|
|
130
60
|
var self = this;
|
|
131
61
|
|
|
132
62
|
this._ably = new Ably.Realtime({
|
|
@@ -134,10 +64,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
134
64
|
callback(null, ablyToken);
|
|
135
65
|
},
|
|
136
66
|
clientId: "sdk-" + this._sandboxId,
|
|
67
|
+
echoMessages: false, // don't receive our own published messages
|
|
137
68
|
disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
|
|
138
69
|
suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
|
|
139
70
|
});
|
|
140
71
|
|
|
72
|
+
logger.log(`[ably] Connecting as sdk-${this._sandboxId}...`);
|
|
73
|
+
|
|
141
74
|
await new Promise(function (resolve, reject) {
|
|
142
75
|
self._ably.connection.on("connected", resolve);
|
|
143
76
|
self._ably.connection.on("failed", function () {
|
|
@@ -148,26 +81,29 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
148
81
|
}, 30000);
|
|
149
82
|
});
|
|
150
83
|
|
|
151
|
-
this.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this._filesChannel = this._ably.channels.get(channelNames.files);
|
|
84
|
+
this._sessionChannel = this._ably.channels.get(channelName);
|
|
85
|
+
|
|
86
|
+
logger.log(`[ably] Channel initialized: ${channelName}`);
|
|
155
87
|
|
|
156
|
-
// Enter presence on
|
|
88
|
+
// Enter presence on the session channel so the API can count connected SDK clients
|
|
157
89
|
try {
|
|
158
|
-
await this.
|
|
90
|
+
await this._sessionChannel.presence.enter({
|
|
159
91
|
sandboxId: this._sandboxId,
|
|
160
92
|
connectedAt: Date.now(),
|
|
161
93
|
});
|
|
94
|
+
logger.log(`[ably] Entered presence on session channel (sandbox=${this._sandboxId})`);
|
|
162
95
|
} catch (e) {
|
|
163
96
|
// Non-fatal — presence is used for concurrency counting, not critical path
|
|
164
|
-
logger.warn("Failed to enter presence on
|
|
97
|
+
logger.warn("Failed to enter presence on session channel: " + (e.message || e));
|
|
165
98
|
}
|
|
166
99
|
|
|
167
|
-
|
|
100
|
+
// Save subscription references for historyBeforeSubscribe() during discontinuity recovery
|
|
101
|
+
this._responseSubscription = await this._sessionChannel.subscribe("response", function (msg) {
|
|
168
102
|
var message = msg.data;
|
|
169
103
|
if (!message) return;
|
|
170
104
|
|
|
105
|
+
logger.log(`[ably] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
106
|
+
|
|
171
107
|
if (message.type === "sandbox.progress") {
|
|
172
108
|
emitter.emit(events.sandbox.progress, {
|
|
173
109
|
step: message.step,
|
|
@@ -263,9 +199,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
263
199
|
delete self.ps[message.requestId];
|
|
264
200
|
});
|
|
265
201
|
|
|
266
|
-
this.
|
|
202
|
+
this._fileSubscription = await this._sessionChannel.subscribe("file", function (msg) {
|
|
267
203
|
var message = msg.data;
|
|
268
204
|
if (!message) return;
|
|
205
|
+
logger.log(`[ably] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
269
206
|
if (message.requestId && self.ps[message.requestId]) {
|
|
270
207
|
emitter.emit(events.sandbox.received);
|
|
271
208
|
self.ps[message.requestId].resolve(message);
|
|
@@ -274,64 +211,168 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
274
211
|
emitter.emit(events.sandbox.file, message);
|
|
275
212
|
});
|
|
276
213
|
|
|
277
|
-
this.heartbeat = setInterval(function () {}, 5000);
|
|
214
|
+
this.heartbeat = setInterval(function () { }, 5000);
|
|
278
215
|
if (this.heartbeat.unref) this.heartbeat.unref();
|
|
279
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
|
+
const pending = Object.keys(this.ps).length;
|
|
222
|
+
logger.log(`[ably][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
|
|
223
|
+
}, 10000);
|
|
224
|
+
if (this._statsInterval.unref) this._statsInterval.unref();
|
|
225
|
+
|
|
280
226
|
this._ably.connection.on("disconnected", function () {
|
|
281
|
-
logger.log("
|
|
227
|
+
logger.log("[ably] Connection: disconnected - will auto-reconnect");
|
|
282
228
|
});
|
|
283
229
|
|
|
284
230
|
this._ably.connection.on("connected", function () {
|
|
285
231
|
// Log reconnection so the user knows the blip was recovered
|
|
286
|
-
logger.log("
|
|
232
|
+
logger.log("[ably] Connection: reconnected");
|
|
287
233
|
});
|
|
288
234
|
|
|
289
235
|
this._ably.connection.on("suspended", function () {
|
|
290
|
-
logger.warn("
|
|
236
|
+
logger.warn("[ably] Connection: suspended - connection lost for extended period, will keep retrying");
|
|
291
237
|
});
|
|
292
238
|
|
|
293
239
|
this._ably.connection.on("failed", function () {
|
|
240
|
+
logger.error("[ably] Connection: failed");
|
|
294
241
|
self.apiSocketConnected = false;
|
|
295
242
|
self.instanceSocketConnected = false;
|
|
296
243
|
emitter.emit(events.error.sandbox, "Ably connection failed");
|
|
297
244
|
});
|
|
245
|
+
|
|
246
|
+
// ─── Channel discontinuity detection ──────────────────────────────
|
|
247
|
+
// Set up BEFORE subscribing so we catch any continuity loss during
|
|
248
|
+
// the initial attachment. Fires at the channel level, covering all
|
|
249
|
+
// message types (response, file, control).
|
|
250
|
+
this._sessionChannel.on(function (stateChange) {
|
|
251
|
+
var current = stateChange.current;
|
|
252
|
+
var previous = stateChange.previous;
|
|
253
|
+
var reason = stateChange.reason;
|
|
254
|
+
var reasonMsg = reason ? (reason.message || reason.code || String(reason)) : '';
|
|
255
|
+
|
|
256
|
+
if (current === 'attached' && stateChange.resumed === false && previous) {
|
|
257
|
+
logger.warn('[ably] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
|
|
258
|
+
emitter.emit(events.sandbox.progress, {
|
|
259
|
+
step: 'discontinuity',
|
|
260
|
+
message: 'Recovering missed messages after connection interruption...',
|
|
261
|
+
});
|
|
262
|
+
self._recoverFromDiscontinuity();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
298
265
|
}
|
|
299
266
|
|
|
300
267
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
|
|
268
|
+
* Recover missed messages after a channel discontinuity.
|
|
269
|
+
* Uses historyBeforeSubscribe() on each subscription, which guarantees
|
|
270
|
+
* no gap between historical and live messages.
|
|
271
|
+
*/
|
|
272
|
+
async _recoverFromDiscontinuity() {
|
|
273
|
+
var subs = [
|
|
274
|
+
{ name: 'response', sub: this._responseSubscription },
|
|
275
|
+
{ name: 'file', sub: this._fileSubscription },
|
|
276
|
+
];
|
|
277
|
+
var totalRecovered = 0;
|
|
278
|
+
for (var i = 0; i < subs.length; i++) {
|
|
279
|
+
var entry = subs[i];
|
|
280
|
+
if (!entry.sub) continue;
|
|
281
|
+
try {
|
|
282
|
+
logger.log('[ably] Discontinuity recovery: fetching historyBeforeSubscribe for ' + entry.name + '...');
|
|
283
|
+
var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
|
|
284
|
+
var recovered = 0;
|
|
285
|
+
while (page) {
|
|
286
|
+
recovered += page.items.length;
|
|
287
|
+
page = page.hasNext() ? await page.next() : null;
|
|
288
|
+
}
|
|
289
|
+
totalRecovered += recovered;
|
|
290
|
+
logger.log('[ably] Discontinuity recovery: found ' + recovered + ' ' + entry.name + ' message(s) in gap');
|
|
291
|
+
} catch (err) {
|
|
292
|
+
logger.error('[ably] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (totalRecovered > 0) {
|
|
296
|
+
logger.warn('[ably] Recovered ' + totalRecovered + ' message(s) that were missed during connection interruption');
|
|
297
|
+
} else {
|
|
298
|
+
logger.log('[ably] Discontinuity recovery: no missed messages found');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* POST to the API with retry for transient network errors (via withRetry)
|
|
304
|
+
* and infinite polling for CONCURRENCY_LIMIT_EXCEEDED (until vitest's
|
|
305
|
+
* testTimeout kills the test).
|
|
305
306
|
*/
|
|
306
307
|
async _httpPostWithConcurrencyRetry(path, body, timeout) {
|
|
307
|
-
var
|
|
308
|
+
var concurrencyRetryInterval = 10000; // 10 seconds between concurrency retries
|
|
308
309
|
var startTime = Date.now();
|
|
310
|
+
var sessionId = this.sessionInstance ? this.sessionInstance.get() : null;
|
|
311
|
+
|
|
312
|
+
var self = this;
|
|
313
|
+
var makeRequest = function () {
|
|
314
|
+
return axios({
|
|
315
|
+
method: "post",
|
|
316
|
+
url: self.apiRoot + path,
|
|
317
|
+
data: body,
|
|
318
|
+
headers: {
|
|
319
|
+
"Content-Type": "application/json",
|
|
320
|
+
"User-Agent": "TestDriverSDK/" + version + " (Node.js " + process.version + ")",
|
|
321
|
+
...getSentryTraceHeaders(sessionId),
|
|
322
|
+
},
|
|
323
|
+
timeout: timeout || 120000,
|
|
324
|
+
});
|
|
325
|
+
};
|
|
309
326
|
|
|
310
327
|
while (true) {
|
|
311
328
|
try {
|
|
312
|
-
|
|
329
|
+
var response = await withRetry(makeRequest, {
|
|
330
|
+
retryConfig: {
|
|
331
|
+
maxRetries: 3,
|
|
332
|
+
baseDelayMs: 2000,
|
|
333
|
+
retryableStatusCodes: [500, 502, 503, 504], // Don't retry 429 — handled below
|
|
334
|
+
},
|
|
335
|
+
onRetry: function (attempt, error, delayMs) {
|
|
336
|
+
var elapsed = Date.now() - startTime;
|
|
337
|
+
logger.warn(
|
|
338
|
+
"Transient network error: " + (error.message || error.code) +
|
|
339
|
+
" — POST " + path +
|
|
340
|
+
" — retry " + attempt + "/3" +
|
|
341
|
+
" in " + (delayMs / 1000).toFixed(1) + "s" +
|
|
342
|
+
" (" + Math.round(elapsed / 1000) + "s elapsed)...",
|
|
343
|
+
);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
return response.data;
|
|
313
347
|
} catch (err) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
var elapsed = Date.now() - startTime;
|
|
323
|
-
|
|
324
|
-
logger.log(
|
|
325
|
-
"Concurrency limit reached — waiting " +
|
|
326
|
-
retryInterval / 1000 +
|
|
348
|
+
// Concurrency limit — poll forever until a slot opens
|
|
349
|
+
var responseData = err.response && err.response.data;
|
|
350
|
+
if (responseData && responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED") {
|
|
351
|
+
var elapsed = Date.now() - startTime;
|
|
352
|
+
logger.log(
|
|
353
|
+
"Concurrency limit reached — waiting " +
|
|
354
|
+
concurrencyRetryInterval / 1000 +
|
|
327
355
|
"s for a slot to become available (" +
|
|
328
356
|
Math.round(elapsed / 1000) +
|
|
329
357
|
"s elapsed)...",
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
358
|
+
);
|
|
359
|
+
await new Promise(function (resolve) {
|
|
360
|
+
var t = setTimeout(resolve, concurrencyRetryInterval);
|
|
361
|
+
if (t.unref) t.unref();
|
|
362
|
+
});
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Non-retryable HTTP error — preserve responseData for callers
|
|
367
|
+
if (responseData) {
|
|
368
|
+
var httpErr = new Error(
|
|
369
|
+
responseData.errorMessage || responseData.message || "HTTP " + err.response.status,
|
|
370
|
+
);
|
|
371
|
+
httpErr.responseData = responseData;
|
|
372
|
+
throw httpErr;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
throw err;
|
|
335
376
|
}
|
|
336
377
|
}
|
|
337
378
|
}
|
|
@@ -390,14 +431,14 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
390
431
|
this._teamId = reply.teamId;
|
|
391
432
|
|
|
392
433
|
if (reply.ably && reply.ably.token) {
|
|
393
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
434
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
394
435
|
this.instanceSocketConnected = true;
|
|
395
436
|
|
|
396
437
|
// Tell the runner to enable debug log forwarding if debug mode is on
|
|
397
438
|
var debugMode =
|
|
398
439
|
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
399
|
-
if (debugMode && this.
|
|
400
|
-
this.
|
|
440
|
+
if (debugMode && this._sessionChannel) {
|
|
441
|
+
this._sessionChannel.publish("control", {
|
|
401
442
|
type: "debug",
|
|
402
443
|
enabled: true,
|
|
403
444
|
});
|
|
@@ -430,7 +471,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
430
471
|
// agent to signal readiness before sending commands. Without this
|
|
431
472
|
// gate, commands published before the agent subscribes are lost.
|
|
432
473
|
var self = this;
|
|
433
|
-
if (!reply.runner && this.
|
|
474
|
+
if (!reply.runner && this._sessionChannel) {
|
|
434
475
|
logger.log('Waiting for runner agent to signal readiness...');
|
|
435
476
|
var readyTimeout = 120000; // 120s — allows for EC2 boot + agent startup
|
|
436
477
|
await new Promise(function (resolve, reject) {
|
|
@@ -439,7 +480,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
439
480
|
if (resolved) return;
|
|
440
481
|
resolved = true;
|
|
441
482
|
clearTimeout(timer);
|
|
442
|
-
self.
|
|
483
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
443
484
|
// Update runner info if provided
|
|
444
485
|
if (data && data.os) reply.runner = reply.runner || {};
|
|
445
486
|
if (data && data.os && reply.runner) reply.runner.os = data.os;
|
|
@@ -464,8 +505,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
464
505
|
var timer = setTimeout(function () {
|
|
465
506
|
if (!resolved) {
|
|
466
507
|
resolved = true;
|
|
467
|
-
self.
|
|
468
|
-
|
|
508
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
509
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms');
|
|
510
|
+
sentry.captureException(err, {
|
|
511
|
+
tags: { phase: 'runner_ready', connection_type: 'create' },
|
|
512
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId },
|
|
513
|
+
});
|
|
514
|
+
reject(err);
|
|
469
515
|
}
|
|
470
516
|
}, readyTimeout);
|
|
471
517
|
if (timer.unref) timer.unref();
|
|
@@ -478,12 +524,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
478
524
|
finish(data);
|
|
479
525
|
}
|
|
480
526
|
};
|
|
481
|
-
self.
|
|
527
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
482
528
|
|
|
483
529
|
// Also check channel history in case runner.ready was published
|
|
484
530
|
// before we subscribed (race condition on fast-booting agents).
|
|
485
531
|
try {
|
|
486
|
-
self.
|
|
532
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
487
533
|
if (err) {
|
|
488
534
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
489
535
|
return;
|
|
@@ -538,17 +584,22 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
538
584
|
// provision the config to the instance via SSM (client-side).
|
|
539
585
|
// This runs from the user's infrastructure where AWS permissions exist,
|
|
540
586
|
// rather than from the API server.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
587
|
+
// NOTE: For direct connections, the user MUST provide the AWS instanceId
|
|
588
|
+
// because the API only knows the sandboxId, not the actual EC2 instance ID.
|
|
589
|
+
var instanceId = message.instanceId;
|
|
590
|
+
if (reply.agentConfig && instanceId) {
|
|
591
|
+
logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM...');
|
|
592
|
+
await this._provisionAgentConfig(instanceId, reply.agentConfig);
|
|
544
593
|
logger.log('Agent config provisioned successfully.');
|
|
594
|
+
} else if (reply.agentConfig && !instanceId) {
|
|
595
|
+
logger.log('Warning: agentConfig returned but no instanceId provided - cannot provision via SSM');
|
|
545
596
|
}
|
|
546
597
|
|
|
547
598
|
// If the API returned agent credentials (reply.agent present),
|
|
548
599
|
// wait for the runner agent to signal readiness before sending commands.
|
|
549
600
|
// Without this gate, commands published before the agent subscribes are lost.
|
|
550
601
|
var self = this;
|
|
551
|
-
if (reply.agent && this.
|
|
602
|
+
if (reply.agent && this._sessionChannel) {
|
|
552
603
|
logger.log('Waiting for runner agent to signal readiness (direct connection)...');
|
|
553
604
|
var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
|
|
554
605
|
await new Promise(function (resolve, reject) {
|
|
@@ -557,7 +608,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
557
608
|
if (resolved) return;
|
|
558
609
|
resolved = true;
|
|
559
610
|
clearTimeout(timer);
|
|
560
|
-
self.
|
|
611
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
561
612
|
logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
562
613
|
if (data && data.update) {
|
|
563
614
|
var u = data.update;
|
|
@@ -577,8 +628,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
577
628
|
var timer = setTimeout(function () {
|
|
578
629
|
if (!resolved) {
|
|
579
630
|
resolved = true;
|
|
580
|
-
self.
|
|
581
|
-
|
|
631
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
632
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)');
|
|
633
|
+
sentry.captureException(err, {
|
|
634
|
+
tags: { phase: 'runner_ready', connection_type: 'direct' },
|
|
635
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId, instanceId: message.instanceId },
|
|
636
|
+
});
|
|
637
|
+
reject(err);
|
|
582
638
|
}
|
|
583
639
|
}, readyTimeout);
|
|
584
640
|
if (timer.unref) timer.unref();
|
|
@@ -591,12 +647,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
591
647
|
finish(data);
|
|
592
648
|
}
|
|
593
649
|
};
|
|
594
|
-
self.
|
|
650
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
595
651
|
|
|
596
652
|
// Also check channel history in case runner.ready was published
|
|
597
653
|
// before we subscribed (race condition on fast-booting agents).
|
|
598
654
|
try {
|
|
599
|
-
self.
|
|
655
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
600
656
|
if (err) {
|
|
601
657
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
602
658
|
return;
|
|
@@ -638,7 +694,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
638
694
|
_sendAbly(message, timeout) {
|
|
639
695
|
if (timeout === undefined) timeout = 300000;
|
|
640
696
|
|
|
641
|
-
if (!this.
|
|
697
|
+
if (!this._sessionChannel || !this._ably) {
|
|
642
698
|
return Promise.reject(
|
|
643
699
|
new Error("Sandbox not connected (no Ably client)"),
|
|
644
700
|
);
|
|
@@ -731,10 +787,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
731
787
|
rejectPromise(
|
|
732
788
|
new Error(
|
|
733
789
|
"Sandbox message '" +
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
790
|
+
message.type +
|
|
791
|
+
"' timed out after " +
|
|
792
|
+
timeout +
|
|
793
|
+
"ms",
|
|
738
794
|
),
|
|
739
795
|
);
|
|
740
796
|
}
|
|
@@ -756,11 +812,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
756
812
|
};
|
|
757
813
|
|
|
758
814
|
if (message.type === "output") {
|
|
759
|
-
p.catch(function () {});
|
|
815
|
+
p.catch(function () { });
|
|
760
816
|
}
|
|
761
817
|
|
|
762
|
-
this.
|
|
763
|
-
.publish("command", message)
|
|
818
|
+
this._throttledPublish(this._sessionChannel, "command", message)
|
|
764
819
|
.then(function () {
|
|
765
820
|
emitter.emit(events.sandbox.sent, message);
|
|
766
821
|
})
|
|
@@ -777,6 +832,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
777
832
|
return p;
|
|
778
833
|
}
|
|
779
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Throttled publish to stay under Ably's 50 msg/sec per-connection limit.
|
|
837
|
+
* Also tracks and logs the current publish rate for debugging.
|
|
838
|
+
* @param {Object} channel - Ably channel to publish on
|
|
839
|
+
* @param {string} eventName - Event name for the publish
|
|
840
|
+
* @param {Object} message - Message payload
|
|
841
|
+
* @returns {Promise} - Resolves when publish completes
|
|
842
|
+
*/
|
|
843
|
+
async _throttledPublish(channel, eventName, message) {
|
|
844
|
+
var self = this;
|
|
845
|
+
var now = Date.now();
|
|
846
|
+
|
|
847
|
+
// Rate limiting: wait if too soon since last publish
|
|
848
|
+
var elapsed = now - this._publishLastTime;
|
|
849
|
+
if (elapsed < this._publishMinIntervalMs) {
|
|
850
|
+
var waitMs = this._publishMinIntervalMs - elapsed;
|
|
851
|
+
await new Promise(function (resolve) {
|
|
852
|
+
var timer = setTimeout(resolve, waitMs);
|
|
853
|
+
if (timer.unref) timer.unref();
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
this._publishLastTime = Date.now();
|
|
857
|
+
|
|
858
|
+
// Metrics: track messages per second
|
|
859
|
+
this._publishCount++;
|
|
860
|
+
var windowElapsed = Date.now() - this._publishWindowStart;
|
|
861
|
+
if (windowElapsed >= 1000) {
|
|
862
|
+
var rate = (this._publishCount / windowElapsed) * 1000;
|
|
863
|
+
var rateStr = rate.toFixed(1);
|
|
864
|
+
|
|
865
|
+
// Log rate - warning if approaching limit, debug otherwise
|
|
866
|
+
if (rate > 45) {
|
|
867
|
+
logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
|
|
868
|
+
} else if (process.env.VERBOSE || process.env.TD_DEBUG) {
|
|
869
|
+
logger.log("Ably publish rate: " + rateStr + " msg/sec");
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Reset window
|
|
873
|
+
this._publishCount = 0;
|
|
874
|
+
this._publishWindowStart = Date.now();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return channel.publish(eventName, message).then(function () {
|
|
878
|
+
logger.log(`[ably] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
780
882
|
async auth(apiKey) {
|
|
781
883
|
this.apiKey = apiKey;
|
|
782
884
|
var sessionId = this.sessionInstance
|
|
@@ -803,7 +905,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
803
905
|
logger.log("Trace Report (Share When Reporting Bugs):");
|
|
804
906
|
logger.log(
|
|
805
907
|
"https://testdriver.sentry.io/explore/traces/trace/" +
|
|
806
|
-
|
|
908
|
+
reply.traceId,
|
|
807
909
|
);
|
|
808
910
|
}
|
|
809
911
|
|
|
@@ -846,7 +948,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
846
948
|
this._sandboxId = reply.sandboxId;
|
|
847
949
|
|
|
848
950
|
if (reply.ably && reply.ably.token) {
|
|
849
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
951
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
850
952
|
}
|
|
851
953
|
|
|
852
954
|
this.setConnectionParams({
|
|
@@ -895,38 +997,43 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
895
997
|
clearInterval(this.heartbeat);
|
|
896
998
|
this.heartbeat = null;
|
|
897
999
|
}
|
|
1000
|
+
if (this._statsInterval) {
|
|
1001
|
+
clearInterval(this._statsInterval);
|
|
1002
|
+
this._statsInterval = null;
|
|
1003
|
+
}
|
|
898
1004
|
|
|
899
1005
|
// Send end-session control message to runner before disconnecting
|
|
900
|
-
if (this.
|
|
1006
|
+
if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
|
|
901
1007
|
try {
|
|
902
|
-
|
|
1008
|
+
logger.log('[ably] Publishing control: type=end-session');
|
|
1009
|
+
await this._sessionChannel.publish('control', { type: 'end-session' });
|
|
903
1010
|
} catch (e) {
|
|
904
1011
|
// Ignore - best effort
|
|
905
1012
|
}
|
|
906
1013
|
}
|
|
907
1014
|
|
|
908
|
-
// Leave presence on
|
|
909
|
-
if (this.
|
|
1015
|
+
// Leave presence on session channel
|
|
1016
|
+
if (this._sessionChannel) {
|
|
910
1017
|
try {
|
|
911
|
-
|
|
1018
|
+
logger.log('[ably] Leaving presence on session channel');
|
|
1019
|
+
await this._sessionChannel.presence.leave();
|
|
912
1020
|
} catch (e) {
|
|
913
1021
|
// ignore - best effort, Ably will auto-leave on disconnect
|
|
914
1022
|
}
|
|
915
1023
|
}
|
|
916
1024
|
|
|
917
1025
|
try {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
this.
|
|
921
|
-
|
|
922
|
-
this._filesChannel?.detach(),
|
|
923
|
-
].filter(Boolean));
|
|
1026
|
+
logger.log('[ably] Detaching session channel');
|
|
1027
|
+
if (this._sessionChannel) {
|
|
1028
|
+
await this._sessionChannel.detach();
|
|
1029
|
+
}
|
|
924
1030
|
} catch (e) {
|
|
925
1031
|
/* ignore */
|
|
926
1032
|
}
|
|
927
1033
|
|
|
928
1034
|
if (this._ably) {
|
|
929
1035
|
try {
|
|
1036
|
+
logger.log('[ably] Closing Ably connection');
|
|
930
1037
|
this._ably.close();
|
|
931
1038
|
} catch (e) {
|
|
932
1039
|
/* ignore */
|
|
@@ -934,11 +1041,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
934
1041
|
this._ably = null;
|
|
935
1042
|
}
|
|
936
1043
|
|
|
937
|
-
this.
|
|
938
|
-
this.
|
|
939
|
-
this._ctrlChannel = null;
|
|
940
|
-
this._filesChannel = null;
|
|
941
|
-
this._channelNames = null;
|
|
1044
|
+
this._sessionChannel = null;
|
|
1045
|
+
this._channelName = null;
|
|
942
1046
|
this.apiSocketConnected = false;
|
|
943
1047
|
this.instanceSocketConnected = false;
|
|
944
1048
|
this.authenticated = false;
|
|
@@ -961,11 +1065,51 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
961
1065
|
const region = process.env.AWS_REGION || 'us-east-2';
|
|
962
1066
|
|
|
963
1067
|
// Write SSM parameters to a temp file to avoid shell quoting issues
|
|
1068
|
+
// Log key config details for debugging
|
|
1069
|
+
logger.log('Agent config being provisioned:');
|
|
1070
|
+
logger.log(' sandboxId: ' + agentConfig.sandboxId);
|
|
1071
|
+
logger.log(' apiRoot: ' + agentConfig.apiRoot);
|
|
1072
|
+
logger.log(' channel: ' + (agentConfig.ably?.channel || 'N/A'));
|
|
1073
|
+
logger.log(' token length: ' + (agentConfig.ably?.token ? JSON.stringify(agentConfig.ably.token).length : 0));
|
|
1074
|
+
|
|
964
1075
|
const paramsJson = JSON.stringify({
|
|
965
1076
|
commands: [
|
|
1077
|
+
// Debug: show existing state
|
|
1078
|
+
"Write-Host '=== Checking existing state ==='",
|
|
1079
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1080
|
+
"if ($task) { Write-Host \"Task exists, state: $($task.State)\" } else { Write-Host 'Task does NOT exist!' }",
|
|
1081
|
+
"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' }",
|
|
1082
|
+
// Stop any running runner
|
|
1083
|
+
"Write-Host '=== Stopping runner ==='",
|
|
1084
|
+
"Stop-Process -Name node -Force -ErrorAction SilentlyContinue",
|
|
1085
|
+
"Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1086
|
+
// Write config
|
|
1087
|
+
"Write-Host '=== Writing config ==='",
|
|
966
1088
|
"$config = '" + configJson.replace(/'/g, "''") + "'",
|
|
967
1089
|
"[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
|
|
968
1090
|
"Write-Host 'Config written for sandbox " + agentConfig.sandboxId + "'",
|
|
1091
|
+
// Show what was written (redact token)
|
|
1092
|
+
"Write-Host '=== New config (token redacted) ==='",
|
|
1093
|
+
"$cfg = Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | ConvertFrom-Json",
|
|
1094
|
+
"Write-Host \"sandboxId: $($cfg.sandboxId)\"",
|
|
1095
|
+
"Write-Host \"apiRoot: $($cfg.apiRoot)\"",
|
|
1096
|
+
"Write-Host \"channel: $($cfg.ably.channel)\"",
|
|
1097
|
+
"Write-Host \"token type: $($cfg.ably.token.GetType().Name)\"",
|
|
1098
|
+
// Start the runner
|
|
1099
|
+
"Write-Host '=== Starting runner ==='",
|
|
1100
|
+
"Start-Sleep -Seconds 1",
|
|
1101
|
+
"Start-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction Stop",
|
|
1102
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent",
|
|
1103
|
+
"Write-Host \"Task state after start: $($task.State)\"",
|
|
1104
|
+
// Check if node process started
|
|
1105
|
+
"Start-Sleep -Seconds 3",
|
|
1106
|
+
"Write-Host '=== Checking runner process ==='",
|
|
1107
|
+
"$procs = Get-Process -Name node -ErrorAction SilentlyContinue",
|
|
1108
|
+
"if ($procs) { Write-Host \"Node processes: $($procs.Count)\"; $procs | ForEach-Object { Write-Host \" PID: $($_.Id), StartTime: $($_.StartTime)\" } } else { Write-Host 'No node process found!' }",
|
|
1109
|
+
// Check runner logs
|
|
1110
|
+
"Write-Host '=== Runner log (last 30 lines) ==='",
|
|
1111
|
+
"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' }",
|
|
1112
|
+
"Write-Host '=== Done ==='",
|
|
969
1113
|
],
|
|
970
1114
|
});
|
|
971
1115
|
const tmpFile = join(tmpdir(), 'td-provision-' + Date.now() + '.json');
|
|
@@ -987,6 +1131,24 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
987
1131
|
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
|
|
988
1132
|
{ encoding: 'utf-8', timeout: 60000 }
|
|
989
1133
|
);
|
|
1134
|
+
|
|
1135
|
+
// Get the command output for debugging
|
|
1136
|
+
try {
|
|
1137
|
+
const invocationOutput = execSync(
|
|
1138
|
+
'aws ssm get-command-invocation --region "' + region + '" ' +
|
|
1139
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
|
|
1140
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1141
|
+
);
|
|
1142
|
+
const invocation = JSON.parse(invocationOutput);
|
|
1143
|
+
if (invocation.StandardOutputContent) {
|
|
1144
|
+
logger.log('SSM output:\n' + invocation.StandardOutputContent);
|
|
1145
|
+
}
|
|
1146
|
+
if (invocation.StandardErrorContent) {
|
|
1147
|
+
logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
|
|
1148
|
+
}
|
|
1149
|
+
} catch (e) {
|
|
1150
|
+
logger.warn('Could not retrieve SSM command output: ' + e.message);
|
|
1151
|
+
}
|
|
990
1152
|
} finally {
|
|
991
1153
|
try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
|
|
992
1154
|
}
|