@devicecloud.dev/dcd 5.8.0-beta.1 → 5.8.0-beta.3

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.
@@ -129,6 +129,11 @@ export declare const cloudCommand: import("citty").CommandDef<{
129
129
  readonly alias: ["maestroVersion"];
130
130
  readonly description: "Maestro version to run your flow against. Use \"latest\" for the most recent version. See https://docs.devicecloud.dev/configuration/maestro-versions for supported versions.";
131
131
  };
132
+ readonly priority: {
133
+ readonly type: "string";
134
+ readonly default: "normal";
135
+ readonly description: "Run priority within your organization (options: low, normal, high). Reorders only your own queued runs across your concurrency pool, for every flow in this upload; it never moves them ahead of other customers. Runs queued longer than 6 hours are promoted first, so low runs are never starved. Retries keep the same priority.";
136
+ };
132
137
  readonly retry: {
133
138
  readonly type: "string";
134
139
  readonly description: "Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge";
@@ -3,10 +3,11 @@ import { defineCommand } from 'citty';
3
3
  import { existsSync } from 'node:fs';
4
4
  import * as path from 'node:path';
5
5
  import { flags as allFlags } from '../constants.js';
6
- import { ApiError, ApiGateway } from '../gateways/api-gateway.js';
6
+ import { ApiGateway } from '../gateways/api-gateway.js';
7
7
  import { uploadBinary, uploadFlowZip, verifyAppZip, writeJSONFile, } from '../methods.js';
8
8
  import { DeviceValidationService } from '../services/device-validation.service.js';
9
9
  import { plan } from '../services/execution-plan.service.js';
10
+ import { isSubmitEndpointMissing, submitWithRetry, } from '../services/flow-submit.service.js';
10
11
  import { buildTestMetadataMap, computeCommonRoot, } from '../services/flow-paths.js';
11
12
  import { MoropoService } from '../services/moropo.service.js';
12
13
  import { ReportDownloadService } from '../services/report-download.service.js';
@@ -15,7 +16,7 @@ import { telemetry } from '../services/telemetry.service.js';
15
16
  import { TestSubmissionService } from '../services/test-submission.service.js';
16
17
  import { VersionService } from '../services/version.service.js';
17
18
  import { isIosMatrixConfig } from '../types/domain/device.types.js';
18
- import { resolveAuth } from '../utils/auth.js';
19
+ import { refreshAuth, resolveAuth } from '../utils/auth.js';
19
20
  import { assertMatrixSupported, matrixIsIos, parseDeviceMatrix, } from '../utils/device-matrix.js';
20
21
  import { checkKekForEncryption, resolveEncryption } from '../utils/envelope.js';
21
22
  import { detectCiContext, isCI } from '../utils/ci.js';
@@ -42,6 +43,7 @@ const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'];
42
43
  const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'];
43
44
  const ORIENTATION_OPTIONS = ['0', '90'];
44
45
  const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'gpu1', 'cpu1'];
46
+ const PRIORITY_OPTIONS = ['low', 'normal', 'high'];
45
47
  const RENDER_ENGINE_OPTIONS = ['lavapipe', 'swiftshader'];
