peachsim 0.1.630 → 0.1.631

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.
Files changed (89) hide show
  1. package/cli/app-fonts.ts +23 -4
  2. package/cli/bridge-flow-runner.ts +35 -28
  3. package/cli/command-registry.ts +1 -0
  4. package/cli/commands/compat.ts +11 -60
  5. package/cli/commands/control.ts +12 -5
  6. package/cli/commands/detect.ts +289 -26
  7. package/cli/commands/inspect/core.ts +1 -1
  8. package/cli/commands/inspect.ts +4 -0
  9. package/cli/commands/maestro.ts +3 -1
  10. package/cli/commands/project-report.ts +386 -0
  11. package/cli/commands/projectReportContract.ts +42 -0
  12. package/cli/commands/report-issue.ts +38 -54
  13. package/cli/commands/setup.ts +6 -4
  14. package/cli/commands/telemetry.ts +8 -3
  15. package/cli/commands/test.ts +347 -134
  16. package/cli/main.ts +11 -1
  17. package/cli/outbound-endpoints.ts +2 -1
  18. package/cli/privacy.ts +1 -2
  19. package/cli/runtime-diagnostics.ts +51 -0
  20. package/cli/telemetry.ts +78 -7
  21. package/detox/index.ts +3 -8
  22. package/dist-lib/agent-daemon-client.cjs +1 -1
  23. package/dist-lib/agent-events.cjs +1 -1
  24. package/dist-lib/agent-identity.cjs +1 -1
  25. package/dist-lib/agent-sessions.cjs +1 -1
  26. package/dist-lib/attached-projects.cjs +1 -1
  27. package/dist-lib/auth/shared-session.cjs +1 -1
  28. package/dist-lib/backend-origin.cjs +1 -1
  29. package/dist-lib/beta.cjs +1 -1
  30. package/dist-lib/beta.mjs +1 -1
  31. package/dist-lib/bridge-constants.cjs +1 -1
  32. package/dist-lib/bridge-contract-input.cjs +1 -1
  33. package/dist-lib/bridge-contract-input.mjs +1 -1
  34. package/dist-lib/bridge-contract.cjs +1 -1
  35. package/dist-lib/bridge-contract.mjs +1 -1
  36. package/dist-lib/capture-contract.cjs +1 -1
  37. package/dist-lib/capture-contract.mjs +1 -1
  38. package/dist-lib/cli-constants.cjs +1 -1
  39. package/dist-lib/cloud-contract.cjs +1 -1
  40. package/dist-lib/cloud-contract.mjs +1 -1
  41. package/dist-lib/cloud-test-args.cjs +1 -1
  42. package/dist-lib/cloud-test-args.mjs +1 -1
  43. package/dist-lib/cloud.cjs +1 -1
  44. package/dist-lib/cloud.mjs +1 -1
  45. package/dist-lib/config.cjs +1 -1
  46. package/dist-lib/detox/index.cjs +2 -7
  47. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  48. package/dist-lib/home-paths.cjs +8 -5
  49. package/dist-lib/host/bridge-host.cjs +110 -67
  50. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  51. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  52. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  53. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  54. package/dist-lib/host/websocket-proxy.cjs +1 -1
  55. package/dist-lib/index.cjs +5 -1
  56. package/dist-lib/jump-to-source-babel.cjs +1 -1
  57. package/dist-lib/jump-to-source-native.cjs +1 -1
  58. package/dist-lib/kotlin-host.cjs +1 -1
  59. package/dist-lib/menu.cjs +11 -1
  60. package/dist-lib/menu.mjs +11 -1
  61. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  62. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  63. package/dist-lib/metro-production-bundle.cjs +1 -1
  64. package/dist-lib/metro-production-bundle.mjs +1 -1
  65. package/dist-lib/metro.cjs +1 -1
  66. package/dist-lib/native-app-identity.cjs +1 -1
  67. package/dist-lib/native-app-identity.mjs +1 -1
  68. package/dist-lib/profiles.cjs +1 -1
  69. package/dist-lib/public-brand.cjs +1 -1
  70. package/dist-lib/react-native-host-modules.cjs +1 -1
  71. package/dist-lib/react-native-host-modules.mjs +1 -1
  72. package/dist-lib/render-mode.cjs +1 -1
  73. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  74. package/dist-lib/sdk.cjs +3 -1
  75. package/dist-lib/sdk.mjs +2 -1
  76. package/dist-lib/skills.cjs +589 -268
  77. package/dist-lib/swift-compile.cjs +1 -1
  78. package/dist-lib/swift-compile.mjs +1 -1
  79. package/dist-lib/swift-package.cjs +1 -1
  80. package/dist-lib/swift-package.mjs +1 -1
  81. package/dist-lib/swift-submit.cjs +1 -1
  82. package/dist-lib/swift-submit.mjs +1 -1
  83. package/dist-lib/swift.cjs +439 -167
  84. package/dist-lib/vite-swift.cjs +1 -1
  85. package/dist-lib/vite.cjs +1 -1
  86. package/package.json +2 -1
  87. package/skills/contrast/SKILL.md +1 -0
  88. package/src/home-paths.ts +11 -9
  89. package/src/menu.ts +11 -0
