@devicecloud.dev/dcd 5.8.0-beta.2 → 5.8.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.
@@ -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';
@@ -656,31 +657,46 @@ export const cloudCommand = defineCommand({
656
657
  // 404s (route undefined), some proxies 405 (path/method not allowed); in
657
658
  // either case fall back to the legacy multipart POST /uploads/flow, which
658
659
  // is byte-identical bar the storage reference.
659
- let response;
660
- try {
661
- 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
+ }
662
677
  if (debug) {
663
678
  out(`[DEBUG] Flow zip uploaded (id=${storageRef.id}, supabase=${storageRef.supabaseSuccess}, backblaze=${storageRef.backblazeSuccess})`);
664
679
  out(`[DEBUG] Submitting flow test to ${apiUrl}/uploads/submitFlowTest`);
665
680
  }
666
- response = await ApiGateway.submitFlowTest(apiUrl, auth, {
667
- ...fields,
668
- ...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
+ },
669
696
  });
670
- }
671
- catch (error) {
672
- if (error instanceof ApiError &&
673
- (error.status === 404 || error.status === 405)) {
674
- if (debug) {
675
- out(`[DEBUG] Client-direct flow upload unavailable (HTTP ${error.status}); falling back to multipart ${apiUrl}/uploads/flow`);
676
- }
677
- const testFormData = testSubmissionService.buildFormData(fields, buffer);
678
- response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
679
- }
680
- else {
681
- throw error;
682
- }
683
- }
697
+ return submitted.response;
698
+ };
699
+ const response = await submitFlows();
684
700
  const { message, results } = response;
685
701
  if (debug) {
686
702
  out(`[DEBUG] Flow submission response received`);
@@ -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';
@@ -224,24 +225,38 @@ export function registerRunCloudTest(server) {
224
225
  // referencing it. Older API deployments lack these endpoints (404/405);
225
226
  // fall back to the legacy multipart POST /uploads/flow. Mirrors
226
227
  // `dcd cloud`.
227
- let response;
228
- try {
229
- const storageRef = await uploadFlowZip({ apiUrl, auth, buffer });
230
- response = await ApiGateway.submitFlowTest(apiUrl, auth, {
231
- ...fields,
232
- ...storageRef,
233
- });
234
- }
235
- catch (error) {
236
- if (error instanceof ApiError &&
237
- (error.status === 404 || error.status === 405)) {
238
- const testFormData = testSubmissionService.buildFormData(fields, buffer);
239
- 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 });
240
233
  }
241
- else {
242
- throw error;
234
+ catch (error) {
235
+ if (!isSubmitEndpointMissing(error))
236
+ throw error;
237
+ return submitMultipart();
243
238
  }
244
- }
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();
245
260
  const { message, results } = response;
246
261
  if (!results?.length) {
247
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;
@@ -47,15 +47,12 @@ export declare enum EiOSVersions {
47
47
  'twentySix' = "26"
48
48
  }
49
49
  export declare enum EAndroidApiLevels {
50
- 'thirty' = "30",
51
50
  'thirtyFive' = "35",
52
51
  'thirtyFour' = "34",
53
- 'thirtyOne' = "31",
54
52
  'thirtySeven' = "37",
55
53
  'thirtySix' = "36",
56
54
  'thirtyThree' = "33",
57
- 'thirtyTwo' = "32",
58
- 'twentyNine' = "29"
55
+ 'thirtyTwo' = "32"
59
56
  }
60
57
  /**
61
58
  * One explicit device-matrix cell. iOS entries carry {iOSDevice, iOSVersion};
@@ -51,14 +51,11 @@ export var EiOSVersions;
51
51
  })(EiOSVersions || (EiOSVersions = {}));
52
52
  export var EAndroidApiLevels;
53
53
  (function (EAndroidApiLevels) {
54
- EAndroidApiLevels["thirty"] = "30";
55
54
  EAndroidApiLevels["thirtyFive"] = "35";
56
55
  EAndroidApiLevels["thirtyFour"] = "34";
57
- EAndroidApiLevels["thirtyOne"] = "31";
58
56
  EAndroidApiLevels["thirtySeven"] = "37";
59
57
  EAndroidApiLevels["thirtySix"] = "36";
60
58
  EAndroidApiLevels["thirtyThree"] = "33";
61
59
  EAndroidApiLevels["thirtyTwo"] = "32";
62
- EAndroidApiLevels["twentyNine"] = "29";
63
60
  })(EAndroidApiLevels || (EAndroidApiLevels = {}));
64
61
  export const isIosMatrixConfig = (c) => 'iOSDevice' in c;
package/package.json CHANGED
@@ -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.2",
71
+ "version": "5.8.0",
72
72
  "bugs": {
73
73
  "url": "https://discord.gg/gm3mJwcNw8"
74
74
  },