@loadmill/droid-cua 2.25.0 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/build/index.js +210 -2
- package/build/src/cli/cloud-status-line.js +58 -26
- package/build/src/cli/headless-execution-config.js +11 -0
- package/build/src/cli/headless-report.js +105 -15
- package/build/src/cli/headless-runner.js +125 -24
- package/build/src/cli/headless-target.js +63 -7
- package/build/src/cli/loadmill-cloud-credentials.js +11 -0
- package/build/src/cli/loadmill-cloud-usage.js +18 -0
- package/build/src/commands/help.js +5 -0
- package/build/src/core/cua-models.js +7 -0
- package/build/src/core/droid-cua-run-usage.js +8 -2
- package/build/src/core/execution-engine.js +6 -2
- package/build/src/core/report/render-html-report.js +69 -12
- package/build/src/core/runtime-config.js +10 -4
- package/build/src/device/assertions.js +3 -0
- package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
- package/build/src/device/cloud/connection.js +18 -6
- package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
- package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
- package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
- package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
- package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
- package/build/src/device/cloud/progress.js +29 -0
- package/build/src/device/cloud/registry.js +2 -1
- package/build/src/device/connection.js +3 -0
- package/build/src/device/openai.js +1 -0
- package/build/src/integrations/loadmill/client.js +14 -0
- package/build/src/integrations/loadmill/report-upload-manager.js +221 -0
- package/build/src/integrations/loadmill/run-reporter.js +6 -2
- package/build/src/modes/execution-mode.js +2 -1
- package/build/src/prompts/design.js +9 -1
- package/build/src/prompts/execution.js +10 -7
- package/build/src/utils/console-output.js +3 -0
- package/package.json +1 -1
- package/skill/droid-cua/references/running-and-healing.md +3 -3
package/README.md
CHANGED
|
@@ -48,7 +48,10 @@ Open the desktop app and choose the platform you want to test.
|
|
|
48
48
|
|
|
49
49
|
**3. Choose your AI provider**
|
|
50
50
|
|
|
51
|
-
In Settings, choose the CUA model you want to run.
|
|
51
|
+
In Settings, choose the CUA model you want to run. Loadmill Smart is the
|
|
52
|
+
recommended option, Pulse is fast and efficient, and Beacon is experimental.
|
|
53
|
+
The Loadmill models use your Loadmill sign-in and usage tracking. The OpenAI
|
|
54
|
+
models use your own OpenAI API key.
|
|
52
55
|
|
|
53
56
|
**4. Connect a target device**
|
|
54
57
|
|
|
@@ -75,7 +78,7 @@ Desktop app support:
|
|
|
75
78
|
## Requirements
|
|
76
79
|
|
|
77
80
|
**All platforms:**
|
|
78
|
-
- Loadmill sign-in for
|
|
81
|
+
- Loadmill sign-in for the Loadmill models, or an OpenAI API key for the OpenAI models
|
|
79
82
|
|
|
80
83
|
**Android:**
|
|
81
84
|
- Android Debug Bridge (ADB)
|
|
@@ -247,6 +250,7 @@ Supported CLI options include:
|
|
|
247
250
|
- `--no-context`
|
|
248
251
|
- `--context-optimization-threshold`
|
|
249
252
|
- `--no-context-optimization`
|
|
253
|
+
- `--explain`
|
|
250
254
|
- `--base-prompt`
|
|
251
255
|
- `--execution-prompt`
|
|
252
256
|
- `--base-prompt-file`
|
|
@@ -254,11 +258,12 @@ Supported CLI options include:
|
|
|
254
258
|
- `--secrets`
|
|
255
259
|
- `--record`
|
|
256
260
|
- `--report`
|
|
261
|
+
- `--no-cloud-report`
|
|
257
262
|
- `--debug`
|
|
258
263
|
|
|
259
|
-
`--cua-model` accepts `loadmill-smart`, `loadmill-
|
|
260
|
-
`gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
|
|
261
|
-
`OPENAI_API_KEY`.
|
|
264
|
+
`--cua-model` accepts `loadmill-smart`, `loadmill-pulse`, `loadmill-beacon`,
|
|
265
|
+
`gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
|
|
266
|
+
mode and require `OPENAI_API_KEY`.
|
|
262
267
|
|
|
263
268
|
Config and precedence rules:
|
|
264
269
|
- Use `--config <file>` to supply prompt-affecting settings for headless runs.
|
|
@@ -268,6 +273,8 @@ Config and precedence rules:
|
|
|
268
273
|
- Context Optimization is enabled by default with a `30000` input-token threshold.
|
|
269
274
|
- `--context-optimization-threshold <tokens>` sets the threshold and enables Context Optimization.
|
|
270
275
|
- `--no-context-optimization` disables Context Optimization.
|
|
276
|
+
- `--explain` enables Explain Mode for the run. Explain Mode asks the agent for concise explanations of non-obvious decisions, skipped actions, and completion evidence; explanations appear in terminal output and reports.
|
|
277
|
+
- Set `"explainMode": true` in the JSON config to enable the same behavior without a flag. Explain Mode is off by default, and `--explain` enables it when the config leaves it off.
|
|
271
278
|
- `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
|
|
272
279
|
- `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
|
|
273
280
|
- If both inline and file-based prompt overrides are provided, the inline prompt flags win.
|
|
@@ -283,6 +290,7 @@ HTML report (`--report`):
|
|
|
283
290
|
- Writes a self-contained HTML report of the run — the same Run Details view the desktop app shows, with screenshots inlined — that you can share and open in any browser.
|
|
284
291
|
- With no value it saves `<test>--<date>--<time>.html` (e.g. `login--2026-06-03--16-09-20.html`) in the current directory; pass `--report <file>` to pick a filename or `--report <dir>` to pick a folder.
|
|
285
292
|
- In the desktop app the same report is available from a run's **Share → Download HTML report** button.
|
|
293
|
+
- Authenticated headless runs upload their HTML report and screenshots to Loadmill by default. Use `--no-cloud-report` to disable that artifact upload. The basic Droid run row is still reported to Loadmill.
|
|
286
294
|
|
|
287
295
|
```sh
|
|
288
296
|
droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua --report ./reports
|
package/build/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const processStartTime = Date.now();
|
|
1
2
|
import minimist from "minimist";
|
|
2
3
|
import dotenv from "dotenv";
|
|
3
4
|
import path from "path";
|
|
@@ -17,7 +18,7 @@ import { resolveExecutionTestData } from "./src/core/test-data.js";
|
|
|
17
18
|
import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
|
|
18
19
|
import { assertRuntimeCredentialsForProvider, createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
19
20
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
20
|
-
import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
|
|
21
|
+
import { resolveHeadlessTarget, shouldWaitForLoadmillCloudCompletion } from "./src/cli/headless-target.js";
|
|
21
22
|
import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
22
23
|
import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
|
|
23
24
|
import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
|
|
@@ -25,12 +26,33 @@ import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
|
|
|
25
26
|
import { planScenarioRuns } from "./src/cli/scenario-planner.js";
|
|
26
27
|
import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
|
|
27
28
|
import { resolveAwsDeviceFarmCredentials, uploadAwsDeviceFarmCliApp } from "./src/cli/aws-device-farm-app-upload.js";
|
|
29
|
+
import { resolveLoadmillCloudCredentials } from "./src/cli/loadmill-cloud-credentials.js";
|
|
30
|
+
import { formatCloudUsageReport } from "./src/cli/loadmill-cloud-usage.js";
|
|
31
|
+
import { LoadmillCloudControlPlaneClient } from "./src/device/cloud/loadmill-cloud/control-plane-client.js";
|
|
28
32
|
import { createStatusLine } from "./src/cli/cloud-status-line.js";
|
|
29
33
|
import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
|
|
34
|
+
import { droidReportUploadManager } from "./src/integrations/loadmill/report-upload-manager.js";
|
|
30
35
|
import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
|
|
31
36
|
import { setCloudProgressListener } from "./src/device/cloud/progress.js";
|
|
32
37
|
import { runSkillCommand } from "./src/cli/skill-command.js";
|
|
38
|
+
import { isLoadmillCloudUsageLimitError } from "./src/device/cloud/loadmill-cloud/errors.js";
|
|
39
|
+
import { loadmillCloudAdapter } from "./src/device/cloud/loadmill-cloud/adapter.js";
|
|
40
|
+
import { formatDuration } from "./src/core/report/run-log-model.js";
|
|
33
41
|
dotenv.config();
|
|
42
|
+
process.once("SIGTERM", async () => {
|
|
43
|
+
process.exitCode = 143;
|
|
44
|
+
// Registering a SIGTERM listener cancels Node's default terminate, so exit
|
|
45
|
+
// explicitly; await the /stop because a bare fire-and-forget fetch may not
|
|
46
|
+
// dispatch before exit. Capped so a slow server can't hang the exit.
|
|
47
|
+
await Promise.race([
|
|
48
|
+
loadmillCloudAdapter.releaseActiveSession(),
|
|
49
|
+
new Promise((resolve) => setTimeout(resolve, 1_500)),
|
|
50
|
+
]);
|
|
51
|
+
process.exit(143);
|
|
52
|
+
});
|
|
53
|
+
process.once("beforeExit", () => {
|
|
54
|
+
loadmillCloudAdapter.releaseActiveSession();
|
|
55
|
+
});
|
|
34
56
|
const args = minimist(process.argv.slice(2));
|
|
35
57
|
if (args._[0] === "skill") {
|
|
36
58
|
try {
|
|
@@ -57,6 +79,7 @@ const debugMode = args["debug"] || false;
|
|
|
57
79
|
const reportConfig = {
|
|
58
80
|
enabled: args.report === true || (typeof args.report === "string" && args.report.trim().length > 0),
|
|
59
81
|
path: typeof args.report === "string" && args.report.trim().length > 0 ? args.report.trim() : null,
|
|
82
|
+
cloudEnabled: args["no-cloud-report"] !== true,
|
|
60
83
|
};
|
|
61
84
|
const screenshotDir = path.join("droid-cua-recording-" + Date.now());
|
|
62
85
|
if (recordScreenshots)
|
|
@@ -173,6 +196,84 @@ async function resolveLambdaTestDashboardUrl(credentials, sessionId, { attempts
|
|
|
173
196
|
return null;
|
|
174
197
|
}
|
|
175
198
|
async function main() {
|
|
199
|
+
if (args["cloud-usage"] != null) {
|
|
200
|
+
try {
|
|
201
|
+
const credentials = resolveLoadmillCloudCredentials(process.env);
|
|
202
|
+
const client = new LoadmillCloudControlPlaneClient({
|
|
203
|
+
bearerToken: credentials.bearerToken,
|
|
204
|
+
baseUrl: credentials.baseUrl,
|
|
205
|
+
});
|
|
206
|
+
const usage = await client.getUsage();
|
|
207
|
+
for (const line of formatCloudUsageReport(usage)) {
|
|
208
|
+
console.log(line);
|
|
209
|
+
}
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
214
|
+
const code = error?.code ? ` [${error.code}]` : "";
|
|
215
|
+
console.error(`Failed to fetch Loadmill Cloud usage: ${message}${code}`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Pure retroactive artifact fetch: `--cloud-session-id <sid> --artifacts <kinds>`
|
|
220
|
+
// with no --instructions. Reattaching to a terminal run and downloading its
|
|
221
|
+
// recordings/logs doesn't require driving anything, so it doesn't need the
|
|
222
|
+
// full headless-instructions machinery below (which requires --instructions
|
|
223
|
+
// for every cloud device-source run).
|
|
224
|
+
if (targetConfig.deviceSource === "loadmill-cloud" &&
|
|
225
|
+
targetConfig.cloudSessionId &&
|
|
226
|
+
Array.isArray(targetConfig.artifacts) &&
|
|
227
|
+
targetConfig.artifacts.length > 0 &&
|
|
228
|
+
!instructionsFile) {
|
|
229
|
+
const credentials = resolveLoadmillCloudCredentials(process.env);
|
|
230
|
+
// Share the exact same rendering the normal run command uses — install
|
|
231
|
+
// the progress listener before touching the adapter, since prepareSession
|
|
232
|
+
// reports poll/download progress unconditionally and would otherwise be
|
|
233
|
+
// silently dropped (no listener installed => no output for 10-20s).
|
|
234
|
+
const retroactiveStatusLine = createStatusLine();
|
|
235
|
+
setCloudProgressListener((event) => {
|
|
236
|
+
// The run may still be finishing up on the cloud device when this
|
|
237
|
+
// starts — only switch the umbrella label to "fetching" once the
|
|
238
|
+
// artifact list is actually in hand and downloads are starting.
|
|
239
|
+
if (event.kind === "note" && event.message === "Fetching artifacts") {
|
|
240
|
+
retroactiveStatusLine.rename("Fetching Loadmill Cloud artifacts");
|
|
241
|
+
}
|
|
242
|
+
retroactiveStatusLine.handleEvent(event);
|
|
243
|
+
});
|
|
244
|
+
try {
|
|
245
|
+
retroactiveStatusLine.start("Waiting for Loadmill Cloud artifacts");
|
|
246
|
+
await connectToDevice({
|
|
247
|
+
source: "loadmill-cloud",
|
|
248
|
+
providerId: "loadmill-cloud",
|
|
249
|
+
platform: targetConfig.platform,
|
|
250
|
+
deviceName: targetConfig.deviceName,
|
|
251
|
+
osVersion: targetConfig.osVersion,
|
|
252
|
+
appRef: targetConfig.appPath,
|
|
253
|
+
credentials,
|
|
254
|
+
cloudSessionId: targetConfig.cloudSessionId,
|
|
255
|
+
artifacts: targetConfig.artifacts,
|
|
256
|
+
}, null, { log: () => { } });
|
|
257
|
+
retroactiveStatusLine.stop();
|
|
258
|
+
// A resumable (non-terminal) session reconnected successfully instead of
|
|
259
|
+
// exiting via the terminal/artifacts branch above — there's nothing to
|
|
260
|
+
// drive without --instructions, so release the slot and say so.
|
|
261
|
+
await loadmillCloudAdapter.releaseSession(credentials, { waitForCompletion: false });
|
|
262
|
+
console.error(`Loadmill Cloud session ${targetConfig.cloudSessionId} is not terminal yet; add --instructions to drive it, or wait until it finishes and re-run this same command to fetch artifacts.`);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
retroactiveStatusLine.stop();
|
|
267
|
+
if (error?.artifactsOnlyExit) {
|
|
268
|
+
console.log(error.message);
|
|
269
|
+
console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
|
|
270
|
+
process.exit(0);
|
|
271
|
+
}
|
|
272
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
273
|
+
console.error(`Loadmill Cloud artifact fetch failed: ${message}`);
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
176
277
|
if (targetConfig.listDevices) {
|
|
177
278
|
const credentials = resolveAwsDeviceFarmCredentials(process.env);
|
|
178
279
|
const adapter = getCloudProviderAdapter("aws-device-farm");
|
|
@@ -188,12 +289,15 @@ async function main() {
|
|
|
188
289
|
const isBrowserTarget = targetConfig.target === "web";
|
|
189
290
|
const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
|
|
190
291
|
const isAwsDeviceFarmTarget = targetConfig.deviceSource === "aws-device-farm";
|
|
191
|
-
const
|
|
292
|
+
const isLoadmillCloudTarget = targetConfig.deviceSource === "loadmill-cloud";
|
|
293
|
+
const isCloudTarget = isLambdaTestTarget || isAwsDeviceFarmTarget || isLoadmillCloudTarget;
|
|
192
294
|
let lambdaTestSessionConnected = false;
|
|
193
295
|
let lambdaTestSessionUrl = null;
|
|
194
296
|
let lambdaTestCredentials = null;
|
|
195
297
|
let awsSessionConnected = false;
|
|
196
298
|
let awsDeviceFarmStatusLine = null;
|
|
299
|
+
let loadmillCloudSessionConnected = false;
|
|
300
|
+
let loadmillCloudStatusLine = null;
|
|
197
301
|
const headlessDebug = createHeadlessDebugArtifacts({
|
|
198
302
|
cwd: process.cwd(),
|
|
199
303
|
enabled: isHeadlessInstructionsRun && debugMode
|
|
@@ -407,6 +511,94 @@ async function main() {
|
|
|
407
511
|
console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
408
512
|
console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
409
513
|
}
|
|
514
|
+
else if (isLoadmillCloudTarget) {
|
|
515
|
+
const loadmillCloudCredentials = resolveLoadmillCloudCredentials(process.env);
|
|
516
|
+
loadmillCloudStatusLine = createStatusLine();
|
|
517
|
+
const statusLine = loadmillCloudStatusLine;
|
|
518
|
+
setCloudProgressListener((event) => statusLine.handleEvent(event));
|
|
519
|
+
const provisioningAbortController = new AbortController();
|
|
520
|
+
let loadmillCloudSigintHandled = false;
|
|
521
|
+
let loadmillCloudInterruptRequested = false;
|
|
522
|
+
process.once("SIGINT", async () => {
|
|
523
|
+
if (loadmillCloudSigintHandled)
|
|
524
|
+
return;
|
|
525
|
+
loadmillCloudSigintHandled = true;
|
|
526
|
+
loadmillCloudInterruptRequested = true;
|
|
527
|
+
console.log("\nStopping Loadmill Cloud session...");
|
|
528
|
+
provisioningAbortController.abort(new Error("Stopped by user"));
|
|
529
|
+
if (!loadmillCloudSessionConnected) {
|
|
530
|
+
process.exitCode = 130;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
await disconnectDevice({ waitForCompletion: false });
|
|
534
|
+
loadmillCloudAdapter.releaseActiveSession();
|
|
535
|
+
process.exit(130);
|
|
536
|
+
});
|
|
537
|
+
try {
|
|
538
|
+
statusLine.start("Starting Loadmill Cloud session");
|
|
539
|
+
targetId = await connectToDevice({
|
|
540
|
+
source: "loadmill-cloud",
|
|
541
|
+
providerId: "loadmill-cloud",
|
|
542
|
+
platform: targetConfig.platform,
|
|
543
|
+
deviceName: targetConfig.deviceName,
|
|
544
|
+
osVersion: targetConfig.osVersion,
|
|
545
|
+
appRef: targetConfig.appPath,
|
|
546
|
+
credentials: loadmillCloudCredentials,
|
|
547
|
+
sessionName: `droid-cua-cli-${new Date().toISOString()}`,
|
|
548
|
+
cloudSessionId: targetConfig.cloudSessionId,
|
|
549
|
+
artifacts: targetConfig.artifacts,
|
|
550
|
+
signal: provisioningAbortController.signal,
|
|
551
|
+
}, null,
|
|
552
|
+
// These are one-off descriptive messages (not the name of the
|
|
553
|
+
// in-progress step), so they must render as persistent ℹ lines —
|
|
554
|
+
// never painted into the volatile detail slot, where a stray message
|
|
555
|
+
// like "Connected to ..." would linger after its moment has passed
|
|
556
|
+
// and contradict an already-closed "✔ <phase>" line above it.
|
|
557
|
+
{ log: (message) => statusLine.handleEvent({ kind: "note", message }) });
|
|
558
|
+
loadmillCloudSessionConnected = true;
|
|
559
|
+
if (loadmillCloudInterruptRequested) {
|
|
560
|
+
try {
|
|
561
|
+
await disconnectDevice({ waitForCompletion: false });
|
|
562
|
+
loadmillCloudAdapter.releaseActiveSession();
|
|
563
|
+
}
|
|
564
|
+
finally {
|
|
565
|
+
statusLine.stop();
|
|
566
|
+
loadmillCloudSessionConnected = false;
|
|
567
|
+
process.exitCode = 130;
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
deviceInfo = await getDeviceInfo(targetId);
|
|
572
|
+
statusLine.stop();
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
statusLine.stop();
|
|
576
|
+
if (error?.artifactsOnlyExit) {
|
|
577
|
+
console.log(error.message);
|
|
578
|
+
process.exitCode = 0;
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (loadmillCloudSessionConnected) {
|
|
582
|
+
await disconnectDevice({ waitForCompletion: false });
|
|
583
|
+
loadmillCloudAdapter.releaseActiveSession();
|
|
584
|
+
loadmillCloudSessionConnected = false;
|
|
585
|
+
}
|
|
586
|
+
if (loadmillCloudInterruptRequested) {
|
|
587
|
+
process.exitCode = 130;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (isLoadmillCloudUsageLimitError(error)) {
|
|
591
|
+
const suffix = error.code ? ` (${error.code})` : "";
|
|
592
|
+
console.error(`\n${error.message}${suffix}`);
|
|
593
|
+
process.exitCode = 1;
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
console.log(`Connected to Loadmill Cloud device "${targetConfig.deviceName}" (${targetId})`);
|
|
599
|
+
console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
600
|
+
console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
601
|
+
}
|
|
410
602
|
else {
|
|
411
603
|
targetId = await connectToDevice(avdName, platform);
|
|
412
604
|
deviceInfo = await getDeviceInfo(targetId);
|
|
@@ -420,6 +612,7 @@ async function main() {
|
|
|
420
612
|
// If --instructions provided, run in headless mode
|
|
421
613
|
if (instructionsFile) {
|
|
422
614
|
let exitCode = null;
|
|
615
|
+
let succeeded = false;
|
|
423
616
|
try {
|
|
424
617
|
const headlessConfig = preloadedHeadlessConfig;
|
|
425
618
|
const appContext = preloadedAppContext;
|
|
@@ -427,6 +620,7 @@ async function main() {
|
|
|
427
620
|
const runtimeContext = createCliRuntimeContext({
|
|
428
621
|
llmProviderMode: headlessConfig.llmProviderMode,
|
|
429
622
|
cuaModel: headlessConfig.cuaModel,
|
|
623
|
+
explainMode: headlessConfig.explainMode,
|
|
430
624
|
promptCustomizations: headlessConfig.promptCustomizations,
|
|
431
625
|
contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
|
|
432
626
|
contextOptimizationThreshold: headlessConfig.contextOptimizationThreshold,
|
|
@@ -477,8 +671,10 @@ async function main() {
|
|
|
477
671
|
report: reportConfig
|
|
478
672
|
});
|
|
479
673
|
exitCode = result.exitCode;
|
|
674
|
+
succeeded = true;
|
|
480
675
|
}
|
|
481
676
|
finally {
|
|
677
|
+
await droidReportUploadManager.drain();
|
|
482
678
|
if (lambdaTestSessionConnected) {
|
|
483
679
|
console.log("Ending LambdaTest session...");
|
|
484
680
|
await disconnectDevice();
|
|
@@ -505,6 +701,18 @@ async function main() {
|
|
|
505
701
|
awsSessionConnected = false;
|
|
506
702
|
console.log("AWS Device Farm session ended.");
|
|
507
703
|
}
|
|
704
|
+
if (loadmillCloudSessionConnected) {
|
|
705
|
+
console.log("Ending Loadmill Cloud session...");
|
|
706
|
+
loadmillCloudStatusLine.start("Ending Loadmill Cloud session");
|
|
707
|
+
await disconnectDevice({ waitForCompletion: shouldWaitForLoadmillCloudCompletion(targetConfig) });
|
|
708
|
+
loadmillCloudAdapter.releaseActiveSession();
|
|
709
|
+
loadmillCloudStatusLine.stop();
|
|
710
|
+
loadmillCloudSessionConnected = false;
|
|
711
|
+
console.log("Loadmill Cloud session ended.");
|
|
712
|
+
if (succeeded) {
|
|
713
|
+
console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
508
716
|
}
|
|
509
717
|
if (exitCode !== null) {
|
|
510
718
|
process.exit(exitCode);
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
-
|
|
3
|
-
* Heroku-style volatile status line for long-running cloud-provider steps.
|
|
4
|
-
* On a TTY each progress update overwrites the previous one (spinner + step
|
|
5
|
-
* name + elapsed + latest detail) and the whole line is cleared when the step
|
|
6
|
-
* finishes. On a non-TTY (CI, piped output) it degrades to plain log lines,
|
|
7
|
-
* printed only when the detail changes. Feed it cloud progress events via
|
|
8
|
-
* handleEvent: "phase" events time named sub-steps and "note" events keep
|
|
9
|
-
* persistent info; both are printed (prefixed with `indent`) when the step
|
|
10
|
-
* finishes.
|
|
11
|
-
*/
|
|
2
|
+
const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
12
3
|
export function createStatusLine({ stream = process.stderr, indent = "" } = {}) {
|
|
13
4
|
const tty = stream.isTTY;
|
|
14
5
|
let active = false;
|
|
@@ -18,7 +9,6 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
|
|
|
18
9
|
let detail = "";
|
|
19
10
|
let startedAt = 0;
|
|
20
11
|
let lastPrintedDetail = "";
|
|
21
|
-
let entries = [];
|
|
22
12
|
let currentPhase = null;
|
|
23
13
|
const render = () => {
|
|
24
14
|
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
@@ -26,19 +16,45 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
|
|
|
26
16
|
const line = `${SPINNER_FRAMES[frame]} ${stepName} (${seconds}s)${detail ? ` — ${detail}` : ""}`;
|
|
27
17
|
stream.write(`\r\x1b[2K${line}`);
|
|
28
18
|
};
|
|
19
|
+
const heartbeat = () => {
|
|
20
|
+
const seconds = Math.floor((Date.now() - startedAt) / 1000);
|
|
21
|
+
console.log(`… ${stepName}: still waiting (${seconds}s)${detail ? ` — ${detail}` : ""}`);
|
|
22
|
+
};
|
|
23
|
+
const printPersistent = (text) => {
|
|
24
|
+
if (tty) {
|
|
25
|
+
stream.write(`\r\x1b[2K${text}\n`);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
console.log(text);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
29
31
|
const closePhase = () => {
|
|
30
32
|
if (currentPhase) {
|
|
31
|
-
currentPhase.
|
|
33
|
+
if (currentPhase.closeText != null) {
|
|
34
|
+
printPersistent(`${indent}\x1b[32m✔\x1b[0m ${currentPhase.closeText}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const ms = Date.now() - currentPhase.startedAt;
|
|
38
|
+
printPersistent(`${indent}\x1b[32m✔\x1b[0m ${currentPhase.name} \x1b[90m(${ms}ms)\x1b[0m`);
|
|
39
|
+
}
|
|
32
40
|
currentPhase = null;
|
|
33
41
|
}
|
|
34
42
|
};
|
|
35
43
|
return {
|
|
44
|
+
rename(name) {
|
|
45
|
+
if (!active) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
stepName = name;
|
|
49
|
+
if (tty) {
|
|
50
|
+
render();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
36
53
|
start(name) {
|
|
37
54
|
active = true;
|
|
38
55
|
stepName = name;
|
|
39
56
|
detail = "";
|
|
40
57
|
lastPrintedDetail = "";
|
|
41
|
-
entries = [];
|
|
42
58
|
currentPhase = null;
|
|
43
59
|
startedAt = Date.now();
|
|
44
60
|
if (tty) {
|
|
@@ -46,6 +62,12 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
|
|
|
46
62
|
// on its own row before this step starts drawing.
|
|
47
63
|
timer = setInterval(render, 120);
|
|
48
64
|
}
|
|
65
|
+
else {
|
|
66
|
+
timer = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS);
|
|
67
|
+
if (typeof timer.unref === "function") {
|
|
68
|
+
timer.unref();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
49
71
|
},
|
|
50
72
|
update(message) {
|
|
51
73
|
if (!active) {
|
|
@@ -66,11 +88,30 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
|
|
|
66
88
|
}
|
|
67
89
|
if (event.kind === "phase") {
|
|
68
90
|
closePhase();
|
|
69
|
-
currentPhase = {
|
|
70
|
-
entries.push(currentPhase);
|
|
91
|
+
currentPhase = { name: event.message, startedAt: Date.now(), closeText: null };
|
|
71
92
|
}
|
|
72
93
|
else if (event.kind === "note") {
|
|
73
|
-
|
|
94
|
+
printPersistent(`${indent}\x1b[34mℹ\x1b[0m ${event.message}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
else if (event.kind === "phase-close-text") {
|
|
98
|
+
if (currentPhase) {
|
|
99
|
+
currentPhase.closeText = event.message;
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
else if (event.kind === "phase-flush") {
|
|
104
|
+
closePhase();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
else if (event.kind === "print") {
|
|
108
|
+
// Closes any still-open phase first (flushing its "✔ ..." line)
|
|
109
|
+
// before printing the caller-supplied text verbatim, so a final
|
|
110
|
+
// summary can never race ahead of / glue onto the last phase's
|
|
111
|
+
// close line or the live spinner.
|
|
112
|
+
closePhase();
|
|
113
|
+
printPersistent(event.message);
|
|
114
|
+
return;
|
|
74
115
|
}
|
|
75
116
|
this.update(event.message);
|
|
76
117
|
},
|
|
@@ -80,19 +121,10 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
|
|
|
80
121
|
clearInterval(timer);
|
|
81
122
|
timer = null;
|
|
82
123
|
}
|
|
124
|
+
closePhase();
|
|
83
125
|
if (tty) {
|
|
84
126
|
stream.write("\r\x1b[2K");
|
|
85
127
|
}
|
|
86
|
-
closePhase();
|
|
87
|
-
for (const entry of entries) {
|
|
88
|
-
if (entry.kind === "phase") {
|
|
89
|
-
stream.write(`${indent}\x1b[32m✔\x1b[0m ${entry.name} \x1b[90m(${entry.ms}ms)\x1b[0m\n`);
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
stream.write(`${indent}\x1b[34mℹ\x1b[0m ${entry.text}\n`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
entries = [];
|
|
96
128
|
}
|
|
97
129
|
};
|
|
98
130
|
}
|
|
@@ -129,6 +129,7 @@ async function loadConfigFromFile(configPath) {
|
|
|
129
129
|
llmProviderMode: undefined,
|
|
130
130
|
cuaModel: undefined,
|
|
131
131
|
aimAssistMode: undefined,
|
|
132
|
+
explainMode: undefined,
|
|
132
133
|
promptCustomizations: createEmptyPromptCustomizations(),
|
|
133
134
|
appContextEnabled: undefined,
|
|
134
135
|
appContextPath: undefined,
|
|
@@ -144,6 +145,12 @@ async function loadConfigFromFile(configPath) {
|
|
|
144
145
|
if ("aimAssistMode" in rawConfig) {
|
|
145
146
|
normalized.aimAssistMode = validateAimAssistMode(rawConfig.aimAssistMode, "config.aimAssistMode");
|
|
146
147
|
}
|
|
148
|
+
if ("explainMode" in rawConfig) {
|
|
149
|
+
if (typeof rawConfig.explainMode !== "boolean") {
|
|
150
|
+
throw new Error("config.explainMode must be a boolean.");
|
|
151
|
+
}
|
|
152
|
+
normalized.explainMode = rawConfig.explainMode;
|
|
153
|
+
}
|
|
147
154
|
if ("promptCustomizations" in rawConfig) {
|
|
148
155
|
normalized.promptCustomizations = normalizePromptCustomizations(rawConfig.promptCustomizations, "config.promptCustomizations");
|
|
149
156
|
}
|
|
@@ -217,6 +224,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
217
224
|
llmProviderMode: fileConfig?.llmProviderMode || readEnvDefaultLlmProviderMode(),
|
|
218
225
|
cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
|
|
219
226
|
aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
|
|
227
|
+
explainMode: fileConfig?.explainMode ?? false,
|
|
220
228
|
promptCustomizations,
|
|
221
229
|
appContextEnabled: fileConfig?.appContextEnabled ?? true,
|
|
222
230
|
appContextPath: fileConfig?.appContextPath || null,
|
|
@@ -235,6 +243,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
235
243
|
if (typeof args["aim-assist"] === "string") {
|
|
236
244
|
resolved.aimAssistMode = validateAimAssistMode(args["aim-assist"], "--aim-assist");
|
|
237
245
|
}
|
|
246
|
+
if (args.explain === true) {
|
|
247
|
+
resolved.explainMode = true;
|
|
248
|
+
}
|
|
238
249
|
if (hasContextOptimizationThresholdFlag) {
|
|
239
250
|
resolved.contextOptimizationEnabled = true;
|
|
240
251
|
resolved.contextOptimizationThreshold = parseContextOptimizationThreshold(args["context-optimization-threshold"], "--context-optimization-threshold");
|