@@ -455,6 +455,24 @@ function collectFlowHelperPaths(flowAbsPath: string): string[] {
455
455
  return helpers
456
456
  }
457
457
 
458
+ // a synthesized workspace config that selects exactly one flow. bun's glob
459
+ // (node:fs.globSync, which the planner reads) treats * ? [ ] { } as special
460
+ // and has no backslash escape, so each is wrapped in a one-character class; a
461
+ // leading `!` would negate the pattern, so it is anchored under ./.
462
+ function selectionConfig(entryKey: string): string {
463
+ if (entryKey.includes('\\')) {
464
+ throw new Error(
465
+ `cloud test cannot select a flow whose path contains a backslash: ${entryKey}`,
466
+ )
467
+ }
468
+ const escaped = entryKey.replace(/[*?[\]{}]/g, (char) => `[${char}]`)
469
+ const glob = escaped.startsWith('!') ? `./${escaped}` : escaped
470
+ return (
471
+ '# synthesized by peach test --cloud: run exactly the submitted entry flow.\n' +
472
+ `flows:\n - ${JSON.stringify(glob)}\n`
473
+ )
474
+ }
475
+
458
476
  function collectTestFiles(
459
477
  targetPath: string,
460
478
  ignoredPath: string | null,
@@ -522,17 +540,14 @@ function collectTestFiles(
522
540
  // the recorder runs an unpacked multi-file upload as a workspace,
523
541
  // whose default glob only sees top-level flows: a nested entry would
524
542
  // lose to a top-level helper. pin a config that selects the entry.
525
- if (!keys.some((key) => /^config\.ya?ml$/.test(basename(key)))) {
543
+ if (!keys.some((key) => /^config\.ya?ml$/.test(key))) {
526
544
  if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) {
527
545
  throw new Error(
528
546
  `cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`,
529
547
  )
530
548
  }
531
549
  const entryKey = keys[0] as string
532
- const glob = entryKey.replace(/[\\*?[\]{}()!+@]/g, '\\$&')
533
- files['config.yaml'] =
534
- '# synthesized by peach test --cloud: run exactly the submitted entry flow.\n' +
535
- `flows:\n - ${JSON.stringify(glob)}\n`
550
+ files['config.yaml'] = selectionConfig(entryKey)
536
551
  }
537
552
  }
538
553
  for (const configName of [
@@ -682,11 +697,16 @@ export async function runCloudTest(
682
697
  return 1
683
698
  }
684
699
 
685
- const targetPath = resolve(process.cwd(), target)
700
+ let targetPath = resolve(process.cwd(), target)
686
701
  if (!existsSync(targetPath)) {
687
702
  console.error(` error: test target not found: ${target}`)
688
703
  return 1
689
704
  }
705
+ // a file literally named config.yaml names a workspace, not a flow: local
706
+ // dispatch runs its directory, so hosted planning does too.
707
+ if (statSync(targetPath).isFile() && /^config\.ya?ml$/.test(basename(targetPath))) {
708
+ targetPath = dirname(targetPath)
709
+ }
690
710
 
691
711
  if (!app) {
692
712
  console.error(
@@ -798,21 +818,135 @@ export async function runCloudTest(
798
818
  app.includes('/api/preview/share/bundle')
799
819
  ? null
800
820
  : resolve(process.cwd(), app)
801
- const testFiles = collectTestFiles(collectionPath, localBundlePath)
802
- if (Object.keys(testFiles).length === 0) {
803
- console.error(` error: no test files found at ${target}`)
804
- return 1
821
+
822
+ // one hosted job per Maestro flow. a suite whose total executor time passes
823
+ // the 45 minute per-job cap still completes when every flow stays under it,
824
+ // so a directory target uploads the whole original file set (flows and
825
+ // helpers byte-identical) plus a synthesized config selecting one flow per
826
+ // job. tag selection resolves here, before any submission, exactly as a
827
+ // local workspace run resolves it: the CLI planner reads the workspace
828
+ // config, applies CLI include/exclude overrides, and the selected flows are
829
+ // the jobs. the jobs run one at a time because live sidecars can share
830
+ // mutable backend state. detox suites and explicit single flows keep their
831
+ // existing one-job whole-suite path.
832
+ const jobs: Array<{
833
+ label: string | null
834
+ testFiles: Record<string, string>
835
+ testArgs: string[] | null
836
+ }> = []
837
+ const hasConfig = forwarded.some(
838
+ (arg) => arg === '--config' || arg.startsWith('--config='),
839
+ )
840
+ const maestroConfig = hasConfig && isMaestroConfigValue(config)
841
+ const maestroTarget = (() => {
842
+ if (hasConfig && !maestroConfig) return false
843
+ if (!statSync(targetPath).isDirectory()) {
844
+ return /\.ya?ml$/.test(extname(targetPath))
845
+ }
846
+ return (
847
+ readdirSync(targetPath).some((name) => /\.ya?ml$/.test(name)) ||
848
+ ['.maestro', 'maestro'].includes(basename(targetPath))
849
+ )
850
+ })()
851
+ if (maestroTarget) {
852
+ // maestro has no test-name or test-path selection; the cloud selector
853
+ // vocabulary is shared with detox, so a jest selector here is refused
854
+ // before submission rather than silently dropped from the hosted job.
855
+ const unsupported = (cloudTestArgs ?? []).filter((arg) => {
856
+ const eq = arg.indexOf('=')
857
+ const head = eq > 0 ? arg.slice(0, eq) : arg
858
+ return ['-t', '--testNamePattern', '--testPathPattern', '--grep'].includes(head)
859
+ })
860
+ if (unsupported.length > 0) {
861
+ for (const arg of unsupported) {
862
+ const eq = arg.indexOf('=')
863
+ const head = eq > 0 ? arg.slice(0, eq) : arg
864
+ console.error(
865
+ ` error: ${head} selects detox/jest tests and does not apply to a maestro target`,
866
+ )
867
+ }
868
+ return 1
869
+ }
870
+ }
871
+ if (maestroTarget && statSync(targetPath).isDirectory()) {
872
+ const { collectFlowFiles, splitTagList } = await import('./maestro')
873
+ const tagFlag = (name: string): string[] | null => {
874
+ let values: string[] | null = null
875
+ for (let i = 0; i < forwarded.length; i++) {
876
+ const arg = forwarded[i]
877
+ const eq = arg.indexOf('=')
878
+ const head = eq > 0 ? arg.slice(0, eq) : arg
879
+ if (head !== name) continue
880
+ const inline = eq > 0 ? arg.slice(eq + 1) : null
881
+ const next = forwarded[i + 1]
882
+ const value = inline ?? (next !== undefined && !next.startsWith('-') ? next : '')
883
+ if (inline === null && next !== undefined && !next.startsWith('-')) i += 1
884
+ if (values === null) values = []
885
+ values.push(...splitTagList(value))
886
+ }
887
+ return values
888
+ }
889
+ const includeTags = tagFlag('--include-tags')
890
+ const excludeTags = tagFlag('--exclude-tags')
891
+ let flows: string[]
892
+ try {
893
+ flows = collectFlowFiles(targetPath, {
894
+ configPath: config ? resolve(process.cwd(), config) : undefined,
895
+ includeTags,
896
+ excludeTags,
897
+ })
898
+ } catch (err) {
899
+ console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
900
+ return 1
901
+ }
902
+ if (flows.length === 0) {
903
+ console.error(
904
+ includeTags !== null || excludeTags !== null
905
+ ? ` error: no flows in ${target} match the requested tags`
906
+ : ` error: no *.yaml or *.yml files found in ${target}`,
907
+ )
908
+ return 1
909
+ }
910
+ const baseFiles = collectTestFiles(targetPath, localBundlePath)
911
+ for (const flow of flows) {
912
+ const entryKey = relative(targetPath, flow).split(sep).join('/')
913
+ let generated: string
914
+ try {
915
+ generated = selectionConfig(entryKey)
916
+ } catch (err) {
917
+ console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
918
+ return 1
919
+ }
920
+ // the recorder selects the root workspace config. nested config.yaml
921
+ // files may be runFlow helpers and must keep their original bytes.
922
+ const testFiles = { ...baseFiles, 'config.yaml': generated }
923
+ jobs.push({ label: entryKey, testFiles, testArgs: null })
924
+ }
925
+ console.log(
926
+ ` cloud test: ${jobs.length} flow${jobs.length === 1 ? '' : 's'}, one hosted job each`,
927
+ )
928
+ } else {
929
+ const testFiles = collectTestFiles(collectionPath, localBundlePath)
930
+ if (Object.keys(testFiles).length === 0) {
931
+ console.error(` error: no test files found at ${target}`)
932
+ return 1
933
+ }
934
+ jobs.push({ label: null, testFiles, testArgs: cloudTestArgs })
805
935
  }
806
936
 
807
- // the tunnel stays open from submit until the job reaches a terminal
808
- // state; tearing it down early strands the recorder mid-run.
937
+ // the tunnel stays open from the first submit until every job reaches a
938
+ // terminal state; tearing it down early strands the recorder mid-run.
809
939
  if (liveDevStack && liveSidecarPorts) {
810
940
  try {
811
941
  const published = await publishLiveDevStack({
812
942
  devPort: liveDevStack.port,
813
943
  sidecarPorts: liveSidecarPorts,
814
944
  })
815
- tunnelTargetUrl = published.targetUrl
945
+ const appUrl = new URL(app)
946
+ const targetUrl = new URL(published.targetUrl)
947
+ targetUrl.pathname = appUrl.pathname
948
+ targetUrl.search = appUrl.search
949
+ tunnelTargetUrl = targetUrl.href
816
950
  tunnelHostHeader = published.hostHeader
817
951
  tunnelJobToken = published.jobToken
818
952
  tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined
@@ -824,150 +958,229 @@ export async function runCloudTest(
824
958
  }
825
959
  }
826
960
 
827
- const submitPayload: Record<string, unknown> = {
828
- kind: 'test',
829
- testFiles,
961
+ // a signal stops the run: it lets a submission already in flight finish, so
962
+ // an accepted job is never orphaned, cancels the job currently relying on
963
+ // this process's tunnel, and only then lets the tunnel close. the listener
964
+ // is installed once for the whole invocation and removed on the way out; a
965
+ // second signal waits for that cancellation instead of closing the tunnel
966
+ // while a delete is still pending.
967
+ const tunnelDependent = closeTunnel !== null
968
+ let interrupted = false
969
+ let wake: (() => void) | null = null
970
+ const onSignal = () => {
971
+ if (interrupted) {
972
+ console.error(' already stopping: waiting for the current job to cancel')
973
+ return
974
+ }
975
+ interrupted = true
976
+ console.error('\n interrupted: stopping after the current job')
977
+ wake?.()
978
+ }
979
+ if (tunnelDependent) {
980
+ process.on('SIGINT', onSignal)
981
+ process.on('SIGTERM', onSignal)
830
982
  }
831
- if (bundleUrl) submitPayload.bundleUrl = bundleUrl
832
- if (bundle) submitPayload.bundle = bundle
833
- if (bundleShareId) submitPayload.bundleShareId = bundleShareId
834
- if (tunnelTargetUrl) submitPayload.targetUrl = tunnelTargetUrl
835
- if (tunnelHostHeader) submitPayload.hostHeader = tunnelHostHeader
836
- if (tunnelJobToken) submitPayload.jobToken = tunnelJobToken
837
- if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
838
- if (owner) submitPayload.owner = owner
839
- if (repo) submitPayload.repo = repo
840
- if (cloudTestArgs) submitPayload.testArgs = cloudTestArgs
841
983
 
842
- try {
843
- const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
844
- let response: Response
845
- try {
846
- response = await fetch(endpoint, {
847
- method: 'POST',
848
- headers: {
849
- 'content-type': 'application/json',
850
- authorization: authHeaderValue(auth),
851
- },
852
- body: JSON.stringify(submitPayload),
853
- })
854
- } catch (err) {
855
- console.error(
856
- ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
857
- )
858
- return 1
859
- }
984
+ // a sleep the signal handler can cut short, so an interrupt is noticed while
985
+ // a job poll is waiting instead of after the whole interval.
986
+ const waitForPoll = (ms: number) =>
987
+ new Promise<void>((resolvePoll) => {
988
+ const timer = setTimeout(() => {
989
+ wake = null
990
+ resolvePoll()
991
+ }, ms)
992
+ wake = () => {
993
+ clearTimeout(timer)
994
+ wake = null
995
+ resolvePoll()
996
+ }
997
+ })
860
998
 
861
- if (!response.ok) {
862
- const body = await response.text()
863
- let message = body
864
- try {
865
- const json = JSON.parse(body)
866
- if (json.message) message = json.message
867
- } catch {}
868
- console.error(
869
- ` error: cloud test submission failed (${response.status}): ${message}`,
870
- )
871
- return 1
872
- }
999
+ const submitIdentity: Record<string, unknown> = {}
1000
+ if (bundleUrl) submitIdentity.bundleUrl = bundleUrl
1001
+ if (bundle) submitIdentity.bundle = bundle
1002
+ if (bundleShareId) submitIdentity.bundleShareId = bundleShareId
1003
+ if (tunnelTargetUrl) submitIdentity.targetUrl = tunnelTargetUrl
1004
+ if (tunnelHostHeader) submitIdentity.hostHeader = tunnelHostHeader
1005
+ if (tunnelJobToken) submitIdentity.jobToken = tunnelJobToken
1006
+ if (tunnelSidecarPorts) submitIdentity.sidecarPorts = tunnelSidecarPorts
1007
+ if (owner) submitIdentity.owner = owner
1008
+ if (repo) submitIdentity.repo = repo
873
1009
 
874
- const { id } = (await response.json()) as { id: string }
875
- console.log(` cloud test job submitted: ${id}`)
876
- // a job whose app this process serves over its tunnel cannot finish once
877
- // the process exits, so an interrupt cancels it rather than leaving it to
878
- // fail flow by flow while it bills.
879
- if (closeTunnel) {
880
- const cancelOnSignal = () => {
881
- console.error(`\n interrupted: cancelling ${id}`)
882
- void cancelCloudTest(id, origin).finally(() => {
883
- closeTunnel?.()
884
- process.exit(130)
885
- })
1010
+ const summary: Array<{ label: string; passed: boolean }> = []
1011
+ let worstExit = 0
1012
+ try {
1013
+ for (const [index, job] of jobs.entries()) {
1014
+ const submitPayload: Record<string, unknown> = {
1015
+ kind: 'test',
1016
+ testFiles: job.testFiles,
1017
+ ...submitIdentity,
886
1018
  }
887
- process.once('SIGINT', cancelOnSignal)
888
- process.once('SIGTERM', cancelOnSignal)
889
- }
890
-
891
- const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
892
- const startedAt = Date.now()
1019
+ if (job.testArgs) submitPayload.testArgs = job.testArgs
893
1020
 
894
- while (Date.now() - startedAt < timeoutMs) {
895
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
896
- let pollRes: Response
1021
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
1022
+ let response: Response
897
1023
  try {
898
- pollRes = await fetch(pollUrl, {
899
- headers: { authorization: authHeaderValue(auth) },
1024
+ response = await fetch(endpoint, {
1025
+ method: 'POST',
1026
+ headers: {
1027
+ 'content-type': 'application/json',
1028
+ authorization: authHeaderValue(auth),
1029
+ },
1030
+ body: JSON.stringify(submitPayload),
900
1031
  })
901
1032
  } catch (err) {
902
- console.warn(
903
- ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
1033
+ // a transport failure is not a flow failure: stop submitting further
1034
+ // jobs and report. jobs that already ran keep their collected results.
1035
+ console.error(
1036
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
904
1037
  )
905
- continue
1038
+ if (interrupted) break
1039
+ return 1
906
1040
  }
907
- if (!pollRes.ok) {
908
- console.warn(` warn: polling error: HTTP ${pollRes.status}`)
909
- continue
1041
+
1042
+ if (!response.ok) {
1043
+ const body = await response.text()
1044
+ let message = body
1045
+ try {
1046
+ const json = JSON.parse(body)
1047
+ if (json.message) message = json.message
1048
+ } catch {}
1049
+ console.error(
1050
+ ` error: cloud test submission failed (${response.status}): ${message}`,
1051
+ )
1052
+ if (interrupted) break
1053
+ return 1
910
1054
  }
911
- const job = (await pollRes.json()) as {
912
- id: string
913
- status: string
914
- result?: {
915
- previewId?: string | null
916
- previewUrl?: string | null
917
- runId?: string | null
918
- flows?: Array<{
919
- flow?: string | null
920
- previewId?: string | null
921
- previewUrl?: string | null
922
- runId?: string | null
923
- }> | null
924
- exitCode?: number | null
925
- failureReason?: string | null
926
- } | null
1055
+
1056
+ const { id } = (await response.json()) as { id: string }
1057
+ console.log(
1058
+ jobs.length > 1
1059
+ ? ` cloud test job ${index + 1}/${jobs.length} (${job.label}) submitted: ${id}`
1060
+ : ` cloud test job submitted: ${id}`,
1061
+ )
1062
+ if (interrupted) {
1063
+ // the signal arrived while the POST was in flight: the server accepted
1064
+ // the job, so cancel it before the tunnel closes.
1065
+ if (tunnelDependent) await cancelCloudTest(id, origin)
1066
+ break
927
1067
  }
928
1068
 
929
- if (
930
- job.status === 'done' ||
931
- job.status === 'failed' ||
932
- job.status === 'expired' ||
933
- job.status === 'cancelled'
934
- ) {
935
- const result = job.result
936
- // a job the recorder failed after a passing suite (missing recording or
937
- // registration) is still a failed run.
938
- const exitCode =
939
- job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
940
- console.log(` status: ${job.status}`)
941
- console.log(` exit code: ${exitCode}`)
942
- if (result?.previewUrl) {
943
- console.log(` preview: ${result.previewUrl}`)
944
- } else if (result?.previewId) {
945
- console.log(
946
- ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
1069
+ const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
1070
+ const startedAt = Date.now()
1071
+ let exitCode: number | null = null
1072
+
1073
+ // every flow keeps the job's own budget; a wait that expires cancels
1074
+ // the job before the tunnel closes, and later flows are not submitted.
1075
+ while (!interrupted && Date.now() - startedAt < timeoutMs) {
1076
+ await waitForPoll(pollIntervalMs)
1077
+ if (interrupted) break
1078
+ let pollRes: Response
1079
+ try {
1080
+ pollRes = await fetch(pollUrl, {
1081
+ headers: { authorization: authHeaderValue(auth) },
1082
+ })
1083
+ } catch (err) {
1084
+ console.warn(
1085
+ ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
947
1086
  )
1087
+ continue
1088
+ }
1089
+ if (interrupted) break
1090
+ if (!pollRes.ok) {
1091
+ console.warn(` warn: polling error: HTTP ${pollRes.status}`)
1092
+ continue
948
1093
  }
949
- if (result?.runId) {
950
- console.log(` run: ${result.runId}`)
1094
+ const jobState = (await pollRes.json()) as {
1095
+ id: string
1096
+ status: string
1097
+ result?: {
1098
+ previewId?: string | null
1099
+ previewUrl?: string | null
1100
+ runId?: string | null
1101
+ flows?: Array<{
1102
+ flow?: string | null
1103
+ previewId?: string | null
1104
+ previewUrl?: string | null
1105
+ runId?: string | null
1106
+ }> | null
1107
+ exitCode?: number | null
1108
+ failureReason?: string | null
1109
+ } | null
951
1110
  }
952
- if (result?.flows && result.flows.length > 1) {
953
- result.flows.forEach((flow, index) => {
1111
+
1112
+ if (
1113
+ jobState.status === 'done' ||
1114
+ jobState.status === 'failed' ||
1115
+ jobState.status === 'expired' ||
1116
+ jobState.status === 'cancelled'
1117
+ ) {
1118
+ const result = jobState.result
1119
+ // a job the recorder failed after a passing suite (missing recording or
1120
+ // registration) is still a failed run.
1121
+ exitCode =
1122
+ jobState.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
1123
+ console.log(` status: ${jobState.status}`)
1124
+ console.log(` exit code: ${exitCode}`)
1125
+ if (result?.previewUrl) {
1126
+ console.log(` preview: ${result.previewUrl}`)
1127
+ } else if (result?.previewId) {
954
1128
  console.log(
955
- ` flow ${index + 1}: ${flow.flow ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
1129
+ ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
956
1130
  )
957
- })
958
- }
959
- if (result?.failureReason) {
960
- console.error(` failure: ${result.failureReason}`)
1131
+ }
1132
+ if (result?.runId) {
1133
+ console.log(` run: ${result.runId}`)
1134
+ }
1135
+ if (result?.flows && (result.flows.length > 1 || jobs.length > 1)) {
1136
+ result.flows.forEach((flow, flowIndex) => {
1137
+ console.log(
1138
+ ` flow ${flowIndex + 1}: ${flow.flow ?? job.label ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
1139
+ )
1140
+ })
1141
+ }
1142
+ if (result?.failureReason) {
1143
+ console.error(` failure: ${result.failureReason}`)
1144
+ }
1145
+ break
961
1146
  }
962
- return exitCode
963
1147
  }
964
- }
965
1148
 
966
- console.error(
967
- ` error: cloud test timed out after ${timeoutMs < 1000 ? `${timeoutMs}ms` : `${timeoutMs / 1000}s`}`,
968
- )
969
- return 1
1149
+ if (exitCode === null) {
1150
+ // the wait ended without a terminal state: the budget ran out or a
1151
+ // signal asked to stop. a tunnel-dependent job is cancelled before
1152
+ // the tunnel closes instead of failing flow by flow while it bills.
1153
+ if (tunnelDependent) await cancelCloudTest(id, origin)
1154
+ if (interrupted) break
1155
+ console.error(
1156
+ ` error: cloud test timed out after ${timeoutMs < 1000 ? `${timeoutMs}ms` : `${timeoutMs / 1000}s`}`,
1157
+ )
1158
+ return 1
1159
+ }
1160
+
1161
+ summary.push({ label: job.label ?? id, passed: exitCode === 0 })
1162
+ if (exitCode !== 0 && worstExit === 0) worstExit = exitCode
1163
+ if (interrupted) break
1164
+ }
1165
+ if (interrupted) return 130
1166
+ return worstExit
970
1167
  } finally {
1168
+ if (tunnelDependent) {
1169
+ process.off('SIGINT', onSignal)
1170
+ process.off('SIGTERM', onSignal)
1171
+ }
1172
+ if (jobs.length > 1 && summary.length > 0) {
1173
+ console.log('\n cloud test summary:')
1174
+ for (const entry of summary) {
1175
+ console.log(` ${entry.passed ? 'pass' : 'fail'} ${entry.label}`)
1176
+ }
1177
+ const passed = summary.filter((entry) => entry.passed).length
1178
+ const stopped =
1179
+ summary.length < jobs.length
1180
+ ? ` (stopped after ${summary.length} of ${jobs.length})`
1181
+ : ''
1182
+ console.log(` ${passed}/${summary.length} flows passed${stopped}`)
1183
+ }
971
1184
  closeTunnel?.()
972
1185
  }
973
1186
  }
package/cli/main.ts CHANGED
@@ -22,7 +22,10 @@ import { IS_STANDALONE } from './standalone'
22
22
  // "command timed out after 10s" message and is uncopyable for a bug report.
23
23
  // rejections that don't match this shape still get the verbose stack so we
24
24
  // don't paper over real bugs.
25
- function handleTopLevelError(err: unknown): never {
25
+ async function handleTopLevelError(err: unknown): Promise<never> {
26
+ const { trackCliError, flushCliTelemetry } = await import('./telemetry')
27
+ trackCliError(err)
28
+ await flushCliTelemetry()
26
29
  const msg = err instanceof Error ? err.message : String(err)
27
30
  // peach's known top-level errors are all single-line, narrow strings.
28
31
  // everything else gets the full stack so unexpected crashes still surface.
@@ -595,6 +598,11 @@ try {
595
598
  break
596
599
  }
597
600
 
601
+ case 'project-report': {
602
+ const { runProjectReport } = await import('./commands/project-report')
603
+ await exitWithFlush(runProjectReport(parsed.commandArgs))
604
+ }
605
+
598
606
  case 'report-issue': {
599
607
  const { runReportIssue } = await import('./commands/report-issue')
600
608
  const code = await runReportIssue(parsed.commandArgs)
@@ -714,5 +722,7 @@ try {
714
722
  throw error
715
723
  }
716
724
 
725
+ const { flushCliTelemetry } = await import('./telemetry')
726
+ await flushCliTelemetry()
717
727
  await scheduleAutomaticCliUpdate(0)
718
728
  await scheduleAutomaticCleanup()
@@ -38,7 +38,8 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
38
38
  category: 'bounded-diagnostics',
39
39
  destination: 'configured PostHog ingestion host',
40
40
  preConsent: false,
41
- payload: 'package name/version/unsupported API or error class only',
41
+ payload:
42
+ 'anonymous install id, CLI/runtime versions, OS/arch, command name, package compatibility fields, error class and bounded path-redacted first-line error/warning messages',
42
43
  },
43
44
  contrast_user_action: {
44
45
  category: 'explicit-user-action',
package/cli/privacy.ts CHANGED
@@ -23,7 +23,6 @@ export function diagnosticsAllowedForCurrentProcess(
23
23
  ): boolean {
24
24
  const doNotTrack = process.env.DO_NOT_TRACK
25
25
  if (doNotTrack === '1' || doNotTrack === 'true') return false
26
- if (!canPromptForPrivacyChoice()) return false
27
26
  return preferences.configured && preferences.productAnalytics
28
27
  }
29
28
 
@@ -42,7 +41,7 @@ export async function ensurePrivacyChoice(options: {
42
41
  }
43
42
 
44
43
  const allowed = await confirm(
45
- 'Share anonymous package incompatibilities and error classes to improve peach?',
44
+ 'Share anonymous diagnostics, including CI and agent runs: CLI/runtime versions, OS/arch, command name, random install ID, package incompatibilities, error class, and first-line error/warning messages (300 characters, paths redacted; no arguments, stacks, source or structured app data)?',
46
45
  false,
47
46
  )
48
47
  // the engine's historical analytics and crash transports carry broader