46
48
  /**
47
49
  * Primary CLI command for executing tests on DeviceCloud.
@@ -162,6 +164,7 @@ export const cloudCommand = defineCommand({
162
164
  const moropoApiKey = args['moropo-v1-api-key'];
163
165
  const name = args.name;
164
166
  const orientation = validateEnum(args.orientation, ORIENTATION_OPTIONS, 'orientation');
167
+ const priority = validateEnum(args.priority, PRIORITY_OPTIONS, 'priority') ?? 'normal';
165
168
  let quiet = Boolean(args.quiet);
166
169
  const report = validateEnum(args.report, REPORT_OPTIONS, 'report');
167
170
  let retry = parseIntFlag(args.retry, 'retry');
@@ -600,6 +603,7 @@ export const cloudCommand = defineCommand({
600
603
  metadata: mergedMetadata,
601
604
  name,
602
605
  orientation,
606
+ priority,
603
607
  raw: [],
604
608
  report,
605
609
  retry,
@@ -653,31 +657,46 @@ export const cloudCommand = defineCommand({
653
657
  // 404s (route undefined), some proxies 405 (path/method not allowed); in
654
658
  // either case fall back to the legacy multipart POST /uploads/flow, which
655
659
  // is byte-identical bar the storage reference.
656
- let response;
657
- try {
658
- const storageRef = await uploadFlowZip({ apiUrl, auth, buffer, debug });
660
+ const submitMultipart = (status) => {
661
+ if (debug) {
662
+ out(`[DEBUG] Client-direct flow upload unavailable (HTTP ${status}); falling back to multipart ${apiUrl}/uploads/flow`);
663
+ }
664
+ const testFormData = testSubmissionService.buildFormData(fields, buffer);
665
+ return ApiGateway.uploadFlow(apiUrl, auth, testFormData);
666
+ };
667
+ const submitFlows = async () => {
668
+ let storageRef;
669
+ try {
670
+ storageRef = await uploadFlowZip({ apiUrl, auth, buffer, debug });
671
+ }
672
+ catch (error) {
673
+ if (!isSubmitEndpointMissing(error))
674
+ throw error;
675
+ return submitMultipart(error.status);
676
+ }
659
677
  if (debug) {
660
678
  out(`[DEBUG] Flow zip uploaded (id=${storageRef.id}, supabase=${storageRef.supabaseSuccess}, backblaze=${storageRef.backblazeSuccess})`);
661
679
  out(`[DEBUG] Submitting flow test to ${apiUrl}/uploads/submitFlowTest`);
662
680
  }
663
- response = await ApiGateway.submitFlowTest(apiUrl, auth, {
664
- ...fields,
665
- ...storageRef,
681
+ // The same body on every attempt: its upload id is what lets the API
682
+ // answer a repeat with the run it already created (dcd#1617 follow-up).
683
+ const submitBody = { ...fields, ...storageRef };
684
+ // The retries can outlast a `dcd login` access token, so each attempt
685
+ // re-checks it, as the poll loop does.
686
+ let submitAuth = auth;
687
+ const submitted = await submitWithRetry({
688
+ fallback: (error) => submitMultipart(error.status),
689
+ // Recorded before each attempt, so `command failed` carries it too.
690
+ onAttempt: (attempt) => telemetry.recordSubmitAttempts(attempt),
691
+ onRetry: ({ attempt, attempts, delayMs, reason }) => warnOut(`Could not confirm the test submission (${reason}); sending it again in ${delayMs / 1000}s (attempt ${attempt}/${attempts}). It will not run twice.`),
692
+ async submit() {
693
+ submitAuth = await refreshAuth(submitAuth);
694
+ return ApiGateway.submitFlowTest(apiUrl, submitAuth, submitBody);
695
+ },
666
696
  });
667
- }
668
- catch (error) {
669
- if (error instanceof ApiError &&
670
- (error.status === 404 || error.status === 405)) {
671
- if (debug) {
672
- out(`[DEBUG] Client-direct flow upload unavailable (HTTP ${error.status}); falling back to multipart ${apiUrl}/uploads/flow`);
673
- }
674
- const testFormData = testSubmissionService.buildFormData(fields, buffer);
675
- response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
676
- }
677
- else {
678
- throw error;
679
- }
680
- }
697
+ return submitted.response;
698
+ };
699
+ const response = await submitFlows();
681
700
  const { message, results } = response;
682
701
  if (debug) {
683
702
  out(`[DEBUG] Flow submission response received`);
@@ -33,6 +33,11 @@ export declare const executionFlags: {
33
33
  readonly alias: ["maestroVersion"];
34
34
  readonly description: "Maestro version to run your flow against. Use \"latest\" for the most recent version. See https://docs.devicecloud.dev/configuration/maestro-versions for supported versions.";
35
35
  };
36
+ readonly priority: {
37
+ readonly type: "string";
38
+ readonly default: "normal";
39
+ readonly description: "Run priority within your organization (options: low, normal, high). Reorders only your own queued runs across your concurrency pool, for every flow in this upload; it never moves them ahead of other customers. Runs queued longer than 6 hours are promoted first, so low runs are never starved. Retries keep the same priority.";
40
+ };
36
41
  readonly retry: {
37
42
  readonly type: "string";
38
43
  readonly description: "Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge";
@@ -33,6 +33,11 @@ export const executionFlags = {
33
33
  alias: ['maestroVersion'],
34
34
  description: 'Maestro version to run your flow against. Use "latest" for the most recent version. See https://docs.devicecloud.dev/configuration/maestro-versions for supported versions.',
35
35
  },
36
+ priority: {
37
+ type: 'string',
38
+ default: 'normal',
39
+ description: 'Run priority within your organization (options: low, normal, high). Reorders only your own queued runs across your concurrency pool, for every flow in this upload; it never moves them ahead of other customers. Runs queued longer than 6 hours are promoted first, so low runs are never starved. Retries keep the same priority.',
40
+ },
36
41
  retry: {
37
42
  type: 'string',
38
43
  description: 'Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge',
@@ -115,6 +115,11 @@ export declare const flags: {
115
115
  readonly alias: ["maestroVersion"];
116
116
  readonly description: "Maestro version to run your flow against. Use \"latest\" for the most recent version. See https://docs.devicecloud.dev/configuration/maestro-versions for supported versions.";
117
117
  };
118
+ readonly priority: {
119
+ readonly type: "string";
120
+ readonly default: "normal";
121
+ readonly description: "Run priority within your organization (options: low, normal, high). Reorders only your own queued runs across your concurrency pool, for every flow in this upload; it never moves them ahead of other customers. Runs queued longer than 6 hours are promoted first, so low runs are never starved. Retries keep the same priority.";
122
+ };
118
123
  readonly retry: {
119
124
  readonly type: "string";
120
125
  readonly description: "Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge";
@@ -189,6 +189,15 @@ export declare const ApiGateway: {
189
189
  message?: string;
190
190
  results?: components["schemas"]["IDBResult"][];
191
191
  }>;
192
+ /**
193
+ * How long one submit attempt may take (dcd#1617 follow-up). A submit
194
+ * normally answers in a second or two; one held by a Supabase stall is
195
+ * abandoned and re-sent by `submitWithRetry`, which is safe because the API
196
+ * answers a repeat with the run it already created. Well under the 300s at
197
+ * which Railway's edge would cut it with a 502. Settable so tests can
198
+ * shorten it.
199
+ */
200
+ submitAttemptTimeoutMs: number;
192
201
  /**
193
202
  * Dry-run cost + cell-count estimate for a (possibly device-matrix)
194
203
  * submission. Runs the same resolve → validate → fan-out → price core as the
@@ -51,8 +51,13 @@ export const ApiGateway = {
51
51
  message += ` • Verify API URL is correct: ${origin}\n`;
52
52
  message += ` • Check for proxy/VPN interference\n`;
53
53
  message += ` • Try again in a few moments if server is temporarily down\n\n`;
54
- message += `Original error: ${error.message}`;
55
- const enhancedError = new Error(message);
54
+ // undici puts the real reason on `cause` (UND_ERR_HEADERS_TIMEOUT,
55
+ // ECONNRESET, ENOTFOUND, …); without it a timeout, a reset and a DNS
56
+ // failure all read as the same "fetch failed".
57
+ const cause = error.cause;
58
+ const code = cause?.code;
59
+ message += `Original error: ${error.message}${typeof code === 'string' ? ` (${code})` : ''}`;
60
+ const enhancedError = new Error(message, { cause: cause ?? error });
56
61
  enhancedError.name = 'NetworkError';
57
62
  enhancedError.stack = error.stack;
58
63
  return enhancedError;
@@ -545,6 +550,7 @@ export const ApiGateway = {
545
550
  ...auth.headers,
546
551
  },
547
552
  method: 'POST',
553
+ signal: AbortSignal.timeout(this.submitAttemptTimeoutMs),
548
554
  });
549
555
  if (!res.ok) {
550
556
  await this.handleApiError(res, 'Failed to submit test flows');
@@ -556,9 +562,23 @@ export const ApiGateway = {
556
562
  if (error instanceof TypeError && error.message === 'fetch failed') {
557
563
  throw this.enhanceFetchError(error, `${baseUrl}/uploads/submitFlowTest`);
558
564
  }
565
+ if (error instanceof Error && error.name === 'TimeoutError') {
566
+ const timeout = new Error(`Failed to submit test flows: no response from the API within ${this.submitAttemptTimeoutMs / 1000}s`, { cause: error });
567
+ timeout.name = 'SubmitTimeoutError';
568
+ throw timeout;
569
+ }
559
570
  throw error;
560
571
  }
561
572
  },
573
+ /**
574
+ * How long one submit attempt may take (dcd#1617 follow-up). A submit
575
+ * normally answers in a second or two; one held by a Supabase stall is
576
+ * abandoned and re-sent by `submitWithRetry`, which is safe because the API
577
+ * answers a repeat with the run it already created. Well under the 300s at
578
+ * which Railway's edge would cut it with a 502. Settable so tests can
579
+ * shorten it.
580
+ */
581
+ submitAttemptTimeoutMs: 90_000,
562
582
  /**
563
583
  * Dry-run cost + cell-count estimate for a (possibly device-matrix)
564
584
  * submission. Runs the same resolve → validate → fan-out → price core as the
@@ -1,7 +1,8 @@
1
1
  import * as path from 'node:path';
2
2
  import { z } from 'zod';
3
- import { ApiError, ApiGateway } from '../../gateways/api-gateway.js';
3
+ import { ApiGateway } from '../../gateways/api-gateway.js';
4
4
  import { plan } from '../../services/execution-plan.service.js';
5
+ import { isSubmitEndpointMissing, submitWithRetry, } from '../../services/flow-submit.service.js';
5
6
  import { computeCommonRoot, buildTestMetadataMap } from '../../services/flow-paths.js';
6
7
  import { DeviceValidationService } from '../../services/device-validation.service.js';
7
8
  import { platformFromAppFile } from '../../services/notices.service.js';
@@ -70,6 +71,10 @@ export function registerRunCloudTest(server) {
70
71
  .enum(['default', 'm4', 'm1', 'gpu1', 'cpu1'])
71
72
  .optional()
72
73
  .describe('Runner type (default "default"). gpu1 is deprecated and will be removed on 9 November 2026; prefer cpu1.'),
74
+ priority: z
75
+ .enum(['low', 'normal', 'high'])
76
+ .optional()
77
+ .describe('Run priority within the organization (default "normal"). Reorders only the queued runs of this organization, for every flow in this upload; it never moves them ahead of other customers. Runs queued over 6 hours are promoted first, so low runs are never starved.'),
73
78
  configFile: z.string().optional().describe('Path to a workspace config.yaml'),
74
79
  ignoreShaCheck: z
75
80
  .boolean()
@@ -208,6 +213,7 @@ export function registerRunCloudTest(server) {
208
213
  iOSDevice: args.iosDevice,
209
214
  iOSVersion: args.iosVersion,
210
215
  name: args.name,
216
+ priority: args.priority,
211
217
  runnerType: args.runnerType ?? 'default',
212
218
  maestroVersion: resolvedMaestroVersion,
213
219
  retry: args.retry,
@@ -219,24 +225,38 @@ export function registerRunCloudTest(server) {
219
225
  // referencing it. Older API deployments lack these endpoints (404/405);
220
226
  // fall back to the legacy multipart POST /uploads/flow. Mirrors
221
227
  // `dcd cloud`.
222
- let response;
223
- try {
224
- const storageRef = await uploadFlowZip({ apiUrl, auth, buffer });
225
- response = await ApiGateway.submitFlowTest(apiUrl, auth, {
226
- ...fields,
227
- ...storageRef,
228
- });
229
- }
230
- catch (error) {
231
- if (error instanceof ApiError &&
232
- (error.status === 404 || error.status === 405)) {
233
- const testFormData = testSubmissionService.buildFormData(fields, buffer);
234
- response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
228
+ const submitMultipart = () => ApiGateway.uploadFlow(apiUrl, auth, testSubmissionService.buildFormData(fields, buffer));
229
+ const submitFlows = async () => {
230
+ let storageRef;
231
+ try {
232
+ storageRef = await uploadFlowZip({ apiUrl, auth, buffer });
235
233
  }
236
- else {
237
- throw error;
234
+ catch (error) {
235
+ if (!isSubmitEndpointMissing(error))
236
+ throw error;
237
+ return submitMultipart();
238
238
  }
239
- }
239
+ // Re-sent while its outcome is unknown, always with the same body;
240
+ // see submitWithRetry (dcd#1617 follow-up).
241
+ const submitBody = { ...fields, ...storageRef };
242
+ // The retries can outlast a `dcd login` access token, so each
243
+ // attempt re-checks it, as the wait loop below does.
244
+ let submitAuth = auth;
245
+ const submitted = await submitWithRetry({
246
+ fallback: submitMultipart,
247
+ // Recorded before each attempt, so a failed tool call carries it.
248
+ onAttempt: (attempt) => {
249
+ telemetryExtra.submitAttempts = attempt;
250
+ },
251
+ onRetry: ({ attempt, attempts, delayMs, reason }) => logStderr(`Could not confirm the test submission (${reason}); sending it again in ${delayMs / 1000}s (attempt ${attempt}/${attempts}). It will not run twice.`),
252
+ async submit() {
253
+ submitAuth = await refreshAuth(submitAuth);
254
+ return ApiGateway.submitFlowTest(apiUrl, submitAuth, submitBody);
255
+ },
256
+ });
257
+ return submitted.response;
258
+ };
259
+ const response = await submitFlows();
240
260
  const { message, results } = response;
241
261
  if (!results?.length) {
242
262
  throw new Error(`No tests were created: ${message}`);
@@ -47,6 +47,20 @@ export class DeviceValidationService {
47
47
  ? compatibilityData.androidPlay
48
48
  : compatibilityData.android;
49
49
  const argNames = options.argNames ?? CLI_ARG_NAMES;
50
+ // With Google Play the API does not give an omitted half the global
51
+ // default (no longer a Play cell): it picks one that makes the pair a Play
52
+ // cell, and only rejects when none exists (resolveAndroidTarget in
53
+ // compatibility.service.ts). Fill it the same way so a pair the API will
54
+ // run is not refused here.
55
+ let device = androidDevice;
56
+ let apiLevel = androidApiLevel;
57
+ if (googlePlay && device && !apiLevel) {
58
+ apiLevel = versionsFor(lookup, device)[0];
59
+ }
60
+ else if (googlePlay && apiLevel && !device) {
61
+ const level = apiLevel;
62
+ device = offeredDevices(lookup).find((d) => versionsFor(lookup, d).includes(level));
63
+ }
50
64
  this.validateDevice({
51
65
  debugLines: (deviceID, version, supportedVersions) => [
52
66
  `[DEBUG] Android device: ${deviceID}`,
@@ -55,11 +69,11 @@ export class DeviceValidationService {
55
69
  `[DEBUG] Supported Android versions: ${supportedVersions.join(', ')}`,
56
70
  ],
57
71
  // Mirrors the API, which applies these same global defaults to a
58
- // request that omits either half (test-request-validator.service.ts)
72
+ // request that omits either half (execution-planner.service.ts)
59
73
  // and then rejects an incompatible pair.
60
74
  defaultDevice: 'pixel-7',
61
- defaultVersion: '34',
62
- device: androidDevice,
75
+ defaultVersion: '36',
76
+ device,
63
77
  deviceArg: argNames.androidDevice,
64
78
  deviceKind: googlePlay ? 'Google Play' : 'Android',
65
79
  formatVersion: (version) => `API level ${version}`,
@@ -72,7 +86,7 @@ export class DeviceValidationService {
72
86
  ? `Android device "${deviceID}" is not available with Google Play. Google Play devices: ${offeredDevices(lookup).join(', ')}. See ${DEVICES_DOCS_URL}`
73
87
  : undefined,
74
88
  unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} ${googlePlay ? '(Play Store) ' : ''}only supports these Android API levels: ${supportedVersions.join(', ')}`,
75
- version: androidApiLevel,
89
+ version: apiLevel,
76
90
  versionArg: argNames.androidApiLevel,
77
91
  versionName: 'Android API level',
78
92
  });
@@ -0,0 +1,71 @@
1
+ import { ApiError } from '../gateways/api-gateway.js';
2
+ /**
3
+ * Submitting a flow test when the response may be lost (dcd#1617 follow-up).
4
+ *
5
+ * Supabase's ingress sometimes holds a request for minutes before answering,
6
+ * and Railway's edge cuts a request that is silent for 300s with a 502. Either
7
+ * way the CLI cannot tell whether its run was created. The API makes that safe
8
+ * to find out: it answers a repeat of the same submission (same body, same
9
+ * upload id from getFlowUploadUrl) with the run it already created, or a 409
10
+ * while that run is still being created. It never creates a second run or a
11
+ * second charge. So a submit whose outcome is unknown is simply sent again.
12
+ */
13
+ /**
14
+ * Waits before each re-send: 2s, then up to 30s apart, about three minutes in
15
+ * all. That is long enough for a write held in a stall to land, after which the
16
+ * repeat is answered with the run.
17
+ */
18
+ export declare const SUBMIT_RETRY_DELAYS_MS: readonly number[];
19
+ /**
20
+ * No attempt starts more than this long after the first. Each attempt can take
21
+ * up to its 90s timeout, so without a cap every attempt timing out would hold
22
+ * the command for about 16 minutes; with it, the worst case is about 6.5.
23
+ */
24
+ export declare const SUBMIT_RETRY_BUDGET_MS: number;
25
+ /**
26
+ * Whether a failed attempt is worth sending again. Anything that is not an API
27
+ * answer is a transport failure: a network error, the attempt timeout, or a
28
+ * body that died mid-read after a 2xx. In every case the run may already exist,
29
+ * which a repeat finds out. The npm build throws undici's errors and the bun
30
+ * binaries their own; neither is an ApiError, so both retry.
31
+ */
32
+ export declare function isRetryableSubmitError(error: unknown): boolean;
33
+ /** A one-line reason for the retry notice. Network errors carry a long help text. */
34
+ export declare function describeSubmitError(error: unknown): string;
35
+ /** The API predates the client-direct submit: a real API 404s, some proxies 405. */
36
+ export declare function isSubmitEndpointMissing(error: unknown): boolean;
37
+ export interface SubmitRetryNotice {
38
+ /** The attempt about to be sent, counting from 1. */
39
+ attempt: number;
40
+ attempts: number;
41
+ delayMs: number;
42
+ reason: string;
43
+ }
44
+ /**
45
+ * Send a submission, sending it again while its outcome is unknown.
46
+ *
47
+ * `fallback` is the legacy multipart submit for an API that predates this
48
+ * endpoint. It is used only when the *first* attempt says the endpoint is
49
+ * missing. A 404 on a later attempt comes from an API that took the earlier
50
+ * attempt, so a multipart submit then would create a second run.
51
+ *
52
+ * Resolves with the response and how many attempts it took. Throws the last
53
+ * error when it is not retryable or the attempts run out.
54
+ */
55
+ export declare function submitWithRetry<T>(opts: {
56
+ budgetMs?: number;
57
+ delaysMs?: readonly number[];
58
+ fallback: (error: ApiError) => Promise<T>;
59
+ now?: () => number;
60
+ /**
61
+ * Called before each attempt with its number, counting from 1, so a caller
62
+ * can record how many were made whether the submit succeeds or not.
63
+ */
64
+ onAttempt?: (attempt: number) => void;
65
+ onRetry?: (notice: SubmitRetryNotice) => void;
66
+ sleep?: (ms: number) => Promise<void>;
67
+ submit: () => Promise<T>;
68
+ }): Promise<{
69
+ attempts: number;
70
+ response: T;
71
+ }>;
@@ -0,0 +1,117 @@
1
+ import { ApiError } from '../gateways/api-gateway.js';
2
+ import { isDefinitiveAuthFailure } from '../utils/auth.js';
3
+ /**
4
+ * Submitting a flow test when the response may be lost (dcd#1617 follow-up).
5
+ *
6
+ * Supabase's ingress sometimes holds a request for minutes before answering,
7
+ * and Railway's edge cuts a request that is silent for 300s with a 502. Either
8
+ * way the CLI cannot tell whether its run was created. The API makes that safe
9
+ * to find out: it answers a repeat of the same submission (same body, same
10
+ * upload id from getFlowUploadUrl) with the run it already created, or a 409
11
+ * while that run is still being created. It never creates a second run or a
12
+ * second charge. So a submit whose outcome is unknown is simply sent again.
13
+ */
14
+ /**
15
+ * Waits before each re-send: 2s, then up to 30s apart, about three minutes in
16
+ * all. That is long enough for a write held in a stall to land, after which the
17
+ * repeat is answered with the run.
18
+ */
19
+ export const SUBMIT_RETRY_DELAYS_MS = [
20
+ 2000, 5000, 10_000, 20_000, 30_000, 30_000, 30_000, 30_000,
21
+ ];
22
+ /**
23
+ * No attempt starts more than this long after the first. Each attempt can take
24
+ * up to its 90s timeout, so without a cap every attempt timing out would hold
25
+ * the command for about 16 minutes; with it, the worst case is about 6.5.
26
+ */
27
+ export const SUBMIT_RETRY_BUDGET_MS = 5 * 60_000;
28
+ /**
29
+ * Statuses that leave the outcome unknown (502/503/504 from the edge or the
30
+ * API), say the API could not finish (500), or say the first attempt is still
31
+ * creating the run (409 SUBMISSION_IN_PROGRESS). Re-sending is only safe
32
+ * because the API dedupes on the upload id.
33
+ */
34
+ const RETRYABLE_STATUSES = new Set([409, 500, 502, 503, 504]);
35
+ /**
36
+ * Whether a failed attempt is worth sending again. Anything that is not an API
37
+ * answer is a transport failure: a network error, the attempt timeout, or a
38
+ * body that died mid-read after a 2xx. In every case the run may already exist,
39
+ * which a repeat finds out. The npm build throws undici's errors and the bun
40
+ * binaries their own; neither is an ApiError, so both retry.
41
+ */
42
+ export function isRetryableSubmitError(error) {
43
+ if (error instanceof ApiError)
44
+ return RETRYABLE_STATUSES.has(error.status);
45
+ // The session can't be refreshed (refused, or logged out): only `dcd login`
46
+ // fixes that, so another attempt would fail the same way.
47
+ if (isDefinitiveAuthFailure(error))
48
+ return false;
49
+ return true;
50
+ }
51
+ /** A one-line reason for the retry notice. Network errors carry a long help text. */
52
+ export function describeSubmitError(error) {
53
+ if (error instanceof ApiError)
54
+ return `HTTP ${error.status}`;
55
+ if (error instanceof Error) {
56
+ if (error.name === 'SubmitTimeoutError')
57
+ return 'no response in time';
58
+ const code = error.cause?.code;
59
+ if (typeof code === 'string')
60
+ return code;
61
+ return error.name === 'NetworkError' ? 'network error' : error.name;
62
+ }
63
+ return String(error);
64
+ }
65
+ /** The API predates the client-direct submit: a real API 404s, some proxies 405. */
66
+ export function isSubmitEndpointMissing(error) {
67
+ return (error instanceof ApiError && (error.status === 404 || error.status === 405));
68
+ }
69
+ /**
70
+ * Send a submission, sending it again while its outcome is unknown.
71
+ *
72
+ * `fallback` is the legacy multipart submit for an API that predates this
73
+ * endpoint. It is used only when the *first* attempt says the endpoint is
74
+ * missing. A 404 on a later attempt comes from an API that took the earlier
75
+ * attempt, so a multipart submit then would create a second run.
76
+ *
77
+ * Resolves with the response and how many attempts it took. Throws the last
78
+ * error when it is not retryable or the attempts run out.
79
+ */
80
+ export async function submitWithRetry(opts) {
81
+ const delaysMs = opts.delaysMs ?? SUBMIT_RETRY_DELAYS_MS;
82
+ const budgetMs = opts.budgetMs ?? SUBMIT_RETRY_BUDGET_MS;
83
+ const now = opts.now ?? Date.now;
84
+ const startedAt = now();
85
+ const sleep = opts.sleep ??
86
+ ((ms) => new Promise((resolve) => {
87
+ setTimeout(resolve, ms);
88
+ }));
89
+ const attempts = delaysMs.length + 1;
90
+ for (let attempt = 1;; attempt++) {
91
+ opts.onAttempt?.(attempt);
92
+ try {
93
+ return { attempts: attempt, response: await opts.submit() };
94
+ }
95
+ catch (error) {
96
+ if (attempt === 1 && isSubmitEndpointMissing(error)) {
97
+ return {
98
+ attempts: attempt,
99
+ response: await opts.fallback(error),
100
+ };
101
+ }
102
+ const delayMs = delaysMs[attempt - 1];
103
+ if (delayMs === undefined ||
104
+ !isRetryableSubmitError(error) ||
105
+ now() - startedAt + delayMs > budgetMs) {
106
+ throw error;
107
+ }
108
+ opts.onRetry?.({
109
+ attempt: attempt + 1,
110
+ attempts,
111
+ delayMs,
112
+ reason: describeSubmitError(error),
113
+ });
114
+ await sleep(delayMs);
115
+ }
116
+ }
117
+ }
@@ -13,6 +13,7 @@ declare class Telemetry {
13
13
  private config;
14
14
  private command;
15
15
  private encryption;
16
+ private submitAttempts;
16
17
  private sessionId;
17
18
  private startedAt;
18
19
  private readonly disabled;
@@ -46,12 +47,20 @@ declare class Telemetry {
46
47
  * the meta fields it knows, but forwards every event's `extra`.
47
48
  */
48
49
  recordEncryption(update: Partial<EncryptionTelemetry>): void;
50
+ /**
51
+ * Record how many attempts the flow submit took (dcd#1617 follow-up), sent
52
+ * as `submit_attempts` on `command completed` / `command failed`. Anything
53
+ * above 1 means a response was lost and the submit was sent again, which is
54
+ * what to count when judging how often the API stalls under the CLI.
55
+ */
56
+ recordSubmitAttempts(attempts: number): void;
49
57
  recordCommandSuccess(): void;
50
58
  recordCommandFailure(opts: {
51
59
  error: Error | string;
52
60
  exitCode: number;
53
61
  }): void;
54
62
  private encryptionExtra;
63
+ private submitExtra;
55
64
  private enqueue;
56
65
  flush(): Promise<void>;
57
66
  /**
@@ -24,6 +24,7 @@ class Telemetry {
24
24
  config = null;
25
25
  command = inferCommandFromArgv();
26
26
  encryption = null;
27
+ submitAttempts;
27
28
  sessionId = randomUUID();
28
29
  startedAt = Date.now();
29
30
  disabled = !!process.env.DCD_TELEMETRY_DISABLED;
@@ -94,11 +95,21 @@ class Telemetry {
94
95
  ...update,
95
96
  };
96
97
  }
98
+ /**
99
+ * Record how many attempts the flow submit took (dcd#1617 follow-up), sent
100
+ * as `submit_attempts` on `command completed` / `command failed`. Anything
101
+ * above 1 means a response was lost and the submit was sent again, which is
102
+ * what to count when judging how often the API stalls under the CLI.
103
+ */
104
+ recordSubmitAttempts(attempts) {
105
+ this.submitAttempts = attempts;
106
+ }
97
107
  recordCommandSuccess() {
98
108
  this.enqueue('info', 'cli.lifecycle', 'command completed', {
99
109
  duration_ms: Date.now() - this.startedAt,
100
110
  exit_code: 0,
101
111
  ...this.encryptionExtra(),
112
+ ...this.submitExtra(),
102
113
  });
103
114
  }
104
115
  recordCommandFailure(opts) {
@@ -110,11 +121,17 @@ class Telemetry {
110
121
  error_name: opts.error instanceof Error ? opts.error.name : 'CliError',
111
122
  error_stack: opts.error instanceof Error ? opts.error.stack : undefined,
112
123
  ...this.encryptionExtra(),
124
+ ...this.submitExtra(),
113
125
  });
114
126
  }
115
127
  encryptionExtra() {
116
128
  return this.encryption ? { encrypt: this.encryption } : {};
117
129
  }
130
+ submitExtra() {
131
+ return this.submitAttempts === undefined
132
+ ? {}
133
+ : { submit_attempts: this.submitAttempts };
134
+ }
118
135
  enqueue(level, context, message, extra) {
119
136
  if (this.disabled)
120
137
  return;
@@ -50,6 +50,13 @@ export interface TestSubmissionConfig {
50
50
  metadata?: string[];
51
51
  name?: string;
52
52
  orientation?: string;
53
+ /**
54
+ * Where this upload sits among the org's own queued runs. Like
55
+ * `cancelPrevious`, sent as its own field rather than inside `config`: it is
56
+ * a scheduling directive for the whole upload, not run configuration. Only
57
+ * reorders the org's own queue; it never moves the org ahead of others.
58
+ */
59
+ priority?: 'low' | 'normal' | 'high';
53
60
  raw?: unknown;
54
61
  /**
55
62
  * Which emulator software renderer to boot with (Android only). Omitted
@@ -20,7 +20,7 @@ export class TestSubmissionService {
20
20
  * @returns The flow zip buffer, its SHA-256, and the string-encoded fields
21
21
  */
22
22
  async buildTestPayload(config) {
23
- const { apiUrl, appBinaryId, appPlatform, encrypt, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], cancelPrevious = false, googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
23
+ const { apiUrl, appBinaryId, appPlatform, encrypt, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], cancelPrevious = false, googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, priority, retry, continueOnFailure = true, renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
24
24
  const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, includedFiles = [], referencedFiles, sequence, workspaceConfig, } = executionPlan;
25
25
  const { flows: sequentialFlows = [] } = sequence ?? {};
26
26
  const envObject = this.parseKeyValuePairs(env);
@@ -168,6 +168,11 @@ export class TestSubmissionService {
168
168
  if (cancelPrevious) {
169
169
  fields.cancelPrevious = 'true';
170
170
  }
171
+ // Same reasoning: 'normal' is the default and is never sent, so the
172
+ // default path's wire shape is unchanged and an older API never sees it.
173
+ if (priority && priority !== 'normal') {
174
+ fields.priority = priority;
175
+ }
171
176
  this.setOptionalFields(fields, {
172
177
  androidApiLevel,
173
178
  androidDevice,
package/package.json CHANGED
@@ -30,7 +30,7 @@
30
30
  "chai": "^6.2.2",
31
31
  "eslint": "^10.5.0",
32
32
  "eslint-config-prettier": "^10.1.8",
33
- "eslint-plugin-unicorn": "^76.0.0",
33
+ "eslint-plugin-unicorn": "^77.0.0",
34
34
  "husky": "^9.1.7",
35
35
  "mocha": "^12.0.0",
36
36
  "prettier": "^3.8.4",
@@ -68,7 +68,7 @@
68
68
  "test:unit": "node scripts/test-runner.mjs --unit",
69
69
  "typecheck": "tsc --noEmit -p tsconfig.test.json"
70
70
  },
71
- "version": "5.8.0-beta.1",
71
+ "version": "5.8.0-beta.3",
72
72
  "bugs": {
73
73
  "url": "https://discord.gg/gm3mJwcNw8"
74
74
  },