@loadmill/droid-cua 2.25.0 → 2.26.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.
@@ -18,10 +18,13 @@ const MODEL_PRICING = Object.freeze({
18
18
  "gpt-5.4": Object.freeze({
19
19
  ...OPENAI_GPT_5_4_PRICING,
20
20
  }),
21
- // Loadmill Smart currently routes to GPT-5.4 behind the Loadmill LLM gateway.
21
+ // Loadmill Smart currently routes to GPT-5.6 Terra behind the Loadmill LLM gateway.
22
22
  // Keep this explicit so pricing can be updated here if the backing model changes.
23
23
  "loadmill-smart": Object.freeze({
24
- ...OPENAI_GPT_5_4_PRICING,
24
+ ...OPENAI_GPT_5_6_TERRA_PRICING,
25
+ }),
26
+ "loadmill-pulse": Object.freeze({
27
+ ...OPENAI_GPT_5_6_LUNA_PRICING,
25
28
  }),
26
29
  });
27
30
  function finiteNumber(value) {
@@ -50,6 +53,9 @@ export function normalizeDroidCuaCostModel(model) {
50
53
  if (model === "loadmill-smart" || model === "smart") {
51
54
  return "loadmill-smart";
52
55
  }
56
+ if (model === "loadmill-pulse" || model === "pulse") {
57
+ return "loadmill-pulse";
58
+ }
53
59
  return null;
54
60
  }
55
61
  export function createDroidCuaRunUsageTotals({ model = null } = {}) {
@@ -251,6 +251,7 @@ export class ExecutionEngine {
251
251
  this.navigateToUrl = options.navigateToUrl || navigateBrowserToUrl;
252
252
  this.closeCurrentTab = options.closeCurrentTab || closeCurrentBrowserTab;
253
253
  this.executeLoadmillInstruction = options.executeLoadmillInstruction || executeLoadmillInstruction;
254
+ this.connectToDevice = options.connectToDevice || null;
254
255
  this.toolCapabilities = options.toolCapabilities || {};
255
256
  this.secretsByKey = options.secretsByKey && typeof options.secretsByKey === "object" ? options.secretsByKey : {};
256
257
  this.baseSystemPrompt = options.baseSystemPrompt || session?.systemPrompt || null;
@@ -7,6 +7,7 @@ export const LOADMILL_MODEL_ALIASES = Object.freeze({
7
7
  DEFAULT_AGENT_MODEL: "default-agent-model",
8
8
  FAST: "fast",
9
9
  SMART: "smart",
10
+ PULSE: "pulse",
10
11
  BEACON: "beacon",
11
12
  });
12
13
  const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
@@ -90,9 +91,13 @@ export function inferLlmProviderModeFromCredentials(credentials = {}) {
90
91
  }
91
92
  export function resolveGatewayCuaModelAlias({ cuaModel } = {}) {
92
93
  const normalizedCuaModel = resolveCuaModel(cuaModel);
93
- return normalizedCuaModel === "loadmill-beacon"
94
- ? LOADMILL_MODEL_ALIASES.BEACON
95
- : LOADMILL_MODEL_ALIASES.SMART;
94
+ if (normalizedCuaModel === "loadmill-pulse") {
95
+ return LOADMILL_MODEL_ALIASES.PULSE;
96
+ }
97
+ if (normalizedCuaModel === "loadmill-beacon") {
98
+ return LOADMILL_MODEL_ALIASES.BEACON;
99
+ }
100
+ return LOADMILL_MODEL_ALIASES.SMART;
96
101
  }
97
102
  export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaModel = null, } = {}) {
98
103
  const providerMode = resolveLlmProviderMode(llmProviderMode);
@@ -7,7 +7,7 @@ import { reportCloudProgress } from "../progress.js";
7
7
  export const DEFAULT_UPLOAD_CACHE_PATH = path.join(os.tmpdir(), "droid-cua", "aws-device-farm-upload-cache.json");
8
8
  // Stream the binary through the hash rather than buffering the whole .apk/.ipa
9
9
  // (tens–hundreds of MB) in one allocation just to checksum it.
10
- async function hashFile(filePath) {
10
+ export async function hashFile(filePath) {
11
11
  const hash = createHash("sha256");
12
12
  for await (const chunk of createReadStream(filePath)) {
13
13
  hash.update(chunk);
@@ -4,7 +4,7 @@ import { CloudAppiumClient } from "./cloud-appium-client.js";
4
4
  import { reportCloudPhase } from "./progress.js";
5
5
  let activeSession = null;
6
6
  function buildCloudCapabilities(options) {
7
- const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName } = options;
7
+ const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName, cloudSessionId, artifacts } = options;
8
8
  const adapter = getCloudProviderAdapter(providerId);
9
9
  if (!adapter) {
10
10
  throw new Error(`Cloud provider "${providerId}" is not registered.`);
@@ -20,7 +20,9 @@ function buildCloudCapabilities(options) {
20
20
  accessKey: credentials?.accessKey,
21
21
  projectName: "droid-cua",
22
22
  buildName: buildName || `desktop-${new Date().toISOString().slice(0, 10)}`,
23
- sessionName: sessionName || `${deviceName} (${platform})`
23
+ sessionName: sessionName || `${deviceName} (${platform})`,
24
+ cloudSessionId,
25
+ artifacts
24
26
  }
25
27
  };
26
28
  }
@@ -29,11 +31,19 @@ export async function connectToDevice(options, logOptions = {}) {
29
31
  if (!options || typeof options !== "object") {
30
32
  throw new Error("Cloud device connection requires a provider payload.");
31
33
  }
32
- const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName } = options;
33
- if (!providerId || !platform || !deviceName || !osVersion || !appRef) {
34
+ const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName, cloudSessionId, artifacts } = options;
35
+ const isReconnect = providerId === "loadmill-cloud" && Boolean(cloudSessionId);
36
+ if (!providerId ||
37
+ (!isReconnect && (!platform || !deviceName || !osVersion)) ||
38
+ (!appRef && !isReconnect)) {
34
39
  throw new Error("Cloud device connection is missing required session details.");
35
40
  }
36
- if (!credentials?.username || !credentials?.accessKey) {
41
+ if (providerId === "loadmill-cloud") {
42
+ if (!credentials?.bearerToken) {
43
+ throw new Error("Loadmill Cloud connection is missing a Loadmill API token.");
44
+ }
45
+ }
46
+ else if (!credentials?.username || !credentials?.accessKey) {
37
47
  throw new Error("Cloud device connection is missing provider credentials.");
38
48
  }
39
49
  const { adapter, capabilities } = buildCloudCapabilities({
@@ -44,7 +54,9 @@ export async function connectToDevice(options, logOptions = {}) {
44
54
  appRef,
45
55
  credentials,
46
56
  buildName,
47
- sessionName
57
+ sessionName,
58
+ cloudSessionId,
59
+ artifacts
48
60
  });
49
61
  // Optional AbortSignal (Stop/Pause during provisioning). It rides inside the
50
62
  // cloud-options object — see the facade in src/device/connection.js — so no
@@ -0,0 +1,537 @@
1
+ import path from "node:path";
2
+ import { hashFile } from "../aws-device-farm/upload-cache.js";
3
+ import { reportCloudNote, reportCloudPhase, reportCloudPhaseCloseText, reportCloudPhaseFlush, reportCloudPrint, reportCloudProgress } from "../progress.js";
4
+ import { LoadmillCloudControlPlaneClient } from "./control-plane-client.js";
5
+ import { downloadArtifacts } from "./artifact-downloader.js";
6
+ import { formatBytes } from "./format-bytes.js";
7
+ import { timingLog } from "./timing.js";
8
+ const RESUMABLE_STATUSES = new Set(["PROVISIONED", "CONNECTED", "RUNNING"]);
9
+ const TERMINAL_STATUSES = new Set(["COMPLETED", "CANCELLED", "ERROR"]);
10
+ // Artifacts are readable from DF while a run is still tearing down
11
+ // (STOPPING), not just once it reaches a true terminal state. Mirrors the
12
+ // server's ARTIFACT_READY_STATES.
13
+ const ARTIFACT_READY_STATUSES = new Set(["STOPPING", ...TERMINAL_STATUSES]);
14
+ const SLOT_HELD_STATUSES = new Set(["PREPARING", "PROVISIONED", "CONNECTED", "RUNNING"]);
15
+ const ARTIFACT_POLL_BUDGET_MS = 3 * 60_000;
16
+ const ARTIFACT_POLL_INTERVAL_MS = 5_000;
17
+ const TEARDOWN_STOP_BUDGET_MS = 1_500;
18
+ function formatSecondsOneDecimal(ms) {
19
+ return (Math.max(0, ms) / 1000).toFixed(1);
20
+ }
21
+ const DEVICE_CACHE_TTL_MS = 5 * 60 * 1000;
22
+ const deviceCacheByKey = new Map();
23
+ function deviceCacheKey(creds) {
24
+ return `${creds?.baseUrl || ""}::${creds?.bearerToken || ""}`;
25
+ }
26
+ let activeRun = null;
27
+ function makeClient(creds) {
28
+ return new LoadmillCloudControlPlaneClient({ bearerToken: creds.bearerToken, baseUrl: creds.baseUrl });
29
+ }
30
+ function mapDevice(device) {
31
+ return {
32
+ id: `${device.platform}:${device.deviceName}:${device.osVersion}`,
33
+ name: device.deviceName,
34
+ deviceName: device.deviceName,
35
+ platform: device.platform,
36
+ osVersion: device.osVersion,
37
+ };
38
+ }
39
+ async function timedStopRun(client, sessionId, { signal } = {}) {
40
+ timingLog(`stopRun invoked: session=${sessionId}`);
41
+ try {
42
+ const result = await client.stopRun(sessionId, { signal });
43
+ timingLog(`stopRun resolved: session=${sessionId}`);
44
+ return result;
45
+ }
46
+ catch (error) {
47
+ timingLog(`stopRun resolved (error): session=${sessionId} error=${error.message}`);
48
+ throw error;
49
+ }
50
+ }
51
+ function abortableSleep(ms, signal) {
52
+ if (signal?.aborted) {
53
+ return Promise.reject(signal.reason ?? new Error("Provisioning cancelled."));
54
+ }
55
+ return new Promise((resolve, reject) => {
56
+ const onAbort = () => {
57
+ clearTimeout(timer);
58
+ reject(signal.reason ?? new Error("Provisioning cancelled."));
59
+ };
60
+ const timer = setTimeout(() => {
61
+ signal?.removeEventListener("abort", onAbort);
62
+ resolve();
63
+ }, ms);
64
+ signal?.addEventListener("abort", onAbort, { once: true });
65
+ });
66
+ }
67
+ function formatQueueMessage(state) {
68
+ const parts = [];
69
+ if (typeof state.queuePosition === "number") {
70
+ parts.push(`queue position ${state.queuePosition}`);
71
+ }
72
+ if (typeof state.etaSeconds === "number") {
73
+ parts.push(`up to ${Math.max(1, Math.round(state.etaSeconds / 60))}m`);
74
+ }
75
+ return parts.length > 0 ? parts.join(", ") : "waiting for a device slot";
76
+ }
77
+ async function waitForProvisioned(client, sessionId, { signal, onSlotHeld } = {}) {
78
+ const watchAbort = new AbortController();
79
+ let rejectOuter = null;
80
+ const forwardAbort = () => {
81
+ watchAbort.abort(signal.reason);
82
+ rejectOuter?.(signal.reason ?? new Error("Provisioning cancelled."));
83
+ };
84
+ signal?.addEventListener("abort", forwardAbort, { once: true });
85
+ let lastStatus = null;
86
+ let webdriverEndpoint = null;
87
+ let slotHeldReported = false;
88
+ try {
89
+ await new Promise((resolve, reject) => {
90
+ rejectOuter = reject;
91
+ client
92
+ .watchRun(sessionId, (state) => {
93
+ if (!state || typeof state !== "object") {
94
+ return;
95
+ }
96
+ if (!slotHeldReported && SLOT_HELD_STATUSES.has(state.status)) {
97
+ slotHeldReported = true;
98
+ onSlotHeld?.();
99
+ }
100
+ if (state.status && state.status !== lastStatus) {
101
+ lastStatus = state.status;
102
+ reportCloudPhase(state.status.toLowerCase());
103
+ }
104
+ if (state.status === "PENDING") {
105
+ reportCloudProgress(formatQueueMessage(state));
106
+ }
107
+ if (state.status === "PROVISIONED" && state.webdriverEndpoint) {
108
+ webdriverEndpoint = state.webdriverEndpoint;
109
+ watchAbort.abort();
110
+ resolve();
111
+ return;
112
+ }
113
+ if (state.status === "ERROR") {
114
+ const message = state.error?.message || "Loadmill Cloud run failed while provisioning.";
115
+ const error = new Error(message);
116
+ error.code = state.error?.code;
117
+ watchAbort.abort();
118
+ reject(error);
119
+ return;
120
+ }
121
+ if (state.status === "CANCELLED") {
122
+ const error = new Error("Loadmill Cloud run was cancelled.");
123
+ watchAbort.abort();
124
+ reject(error);
125
+ }
126
+ }, { signal: watchAbort.signal })
127
+ .catch(() => { });
128
+ if (signal?.aborted) {
129
+ watchAbort.abort();
130
+ reject(signal.reason ?? new Error("Provisioning cancelled."));
131
+ }
132
+ });
133
+ }
134
+ finally {
135
+ signal?.removeEventListener("abort", forwardAbort);
136
+ }
137
+ return webdriverEndpoint;
138
+ }
139
+ // Number of consecutive polls with an unchanged (but still incomplete)
140
+ // artifact list, with at least one requested kind already present, before we
141
+ // give up early on the remaining kinds instead of burning the full budget.
142
+ // Artifacts appear incrementally while the run is still STOPPING (unlike the
143
+ // old terminal-only flow), so this is generous (~30s) to avoid a premature
144
+ // partial exit just because one kind hasn't shown up yet.
145
+ const UNCHANGED_POLL_EARLY_EXIT_LIMIT = 6;
146
+ function artifactSignature(artifacts) {
147
+ return artifacts
148
+ .map((artifact) => `${artifact.kind}:${artifact.presignedGetUrl}`)
149
+ .sort()
150
+ .join("|");
151
+ }
152
+ // Retries on the server's 409 artifacts_not_ready envelope, and while any
153
+ // requested kind is still missing from the response (DF's video upload lags
154
+ // completion). Returns whatever is present once the budget is exhausted, or
155
+ // early once the artifact list stops changing for UNCHANGED_POLL_EARLY_EXIT_LIMIT
156
+ // consecutive polls while at least one requested kind is already available —
157
+ // this avoids waiting out the full budget for a kind that will never appear
158
+ // (e.g. a mismatched/unmapped artifact type on the server side).
159
+ async function pollForArtifacts(client, sessionId, kinds, { budgetMs, intervalMs = ARTIFACT_POLL_INTERVAL_MS, unchangedPollLimit = UNCHANGED_POLL_EARLY_EXIT_LIMIT } = {}) {
160
+ const startedAt = Date.now();
161
+ const deadline = startedAt + budgetMs;
162
+ let lastArtifacts = [];
163
+ let lastSignature = null;
164
+ let unchangedCount = 0;
165
+ let pollNumber = 0;
166
+ for (;;) {
167
+ pollNumber += 1;
168
+ reportCloudProgress(`artifacts are being prepared…`);
169
+ try {
170
+ const result = await client.getArtifacts(sessionId);
171
+ lastArtifacts = Array.isArray(result?.artifacts) ? result.artifacts : [];
172
+ const presentKinds = new Set(lastArtifacts.map((artifact) => artifact.kind));
173
+ const missingKinds = kinds.filter((kind) => !presentKinds.has(kind));
174
+ timingLog(`poll #${pollNumber}: kinds=[${[...presentKinds].join(",")}] missing=[${missingKinds.join(",")}]`);
175
+ if (missingKinds.length === 0) {
176
+ return { artifacts: lastArtifacts, missingKinds: [], pollCount: pollNumber, elapsedMs: Date.now() - startedAt };
177
+ }
178
+ const signature = artifactSignature(lastArtifacts);
179
+ if (presentKinds.size > 0 && signature === lastSignature) {
180
+ unchangedCount += 1;
181
+ }
182
+ else {
183
+ unchangedCount = presentKinds.size > 0 ? 1 : 0;
184
+ }
185
+ lastSignature = signature;
186
+ if (presentKinds.size > 0 && unchangedCount >= unchangedPollLimit) {
187
+ return { artifacts: lastArtifacts, missingKinds, pollCount: pollNumber, elapsedMs: Date.now() - startedAt };
188
+ }
189
+ }
190
+ catch (error) {
191
+ const retryable = (error?.status === 409 && error?.code === "artifacts_not_ready") ||
192
+ (error?.status === 502 && error?.code === "mobile_testing_artifacts_failed");
193
+ if (retryable) {
194
+ timingLog(error.status === 409
195
+ ? `poll #${pollNumber}: 409 artifacts_not_ready`
196
+ : `poll #${pollNumber}: 502 mobile_testing_artifacts_failed`);
197
+ }
198
+ else {
199
+ throw error;
200
+ }
201
+ }
202
+ if (Date.now() >= deadline) {
203
+ const presentKinds = new Set(lastArtifacts.map((artifact) => artifact.kind));
204
+ return {
205
+ artifacts: lastArtifacts,
206
+ missingKinds: kinds.filter((kind) => !presentKinds.has(kind)),
207
+ pollCount: pollNumber,
208
+ elapsedMs: Date.now() - startedAt,
209
+ };
210
+ }
211
+ await abortableSleep(intervalMs).catch(() => { });
212
+ }
213
+ }
214
+ function formatDownloadDetail(fileName, bytesWritten, totalBytes) {
215
+ if (typeof totalBytes === "number" && totalBytes > 0) {
216
+ const percent = Math.min(100, Math.round((bytesWritten / totalBytes) * 100));
217
+ return `downloading ${fileName} — ${formatBytes(bytesWritten)}/${formatBytes(totalBytes)} (${percent}%)`;
218
+ }
219
+ return `downloading ${fileName} — ${formatBytes(bytesWritten)}`;
220
+ }
221
+ async function fetchAndDownloadArtifacts({ client, sessionId, kinds }) {
222
+ reportCloudPhase("artifact list");
223
+ const { artifacts, missingKinds, elapsedMs } = await pollForArtifacts(client, sessionId, kinds, {
224
+ budgetMs: ARTIFACT_POLL_BUDGET_MS,
225
+ });
226
+ reportCloudPhaseCloseText(`artifacts ready (${formatSecondsOneDecimal(elapsedMs)}s)`);
227
+ // Flush the "artifact list" phase's "✔ artifacts ready (...)" line now,
228
+ // instead of leaving it to lazily flush whenever the first download's
229
+ // reportCloudPhase() call happens to fire — otherwise the note below
230
+ // (which prints immediately, unlike a phase close) would race ahead of it.
231
+ reportCloudPhaseFlush();
232
+ // Only from this point on is "fetching"/"downloading" language accurate —
233
+ // everything before here was still waiting on the provider to prepare them.
234
+ reportCloudNote("Fetching artifacts");
235
+ const destDir = path.resolve(process.cwd(), "droid-cua-artifacts", sessionId);
236
+ const summary = await downloadArtifacts({
237
+ artifacts,
238
+ kinds,
239
+ destDir,
240
+ onNote: (message) => reportCloudNote(message),
241
+ onFileStart: (kind, fileName, totalBytes) => {
242
+ reportCloudPhase(`download ${fileName}`);
243
+ reportCloudProgress(formatDownloadDetail(fileName, 0, totalBytes));
244
+ },
245
+ onProgress: (kind, fileName, bytesWritten, totalBytes) => {
246
+ reportCloudProgress(formatDownloadDetail(fileName, bytesWritten, totalBytes));
247
+ },
248
+ onFileDone: (kind, fileName, bytesWritten, elapsedFileMs) => {
249
+ reportCloudPhaseCloseText(`${fileName} — ${formatBytes(bytesWritten)} (${formatSecondsOneDecimal(elapsedFileMs)}s)`);
250
+ },
251
+ });
252
+ if (summary.length > 0) {
253
+ // reportCloudPrint (not console.log) so this always closes the last
254
+ // file's still-open phase first — otherwise its "✔ <file> — ..." line
255
+ // can race behind / glue onto this summary or the live spinner.
256
+ reportCloudPrint(`Artifacts saved to ${destDir}`);
257
+ }
258
+ if (missingKinds.length > 0) {
259
+ reportCloudNote(`WARNING: artifacts not ready for: ${missingKinds.join(", ")}. Retry later with: droid-cua --cloud-session-id ${sessionId} --artifacts ${kinds.join(",")}`);
260
+ }
261
+ return { downloadedKinds: summary.map((item) => item.kind), missingKinds };
262
+ }
263
+ async function uploadAppIfNeeded(client, sessionId, { app, platform, signal }) {
264
+ const fileName = path.basename(app);
265
+ const sha256 = await hashFile(app);
266
+ reportCloudPhase("register app");
267
+ const registration = await client.registerApp(sessionId, { sha256, platform, fileName });
268
+ if (registration.presignedPutUrl) {
269
+ reportCloudPhase("upload app");
270
+ reportCloudProgress(`uploading ${fileName}...`);
271
+ await client.uploadBinary(registration.presignedPutUrl, app, { signal });
272
+ }
273
+ else {
274
+ reportCloudProgress("upload cache hit — reusing existing app upload");
275
+ }
276
+ return registration.uploadRef;
277
+ }
278
+ /** @type {import("../adapter").CloudProviderAdapter} */
279
+ export const loadmillCloudAdapter = {
280
+ id: "loadmill-cloud",
281
+ displayName: "Loadmill Cloud",
282
+ async validateCredentials(creds) {
283
+ try {
284
+ await makeClient(creds).getDevices();
285
+ return { ok: true, message: "Loadmill Cloud credentials are valid." };
286
+ }
287
+ catch (error) {
288
+ return {
289
+ ok: false,
290
+ message: error instanceof Error ? error.message : "Failed to validate Loadmill Cloud credentials.",
291
+ };
292
+ }
293
+ },
294
+ async getAccountInfo(creds) {
295
+ const { meters = [] } = await makeClient(creds).getUsage();
296
+ return {
297
+ plan: "Metered",
298
+ summary: meters
299
+ .map((meter) => {
300
+ const minutes = meter?.minutes ?? {};
301
+ return `${meter.period}: ${minutes.used}/${minutes.limit} used (${minutes.left} left)`;
302
+ })
303
+ .join("; "),
304
+ meters,
305
+ };
306
+ },
307
+ async getAvailableDevices(creds) {
308
+ const key = deviceCacheKey(creds);
309
+ const cached = deviceCacheByKey.get(key);
310
+ if (cached && Date.now() - cached.at < DEVICE_CACHE_TTL_MS) {
311
+ return cached.devices;
312
+ }
313
+ const { devices = [] } = await makeClient(creds).getDevices();
314
+ const mapped = devices.map(mapDevice);
315
+ deviceCacheByKey.set(key, { at: Date.now(), devices: mapped });
316
+ return mapped;
317
+ },
318
+ async uploadApp(creds, localPath) {
319
+ if (!activeRun) {
320
+ throw new Error("Loadmill Cloud uploadApp requires an active session — call prepareSession first (the dedup/hash decision is server-side and scoped to a run).");
321
+ }
322
+ const client = activeRun.client;
323
+ const uploadRef = await uploadAppIfNeeded(client, activeRun.sessionId, {
324
+ app: localPath,
325
+ platform: activeRun.platform,
326
+ });
327
+ return { id: uploadRef, remotePath: uploadRef };
328
+ },
329
+ async getAppStatus() {
330
+ throw new Error("Loadmill Cloud does not expose a client-side app status check — the server owns the upload cache.");
331
+ },
332
+ async deleteApp() {
333
+ throw new Error("Loadmill Cloud does not support client-initiated app deletion — the server owns the upload cache.");
334
+ },
335
+ async prepareSession(creds, opts, { signal } = {}) {
336
+ if (signal?.aborted) {
337
+ throw signal.reason ?? new Error("Provisioning cancelled.");
338
+ }
339
+ const client = makeClient(creds);
340
+ const { platform, deviceName, osVersion, app, cloudSessionId, artifacts } = opts;
341
+ if (cloudSessionId) {
342
+ // Note: no "reconnect" phase here yet — a pure artifacts-only fetch
343
+ // (below) never actually reconnects to anything, so that phase is
344
+ // deferred until we know we're doing a genuine live reattach.
345
+ const state = await client.getRunState(cloudSessionId);
346
+ if (ARTIFACT_READY_STATUSES.has(state.status) && artifacts && artifacts.length > 0) {
347
+ if (state.status === "STOPPING") {
348
+ reportCloudNote(`Test run is finishing up on the cloud device; artifacts will be available once it completes.`);
349
+ }
350
+ else {
351
+ reportCloudNote(`Loadmill Cloud session has completed`);
352
+ }
353
+ await fetchAndDownloadArtifacts({ client, sessionId: cloudSessionId, kinds: artifacts });
354
+ const exitError = new Error(`Loadmill Cloud artifact fetch complete.`);
355
+ exitError.artifactsOnlyExit = true;
356
+ throw exitError;
357
+ }
358
+ if (TERMINAL_STATUSES.has(state.status)) {
359
+ throw new Error(`Loadmill Cloud session ${cloudSessionId} is already terminal (${state.status}). Fetch its recordings/logs instead with: droid-cua --cloud-session-id ${cloudSessionId} --artifacts all`);
360
+ }
361
+ if (!RESUMABLE_STATUSES.has(state.status) || !state.webdriverEndpoint) {
362
+ throw new Error(`Loadmill Cloud session ${cloudSessionId} is not yet ready to reconnect (status: ${state.status}).`);
363
+ }
364
+ const resolvedPlatform = platform;
365
+ if (!resolvedPlatform) {
366
+ throw new Error(`--platform is required to reconnect to Loadmill Cloud session ${cloudSessionId}; pass --platform android|ios.`);
367
+ }
368
+ reportCloudPhase("reconnect");
369
+ // Server forbids PROVISIONED->RUNNING directly, so report CONNECTED now
370
+ // to make the first getHubUrl()'s RUNNING transition legal on attach.
371
+ if (state.status === "PROVISIONED") {
372
+ await client.reportStatus(cloudSessionId, "CONNECTED");
373
+ }
374
+ activeRun = {
375
+ client,
376
+ sessionId: cloudSessionId,
377
+ webdriverEndpoint: state.webdriverEndpoint,
378
+ // Server rejects RUNNING->RUNNING; skip getHubUrl's report if already RUNNING.
379
+ hubUrlServedOnce: state.status === "RUNNING",
380
+ platform: resolvedPlatform,
381
+ deviceName: deviceName || state.deviceName,
382
+ slotStartedAt: null,
383
+ attachedAt: Date.now(),
384
+ artifacts: artifacts || null,
385
+ };
386
+ client.startHeartbeat(cloudSessionId);
387
+ return;
388
+ }
389
+ reportCloudPhase("init run");
390
+ const initState = await client.initRun({ platform, deviceName, osVersion });
391
+ const sessionId = initState.sessionId;
392
+ reportCloudNote(`Loadmill Cloud session ${sessionId}`);
393
+ // Server reaper kills runs that only heartbeat after CONNECTED, so start now.
394
+ client.startHeartbeat(sessionId);
395
+ activeRun = {
396
+ client,
397
+ sessionId,
398
+ webdriverEndpoint: null,
399
+ hubUrlServedOnce: false,
400
+ platform,
401
+ deviceName,
402
+ slotStartedAt: null,
403
+ attachedAt: Date.now(),
404
+ artifacts: artifacts || null,
405
+ };
406
+ let abortHandled = false;
407
+ const onAbort = async () => {
408
+ abortHandled = true;
409
+ try {
410
+ await timedStopRun(client, sessionId);
411
+ }
412
+ catch {
413
+ // ignore
414
+ }
415
+ finally {
416
+ client.stopHeartbeat();
417
+ }
418
+ };
419
+ try {
420
+ if (signal?.aborted) {
421
+ await onAbort();
422
+ throw signal.reason ?? new Error("Provisioning cancelled.");
423
+ }
424
+ signal?.addEventListener("abort", onAbort, { once: true });
425
+ await uploadAppIfNeeded(client, sessionId, { app, platform, signal });
426
+ const webdriverEndpoint = await waitForProvisioned(client, sessionId, {
427
+ signal,
428
+ onSlotHeld: () => {
429
+ if (activeRun && activeRun.sessionId === sessionId && activeRun.slotStartedAt == null) {
430
+ activeRun.slotStartedAt = Date.now();
431
+ }
432
+ },
433
+ });
434
+ activeRun.webdriverEndpoint = webdriverEndpoint;
435
+ reportCloudPhase("report status: CONNECTED");
436
+ await client.reportStatus(sessionId, "CONNECTED");
437
+ }
438
+ catch (error) {
439
+ client.stopHeartbeat();
440
+ activeRun = null;
441
+ if (!abortHandled) {
442
+ await timedStopRun(client, sessionId).catch(() => { });
443
+ }
444
+ throw error;
445
+ }
446
+ finally {
447
+ signal?.removeEventListener("abort", onAbort);
448
+ }
449
+ },
450
+ buildCapabilities(opts) {
451
+ const platform = opts.platform || activeRun?.platform;
452
+ if (!platform) {
453
+ throw new Error("Loadmill Cloud cannot determine the target platform for WebDriver capabilities; pass --platform android|ios.");
454
+ }
455
+ return {
456
+ platformName: platform,
457
+ "appium:automationName": platform === "ios" ? "XCUITest" : "UiAutomator2",
458
+ "appium:newCommandTimeout": 240,
459
+ };
460
+ },
461
+ getHubUrl() {
462
+ if (!activeRun?.webdriverEndpoint) {
463
+ throw new Error("Loadmill Cloud session has no webdriver endpoint yet.");
464
+ }
465
+ if (!activeRun.hubUrlServedOnce) {
466
+ activeRun.hubUrlServedOnce = true;
467
+ activeRun.client.reportStatus(activeRun.sessionId, "RUNNING").catch((error) => {
468
+ console.error(`[loadmill-cloud] failed to report RUNNING status: ${error.message}`);
469
+ });
470
+ }
471
+ return activeRun.webdriverEndpoint;
472
+ },
473
+ getAuthHeader() {
474
+ // Presigned hub URL carries its own auth; no header needed.
475
+ return "";
476
+ },
477
+ async getSessionArtifacts() {
478
+ return {};
479
+ },
480
+ async setSessionStatus(creds, sessionId, status) {
481
+ if (!activeRun || activeRun.sessionId !== sessionId) {
482
+ return;
483
+ }
484
+ if (status === "failed" || status === "errored") {
485
+ await activeRun.client.reportStatus(sessionId, "ERROR", {
486
+ code: "client_reported_failure",
487
+ message: `Execution reported status: ${status}`,
488
+ });
489
+ }
490
+ },
491
+ async releaseSession(creds, { waitForCompletion = true } = {}) {
492
+ if (!activeRun) {
493
+ return;
494
+ }
495
+ const { client, sessionId, slotStartedAt, attachedAt, artifacts } = activeRun;
496
+ activeRun = null;
497
+ try {
498
+ reportCloudPhase("stop run");
499
+ if (!waitForCompletion) {
500
+ try {
501
+ await timedStopRun(client, sessionId, { signal: AbortSignal.timeout(TEARDOWN_STOP_BUDGET_MS) });
502
+ }
503
+ catch (error) {
504
+ reportCloudNote(`WARNING: stop request failed: ${error?.message ?? error}`);
505
+ }
506
+ reportCloudProgress("session stop requested");
507
+ return;
508
+ }
509
+ await timedStopRun(client, sessionId);
510
+ if (artifacts && artifacts.length > 0) {
511
+ try {
512
+ await fetchAndDownloadArtifacts({ client, sessionId, kinds: artifacts });
513
+ }
514
+ catch (error) {
515
+ reportCloudNote(`WARNING: artifact fetch failed: ${error.message}`);
516
+ }
517
+ }
518
+ }
519
+ finally {
520
+ client.stopHeartbeat();
521
+ }
522
+ },
523
+ releaseActiveSession() {
524
+ if (!activeRun) {
525
+ return Promise.resolve();
526
+ }
527
+ const { client, sessionId } = activeRun;
528
+ activeRun = null;
529
+ try {
530
+ client.stopHeartbeat();
531
+ }
532
+ catch {
533
+ // ignore
534
+ }
535
+ return timedStopRun(client, sessionId, { signal: AbortSignal.timeout(TEARDOWN_STOP_BUDGET_MS) }).catch(() => { });
536
+ },
537
+ };