@bugmole/cli 0.4.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.
Files changed (308) hide show
  1. package/.bugmole.env.example +20 -0
  2. package/LICENSE +7 -0
  3. package/README.md +293 -0
  4. package/TESTING.md +117 -0
  5. package/bugmole.config.yaml +39 -0
  6. package/package.json +85 -0
  7. package/scripts/billing/paypal-setup.mjs +121 -0
  8. package/scripts/bugmole-continue.ts +318 -0
  9. package/scripts/bugmole-init.ts +188 -0
  10. package/scripts/bugmole.cjs +17 -0
  11. package/scripts/bugmole.test.ts +344 -0
  12. package/scripts/bugmole.ts +657 -0
  13. package/scripts/ensure-maestro.cjs +79 -0
  14. package/scripts/ios-tunnel-keeper.sh +45 -0
  15. package/scripts/ios-wda-keeper.sh +66 -0
  16. package/scripts/sync-plan-catalog.d.mts +3 -0
  17. package/scripts/sync-plan-catalog.mjs +16 -0
  18. package/scripts/ui-parity-diff.py +65 -0
  19. package/scripts/ui-parity-requirements.txt +1 -0
  20. package/scripts/verify-manage-to-plans.mts +194 -0
  21. package/spec/app-ui-audit.schema.json +176 -0
  22. package/spec/blockers.yaml +79 -0
  23. package/spec/bugs.index.json +42 -0
  24. package/spec/design-dna.schema.json +38 -0
  25. package/spec/domain_rules.yaml +24 -0
  26. package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
  27. package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
  28. package/spec/journey_graph.yaml +126 -0
  29. package/spec/journeys.graph.json +2618 -0
  30. package/spec/plans/dashboard-smoke.flow.yaml +20 -0
  31. package/spec/plans/example.flow.yaml +99 -0
  32. package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
  33. package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
  34. package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
  35. package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
  36. package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
  37. package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
  38. package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
  39. package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
  40. package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
  41. package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
  42. package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
  43. package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
  44. package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
  45. package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
  46. package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
  47. package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
  48. package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
  49. package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
  50. package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
  51. package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
  52. package/spec/plans/login.flow.yaml +22 -0
  53. package/spec/plans/owner-operations.flow.yaml +20 -0
  54. package/spec/project_config.yaml +55 -0
  55. package/spec/roles.yaml +30 -0
  56. package/spec/schema.md +394 -0
  57. package/spec/test-case-results.schema.json +62 -0
  58. package/spec/test-cases.schema.json +85 -0
  59. package/spec/ui-parity-audit.schema.json +194 -0
  60. package/spec/ui-reverse-engineering.schema.json +94 -0
  61. package/src/billing/plan-catalog.test.ts +46 -0
  62. package/src/billing/plan-catalog.ts +199 -0
  63. package/src/integrations/aws-sigv4.test.ts +42 -0
  64. package/src/integrations/aws-sigv4.ts +72 -0
  65. package/src/integrations/device-farm.ts +155 -0
  66. package/src/integrations/github-app.test.ts +57 -0
  67. package/src/integrations/github-app.ts +143 -0
  68. package/src/integrations/gitlab.ts +81 -0
  69. package/src/integrations/temp-email.test.ts +123 -0
  70. package/src/integrations/temp-email.ts +175 -0
  71. package/src/integrations/testflight-feedback.test.ts +51 -0
  72. package/src/integrations/testflight-feedback.ts +173 -0
  73. package/src/integrations/webdriver-client.ts +131 -0
  74. package/src/mcp/server.test.ts +1220 -0
  75. package/src/mcp/server.ts +3064 -0
  76. package/src/mcp/write-test-cases.test.ts +287 -0
  77. package/src/registry/api-key-client.ts +39 -0
  78. package/src/registry/control-plane-client.ts +212 -0
  79. package/src/registry/migrations/0001_registry.sql +47 -0
  80. package/src/registry/migrations/0002_device_authorizations.sql +23 -0
  81. package/src/registry/migrations/0003_project_environments.sql +25 -0
  82. package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
  83. package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
  84. package/src/registry/migrations/0006_workspaces.sql +36 -0
  85. package/src/registry/migrations/0007_project_apps.sql +26 -0
  86. package/src/registry/migrations/0008_agent_tasks.sql +30 -0
  87. package/src/registry/migrations/0009_journey_revisions.sql +17 -0
  88. package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
  89. package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
  90. package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
  91. package/src/registry/migrations/0013_device_sessions.sql +22 -0
  92. package/src/registry/migrations/0014_agent_task_step.sql +1 -0
  93. package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
  94. package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
  95. package/src/registry/migrations/0017_run_targets.sql +7 -0
  96. package/src/registry/migrations/0018_run_fix_from.sql +4 -0
  97. package/src/registry/migrations/0019_workspace_flags.sql +9 -0
  98. package/src/registry/migrations/0020_orgs.sql +40 -0
  99. package/src/registry/migrations/0021_billing_core.sql +58 -0
  100. package/src/registry/migrations/0022_cloud_runners.sql +19 -0
  101. package/src/registry/migrations/0023_signup.sql +4 -0
  102. package/src/registry/migrations/0024_billing.sql +67 -0
  103. package/src/registry/migrations/0025_notifications.sql +47 -0
  104. package/src/registry/migrations/0026_repo_bindings.sql +28 -0
  105. package/src/registry/migrations/0027_feedback.sql +29 -0
  106. package/src/registry/migrations/0028_devices.sql +48 -0
  107. package/src/registry/migrations/0029_sso.sql +31 -0
  108. package/src/registry/migrations/0030_workspace_domains.sql +18 -0
  109. package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
  110. package/src/registry/migrations/0032_personas.sql +15 -0
  111. package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
  112. package/src/registry/task-scheduling.test.ts +100 -0
  113. package/src/registry/task-scheduling.ts +80 -0
  114. package/src/registry-worker/ai/platform-model.ts +77 -0
  115. package/src/registry-worker/ai/routes.test.ts +88 -0
  116. package/src/registry-worker/ai/routes.ts +90 -0
  117. package/src/registry-worker/artifacts.test.ts +98 -0
  118. package/src/registry-worker/artifacts.ts +85 -0
  119. package/src/registry-worker/billing/billing-core.test.ts +175 -0
  120. package/src/registry-worker/billing/checkout-routes.ts +209 -0
  121. package/src/registry-worker/billing/enforcement.ts +69 -0
  122. package/src/registry-worker/billing/entitlements.ts +108 -0
  123. package/src/registry-worker/billing/ledger.ts +186 -0
  124. package/src/registry-worker/billing/paypal/api.ts +259 -0
  125. package/src/registry-worker/billing/paypal/client.ts +91 -0
  126. package/src/registry-worker/billing/paypal/provider.ts +143 -0
  127. package/src/registry-worker/billing/paypal.test.ts +466 -0
  128. package/src/registry-worker/billing/provider.ts +114 -0
  129. package/src/registry-worker/billing/routes.ts +66 -0
  130. package/src/registry-worker/billing/subscriptions.ts +780 -0
  131. package/src/registry-worker/billing/thresholds.ts +107 -0
  132. package/src/registry-worker/core.ts +308 -0
  133. package/src/registry-worker/devices/devices.test.ts +185 -0
  134. package/src/registry-worker/devices/policy.ts +71 -0
  135. package/src/registry-worker/devices/routes.ts +453 -0
  136. package/src/registry-worker/domains/domains.test.ts +210 -0
  137. package/src/registry-worker/domains/routes.ts +139 -0
  138. package/src/registry-worker/domains.ts +88 -0
  139. package/src/registry-worker/email/sender.ts +75 -0
  140. package/src/registry-worker/env.d.ts +14716 -0
  141. package/src/registry-worker/features.ts +20 -0
  142. package/src/registry-worker/feedback/feedback.test.ts +230 -0
  143. package/src/registry-worker/feedback/format.ts +148 -0
  144. package/src/registry-worker/feedback/routes.ts +386 -0
  145. package/src/registry-worker/flags.ts +39 -0
  146. package/src/registry-worker/github/checks.test.ts +177 -0
  147. package/src/registry-worker/github/checks.ts +374 -0
  148. package/src/registry-worker/gitlab/checks.test.ts +141 -0
  149. package/src/registry-worker/gitlab/checks.ts +349 -0
  150. package/src/registry-worker/hooks.ts +54 -0
  151. package/src/registry-worker/index.ts +2077 -0
  152. package/src/registry-worker/jobs/index.ts +29 -0
  153. package/src/registry-worker/jobs/retention.ts +68 -0
  154. package/src/registry-worker/mcp/mcp.test.ts +355 -0
  155. package/src/registry-worker/mcp/routes.ts +215 -0
  156. package/src/registry-worker/mcp/token.ts +126 -0
  157. package/src/registry-worker/mcp/tools.ts +563 -0
  158. package/src/registry-worker/notifications/alerts.ts +212 -0
  159. package/src/registry-worker/notifications/notifications.test.ts +298 -0
  160. package/src/registry-worker/notifications/outbox.ts +83 -0
  161. package/src/registry-worker/notifications/routes.ts +280 -0
  162. package/src/registry-worker/notifications/secrets.ts +49 -0
  163. package/src/registry-worker/notifications/slack.ts +96 -0
  164. package/src/registry-worker/notifications/teams.ts +46 -0
  165. package/src/registry-worker/org/audit.ts +116 -0
  166. package/src/registry-worker/org/routes.test.ts +163 -0
  167. package/src/registry-worker/org/routes.ts +302 -0
  168. package/src/registry-worker/personas/personas.test.ts +78 -0
  169. package/src/registry-worker/personas/routes.ts +100 -0
  170. package/src/registry-worker/repo-triggers.ts +20 -0
  171. package/src/registry-worker/routes/index.ts +74 -0
  172. package/src/registry-worker/run-events.ts +24 -0
  173. package/src/registry-worker/runner/dispatch.ts +219 -0
  174. package/src/registry-worker/runner/jobs.ts +43 -0
  175. package/src/registry-worker/runner/metering.ts +82 -0
  176. package/src/registry-worker/runner/policy.ts +59 -0
  177. package/src/registry-worker/runner/routes.ts +171 -0
  178. package/src/registry-worker/runner/runner.test.ts +358 -0
  179. package/src/registry-worker/runner/tokens.ts +93 -0
  180. package/src/registry-worker/runs.test.ts +60 -0
  181. package/src/registry-worker/signup/policy.ts +57 -0
  182. package/src/registry-worker/signup/routes.ts +106 -0
  183. package/src/registry-worker/signup/signup.test.ts +81 -0
  184. package/src/registry-worker/sso/aegis.ts +141 -0
  185. package/src/registry-worker/sso/membership.ts +157 -0
  186. package/src/registry-worker/sso/routes.ts +458 -0
  187. package/src/registry-worker/sso/sso.test.ts +344 -0
  188. package/src/registry-worker/testing/d1-shim.ts +180 -0
  189. package/src/registry-worker/testing/harness.ts +137 -0
  190. package/src/runner-worker/index.ts +108 -0
  191. package/src/runtime/ai-analysis.ts +97 -0
  192. package/src/runtime/ai-exploration.test.ts +32 -0
  193. package/src/runtime/ai-exploration.ts +69 -0
  194. package/src/runtime/ai-repair.ts +74 -0
  195. package/src/runtime/ai-work.test.ts +99 -0
  196. package/src/runtime/android-screen-record.test.ts +75 -0
  197. package/src/runtime/android-screen-record.ts +192 -0
  198. package/src/runtime/app-understanding.test.ts +123 -0
  199. package/src/runtime/app-understanding.ts +201 -0
  200. package/src/runtime/appium-driver.test.ts +179 -0
  201. package/src/runtime/appium-driver.ts +295 -0
  202. package/src/runtime/blocker-resolution.test.ts +113 -0
  203. package/src/runtime/blocker-resolution.ts +111 -0
  204. package/src/runtime/browser-matrix.integration.test.ts +212 -0
  205. package/src/runtime/browser-matrix.test.ts +143 -0
  206. package/src/runtime/browser-matrix.ts +200 -0
  207. package/src/runtime/canonical-flow.test.ts +52 -0
  208. package/src/runtime/config-validate.ts +185 -0
  209. package/src/runtime/continuous-execution.ts +291 -0
  210. package/src/runtime/cursor-applescript.ts +573 -0
  211. package/src/runtime/cursor-cli-driver.test.ts +78 -0
  212. package/src/runtime/cursor-cli-driver.ts +156 -0
  213. package/src/runtime/cursor-driver-example.ts +117 -0
  214. package/src/runtime/cursor-driver-index.ts +65 -0
  215. package/src/runtime/cursor-driver-init.ts +277 -0
  216. package/src/runtime/cursor-driver-run.test.ts +15 -0
  217. package/src/runtime/cursor-driver-run.ts +323 -0
  218. package/src/runtime/cursor-driver.ts +332 -0
  219. package/src/runtime/cursor-llm-example.ts +90 -0
  220. package/src/runtime/cursor-llm.ts +206 -0
  221. package/src/runtime/cursor-mcp-monitor.ts +386 -0
  222. package/src/runtime/device-clouds/browserstack.ts +73 -0
  223. package/src/runtime/device-clouds/device-farm.ts +52 -0
  224. package/src/runtime/device-clouds/index.ts +92 -0
  225. package/src/runtime/device-clouds/kobiton.ts +70 -0
  226. package/src/runtime/device-clouds/targets.ts +44 -0
  227. package/src/runtime/device-clouds/types.ts +62 -0
  228. package/src/runtime/diff-proposal.ts +84 -0
  229. package/src/runtime/discovery-task.test.ts +29 -0
  230. package/src/runtime/discovery-task.ts +284 -0
  231. package/src/runtime/driver-recovery.ts +69 -0
  232. package/src/runtime/driver.ts +79 -0
  233. package/src/runtime/environment.test.ts +104 -0
  234. package/src/runtime/environment.ts +137 -0
  235. package/src/runtime/executor.test.ts +509 -0
  236. package/src/runtime/executor.ts +921 -0
  237. package/src/runtime/explorer.test.ts +101 -0
  238. package/src/runtime/explorer.ts +1013 -0
  239. package/src/runtime/failure-analysis.test.ts +111 -0
  240. package/src/runtime/failure-analysis.ts +272 -0
  241. package/src/runtime/fixtures/fake-maestro.sh +36 -0
  242. package/src/runtime/flow-language.test.ts +268 -0
  243. package/src/runtime/flow-language.ts +414 -0
  244. package/src/runtime/init-wizard.ts +354 -0
  245. package/src/runtime/ios-screen-record.test.ts +68 -0
  246. package/src/runtime/ios-screen-record.ts +155 -0
  247. package/src/runtime/journey-editor.ts +452 -0
  248. package/src/runtime/journey-evidence.test.ts +161 -0
  249. package/src/runtime/journey-evidence.ts +180 -0
  250. package/src/runtime/journey-graph.test.ts +257 -0
  251. package/src/runtime/journey-graph.ts +170 -0
  252. package/src/runtime/legacy-names.ts +32 -0
  253. package/src/runtime/llm-example.ts +105 -0
  254. package/src/runtime/llm.ts +527 -0
  255. package/src/runtime/local-browser.test.ts +45 -0
  256. package/src/runtime/local-browser.ts +48 -0
  257. package/src/runtime/local-registry-stub.test.ts +325 -0
  258. package/src/runtime/local-registry-stub.ts +803 -0
  259. package/src/runtime/maestro-driver.test.ts +84 -0
  260. package/src/runtime/maestro-driver.ts +209 -0
  261. package/src/runtime/mole-voice.ts +21 -0
  262. package/src/runtime/nav-crawl.test.ts +100 -0
  263. package/src/runtime/nav-crawl.ts +153 -0
  264. package/src/runtime/pipeline.test.ts +405 -0
  265. package/src/runtime/pipeline.ts +833 -0
  266. package/src/runtime/planner.test.ts +37 -0
  267. package/src/runtime/planner.ts +274 -0
  268. package/src/runtime/platform-ai.ts +76 -0
  269. package/src/runtime/playwright-driver.test.ts +93 -0
  270. package/src/runtime/playwright-driver.ts +620 -0
  271. package/src/runtime/project-spec.ts +140 -0
  272. package/src/runtime/record-run-verdicts.ts +68 -0
  273. package/src/runtime/reporter.test.ts +56 -0
  274. package/src/runtime/reporter.ts +158 -0
  275. package/src/runtime/reset.test.ts +44 -0
  276. package/src/runtime/reset.ts +61 -0
  277. package/src/runtime/reviewer.test.ts +73 -0
  278. package/src/runtime/reviewer.ts +158 -0
  279. package/src/runtime/run-job.ts +136 -0
  280. package/src/runtime/run-once.test.ts +207 -0
  281. package/src/runtime/run-once.ts +168 -0
  282. package/src/runtime/run.ts +132 -0
  283. package/src/runtime/screen-recording.ts +34 -0
  284. package/src/runtime/serve-gateway.test.ts +74 -0
  285. package/src/runtime/serve-gateway.ts +164 -0
  286. package/src/runtime/serve-worker.test.ts +23 -0
  287. package/src/runtime/serve-worker.ts +278 -0
  288. package/src/runtime/site-discovery.test.ts +168 -0
  289. package/src/runtime/site-discovery.ts +308 -0
  290. package/src/runtime/target-runner.ts +144 -0
  291. package/src/runtime/test-case-verdicts.test.ts +94 -0
  292. package/src/runtime/test-case-verdicts.ts +120 -0
  293. package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
  294. package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
  295. package/src/runtime/ui-reverse-engineering/types.ts +197 -0
  296. package/src/runtime/web-suite.test.ts +97 -0
  297. package/src/runtime/web-suite.ts +176 -0
  298. package/src/storage/create-object-store.ts +144 -0
  299. package/src/storage/keys.ts +34 -0
  300. package/src/storage/local-artifact-server.test.ts +314 -0
  301. package/src/storage/local-artifact-server.ts +357 -0
  302. package/src/storage/object-store.test.ts +28 -0
  303. package/src/storage/object-store.ts +101 -0
  304. package/src/storage/registry-object-store.ts +88 -0
  305. package/src/storage/remote-object-store.ts +104 -0
  306. package/src/storage/storage-directory.test.ts +43 -0
  307. package/src/storage/storage-directory.ts +24 -0
  308. package/tsconfig.json +24 -0
@@ -0,0 +1,71 @@
1
+ // Who may run on which device cloud.
2
+ import type { CloudDevice } from "../../runtime/device-clouds/types.js";
3
+ import type { Env } from "../core.js";
4
+ import { response } from "../core.js";
5
+ import { getEntitlements } from "../billing/entitlements.js";
6
+ import { canSpend } from "../billing/ledger.js";
7
+ import { flagOn } from "../flags.js";
8
+
9
+ export type BugmoleDeviceEnv = {
10
+ BUGMOLE_DEVICE_FARM_ACCESS_KEY_ID?: string;
11
+ BUGMOLE_DEVICE_FARM_SECRET_ACCESS_KEY?: string;
12
+ BUGMOLE_DEVICE_FARM_PROJECT_ARN?: string;
13
+ /** The same keys under their pre-rename secret names, used until they are re-set as BUGMOLE_*. */
14
+ VALKYRIE_DEVICE_FARM_ACCESS_KEY_ID?: string;
15
+ VALKYRIE_DEVICE_FARM_SECRET_ACCESS_KEY?: string;
16
+ /** Test seam for Device Farm calls. */
17
+ DEVICE_FARM_FETCH?: typeof fetch;
18
+ };
19
+
20
+ export function bugmoleDeviceCloud(env: Env): { credentials: { accessKeyId: string; secretAccessKey: string }; projectArn: string; fetch?: typeof fetch } | null {
21
+ const own = env as Env & BugmoleDeviceEnv;
22
+ const accessKeyId = own.BUGMOLE_DEVICE_FARM_ACCESS_KEY_ID ?? own.VALKYRIE_DEVICE_FARM_ACCESS_KEY_ID;
23
+ const secretAccessKey = own.BUGMOLE_DEVICE_FARM_SECRET_ACCESS_KEY ?? own.VALKYRIE_DEVICE_FARM_SECRET_ACCESS_KEY;
24
+ if (!accessKeyId || !secretAccessKey || !own.BUGMOLE_DEVICE_FARM_PROJECT_ARN) return null;
25
+ return {
26
+ credentials: { accessKeyId, secretAccessKey },
27
+ projectArn: own.BUGMOLE_DEVICE_FARM_PROJECT_ARN,
28
+ ...(own.DEVICE_FARM_FETCH ? { fetch: own.DEVICE_FARM_FETCH } : {}),
29
+ };
30
+ }
31
+
32
+ function refused(error: string, code: string, status = 422): Response {
33
+ return response({ error, code, ...(status === 402 ? { upgradeUrl: "/billing" } : {}) }, status);
34
+ }
35
+
36
+ /** Null when every device target in a run may be used; otherwise the response to return. */
37
+ export async function deviceTargetsError(
38
+ env: Env,
39
+ projectId: string,
40
+ workspaceId: string | null,
41
+ targets: CloudDevice[],
42
+ executionMode: "self" | "cloud",
43
+ ): Promise<Response | null> {
44
+ if (!workspaceId) return refused("This project isn't in a workspace", "no_workspace");
45
+ if (!(await flagOn(env, workspaceId, "device_clouds"))) return refused("Device clouds aren't available for this workspace yet.", "device_clouds_unavailable");
46
+ const { features } = await getEntitlements(env, workspaceId);
47
+ if (!features.deviceClouds) return refused("Real devices and device clouds are included in Business.", "plan_feature", 402);
48
+ for (const target of targets) {
49
+ if (target.provider === "bugmole") {
50
+ if (executionMode !== "cloud") return refused("Bugmole's own devices are only used by runs on Bugmole Cloud.", "bugmole_devices_need_cloud");
51
+ if (!target.real) return refused("Bugmole's device cloud has real devices only; use a connected cloud for emulators.", "bugmole_real_only");
52
+ if (!bugmoleDeviceCloud(env)) return refused("Bugmole's device cloud isn't available right now.", "bugmole_devices_unavailable", 503);
53
+ const spend = await canSpend(env, workspaceId, "device_minute", 1);
54
+ if (!spend.allowed) return refused(spend.message, "plan_limit", 402);
55
+ } else {
56
+ const credential = await env.DB.prepare(`
57
+ SELECT provider FROM device_cloud_credentials WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL
58
+ `).bind(target.credentialId ?? "", workspaceId).first<{ provider: string }>();
59
+ if (!credential || credential.provider !== target.provider) {
60
+ return refused(`That ${target.provider} connection isn't set up in this workspace.`, "device_credential_missing");
61
+ }
62
+ }
63
+ if (target.appBuildId) {
64
+ const build = await env.DB.prepare("SELECT platform FROM app_builds WHERE id = ? AND project_id = ? AND deleted_at IS NULL")
65
+ .bind(target.appBuildId, projectId).first<{ platform: string }>();
66
+ if (!build) return refused("That app build isn't in this project.", "app_build_missing");
67
+ if (build.platform !== target.platform) return refused(`That build is for ${build.platform}, not ${target.platform}.`, "app_build_platform");
68
+ }
69
+ }
70
+ return null;
71
+ }
@@ -0,0 +1,453 @@
1
+ // Device clouds: connections a workspace saves, their device catalogs, app
2
+ // builds, the access a running run gets, and sessions on Bugmole's own
3
+ // device cloud (AWS Device Farm), metered in device minutes.
4
+ import type { CloudDevice, DeviceCredential } from "../../runtime/device-clouds/types.js";
5
+ import { deviceFarmCapabilities } from "../../runtime/device-clouds/device-farm.js";
6
+ import {
7
+ deviceFarmCall,
8
+ DeviceFarmError,
9
+ finishedSession,
10
+ listDevices,
11
+ sessionArtifacts,
12
+ stopSession,
13
+ uploadApp,
14
+ type DeviceFarmOptions,
15
+ } from "../../integrations/device-farm.js";
16
+ import type { Env, Principal } from "../core.js";
17
+ import { memberOfProject, memberOfWorkspace, newId, requestJson, response, sha256 } from "../core.js";
18
+ import { getEntitlements } from "../billing/entitlements.js";
19
+ import { canSpend, recordUsage } from "../billing/ledger.js";
20
+ import { registerJob } from "../jobs/index.js";
21
+ import { actorOf, auditProject, recordAudit } from "../org/audit.js";
22
+ import { canStoreSecrets, openSecret, sealSecret } from "../notifications/secrets.js";
23
+ import { matchPath, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
24
+ import { bugmoleDeviceCloud } from "./policy.js";
25
+
26
+ const PROVIDERS = new Set(["browserstack", "kobiton", "aws_device_farm"]);
27
+ export const MAX_APP_BYTES = 95 * 1024 * 1024;
28
+ /** Device Farm deletes uploads after 30 days. */
29
+ const UPLOAD_REUSE_MS = 25 * 86_400_000;
30
+ const SESSION_MAX_MS = 150 * 60_000;
31
+
32
+ type Fetcher = typeof fetch;
33
+
34
+ function providerFetch(env: Env): Fetcher {
35
+ return (env as Env & { DEVICE_FARM_FETCH?: Fetcher }).DEVICE_FARM_FETCH ?? fetch;
36
+ }
37
+
38
+ async function manageable(env: Env, principal: Principal, workspaceId: string): Promise<Response | null> {
39
+ if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"))) return response({ error: "Only owners and admins can manage device clouds" }, 403);
40
+ const { features } = await getEntitlements(env, workspaceId);
41
+ if (!features.deviceClouds) return response({ error: "Device clouds are included in Business.", code: "plan_feature", upgradeUrl: "/billing" }, 402);
42
+ if (!canStoreSecrets(env)) return response({ error: "Device clouds can't be connected right now." }, 503);
43
+ return null;
44
+ }
45
+
46
+ async function credentialFor(env: Env, workspaceId: string, id: string): Promise<(DeviceCredential & { id: string }) | null> {
47
+ const row = await env.DB.prepare(`
48
+ SELECT id, provider, username, project_arn AS projectArn, secret_ciphertext AS ciphertext, secret_iv AS iv
49
+ FROM device_cloud_credentials WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL
50
+ `).bind(id, workspaceId).first<{ id: string; provider: DeviceCredential["provider"]; username: string; projectArn: string | null; ciphertext: string; iv: string }>();
51
+ if (!row) return null;
52
+ return { id: row.id, provider: row.provider, username: row.username, secret: await openSecret(env, row.ciphertext, row.iv), ...(row.projectArn ? { projectArn: row.projectArn } : {}) };
53
+ }
54
+
55
+ // --- Provider catalogs and connection checks ---------------------------------------------------
56
+
57
+ export type CatalogDevice = { name: string; platform: "android" | "ios"; osVersion: string; real: boolean; deviceArn?: string; browsers: Array<"chrome" | "safari"> };
58
+
59
+ function basic(credential: DeviceCredential): string {
60
+ return `Basic ${btoa(`${credential.username}:${credential.secret}`)}`;
61
+ }
62
+
63
+ async function catalog(env: Env, credential: DeviceCredential): Promise<CatalogDevice[]> {
64
+ const doFetch = providerFetch(env);
65
+ if (credential.provider === "browserstack") {
66
+ const reply = await doFetch("https://api-cloud.browserstack.com/app-automate/devices.json", { headers: { authorization: basic(credential) } });
67
+ if (!reply.ok) throw new Error(reply.status === 401 ? "BrowserStack didn't accept the user name and access key" : `BrowserStack answered ${reply.status}`);
68
+ const devices = await reply.json() as Array<{ os: string; os_version: string; device: string; realMobile?: boolean }>;
69
+ return devices.map((device) => ({
70
+ name: device.device,
71
+ platform: device.os === "ios" ? "ios" : "android",
72
+ osVersion: device.os_version,
73
+ real: device.realMobile !== false,
74
+ browsers: [device.os === "ios" ? "safari" : "chrome"],
75
+ }));
76
+ }
77
+ if (credential.provider === "kobiton") {
78
+ const reply = await doFetch("https://api.kobiton.com/v1/devices", { headers: { authorization: basic(credential) } });
79
+ if (!reply.ok) throw new Error(reply.status === 401 ? "Kobiton didn't accept the user name and API key" : `Kobiton answered ${reply.status}`);
80
+ const body = await reply.json() as { cloudDevices?: Array<Record<string, any>>; privateDevices?: Array<Record<string, any>> };
81
+ return [...(body.cloudDevices ?? []), ...(body.privateDevices ?? [])].map((device) => ({
82
+ name: String(device.deviceName),
83
+ platform: String(device.platformName).toLowerCase() === "ios" ? "ios" : "android",
84
+ osVersion: String(device.platformVersion ?? ""),
85
+ real: device.isVirtual !== true,
86
+ browsers: [String(device.platformName).toLowerCase() === "ios" ? "safari" : "chrome"],
87
+ }));
88
+ }
89
+ const devices = await listDevices({
90
+ credentials: { accessKeyId: credential.username, secretAccessKey: credential.secret },
91
+ projectArn: credential.projectArn ?? "",
92
+ fetch: doFetch,
93
+ });
94
+ return devices.map((device) => ({
95
+ name: device.name,
96
+ platform: device.platform === "IOS" ? "ios" : "android",
97
+ osVersion: device.os,
98
+ real: true,
99
+ deviceArn: device.arn,
100
+ browsers: [device.platform === "IOS" ? "safari" : "chrome"],
101
+ }));
102
+ }
103
+
104
+ function unique(devices: CatalogDevice[]): CatalogDevice[] {
105
+ const seen = new Set<string>();
106
+ return devices
107
+ .filter((device) => {
108
+ const key = `${device.platform}|${device.name}|${device.osVersion}|${device.real}`;
109
+ if (seen.has(key)) return false;
110
+ seen.add(key);
111
+ return true;
112
+ })
113
+ .sort((a, b) => a.platform.localeCompare(b.platform) || a.name.localeCompare(b.name) || b.osVersion.localeCompare(a.osVersion, undefined, { numeric: true }));
114
+ }
115
+
116
+ // --- Connections -------------------------------------------------------------------------------
117
+
118
+ async function listConnections({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
119
+ if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
120
+ const rows = await env.DB.prepare(`
121
+ SELECT id, provider, label, username, project_arn AS projectArn, created_at AS createdAt, last_verified_at AS lastVerifiedAt
122
+ FROM device_cloud_credentials WHERE workspace_id = ? AND revoked_at IS NULL ORDER BY created_at ASC
123
+ `).bind(workspaceId).all();
124
+ const { features } = await getEntitlements(env, workspaceId);
125
+ return response({
126
+ available: features.deviceClouds,
127
+ canManage: await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"),
128
+ bugmole: { available: features.deviceClouds && Boolean(bugmoleDeviceCloud(env)) },
129
+ connections: rows.results,
130
+ });
131
+ }
132
+
133
+ async function addConnection({ env, principal, request }: AuthedRouteContext, workspaceId: string): Promise<Response> {
134
+ const denied = await manageable(env, principal, workspaceId);
135
+ if (denied) return denied;
136
+ const body = await requestJson(request);
137
+ const provider = body?.provider;
138
+ const username = typeof body?.username === "string" ? body.username.trim() : "";
139
+ const secret = typeof body?.secret === "string" ? body.secret.trim() : "";
140
+ const projectArn = typeof body?.projectArn === "string" && body.projectArn.trim() ? body.projectArn.trim() : null;
141
+ const label = typeof body?.label === "string" && body.label.trim() ? body.label.trim().slice(0, 80) : String(provider);
142
+ if (typeof provider !== "string" || !PROVIDERS.has(provider)) return response({ error: "provider must be browserstack, kobiton or aws_device_farm" }, 422);
143
+ if (!username || !secret || username.length > 200 || secret.length > 500) return response({ error: "Enter the user name (or access key id) and the key" }, 422);
144
+ if (provider === "aws_device_farm" && !/^arn:aws:devicefarm:us-west-2:\d{12}:project:[A-Za-z0-9-]+$/.test(projectArn ?? "")) {
145
+ return response({ error: "Enter the Device Farm project ARN (us-west-2)" }, 422);
146
+ }
147
+ const credential = { provider, username, secret, ...(projectArn ? { projectArn } : {}) } as DeviceCredential;
148
+ // Check it works before saving it.
149
+ try {
150
+ await catalog(env, credential);
151
+ } catch (error) {
152
+ return response({ error: error instanceof Error ? error.message : "The connection didn't work", code: "device_cloud_rejected" }, 422);
153
+ }
154
+ const sealed = await sealSecret(env, secret);
155
+ const id = newId("dcc");
156
+ await env.DB.prepare(`
157
+ INSERT INTO device_cloud_credentials (id, workspace_id, provider, label, username, project_arn, secret_ciphertext, secret_iv, created_by, last_verified_at)
158
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
159
+ `).bind(id, workspaceId, provider, label, username, projectArn, sealed.ciphertext, sealed.iv, principal.aegisId, new Date().toISOString()).run();
160
+ await recordAudit(env, actorOf(principal), { workspaceId, action: "device_cloud.connected", targetType: "device_cloud", targetId: id, data: { provider } }, request);
161
+ return response({ connection: { id, provider, label, username, projectArn } }, 201);
162
+ }
163
+
164
+ async function removeConnection({ env, principal, request }: AuthedRouteContext, workspaceId: string, id: string): Promise<Response> {
165
+ if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"))) return response({ error: "Only owners and admins can manage device clouds" }, 403);
166
+ const updated = await env.DB.prepare("UPDATE device_cloud_credentials SET revoked_at = ?, secret_ciphertext = '', secret_iv = '' WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL")
167
+ .bind(new Date().toISOString(), id, workspaceId).run();
168
+ if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Not found" }, 404);
169
+ await recordAudit(env, actorOf(principal), { workspaceId, action: "device_cloud.disconnected", targetType: "device_cloud", targetId: id }, request);
170
+ return response({ removed: true });
171
+ }
172
+
173
+ async function devicesFor({ env, principal }: AuthedRouteContext, workspaceId: string, id: string): Promise<Response> {
174
+ if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
175
+ try {
176
+ if (id === "bugmole") {
177
+ const own = bugmoleDeviceCloud(env);
178
+ const { features } = await getEntitlements(env, workspaceId);
179
+ if (!own || !features.deviceClouds) return response({ error: "Bugmole's device cloud isn't available for this workspace" }, 402);
180
+ return response({ devices: unique(await catalog(env, { provider: "aws_device_farm", username: own.credentials.accessKeyId, secret: own.credentials.secretAccessKey, projectArn: own.projectArn })) });
181
+ }
182
+ const credential = await credentialFor(env, workspaceId, id);
183
+ if (!credential) return response({ error: "Not found" }, 404);
184
+ const devices = unique(await catalog(env, credential));
185
+ await env.DB.prepare("UPDATE device_cloud_credentials SET last_verified_at = ? WHERE id = ?").bind(new Date().toISOString(), id).run();
186
+ return response({ devices });
187
+ } catch (error) {
188
+ return response({ error: error instanceof Error ? error.message : "The device cloud didn't answer", code: "device_cloud_unavailable" }, 502);
189
+ }
190
+ }
191
+
192
+ // --- App builds ----------------------------------------------------------------------------------
193
+
194
+ async function uploadBuild({ env, principal, request, url }: AuthedRouteContext, projectId: string): Promise<Response> {
195
+ if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
196
+ if (!env.ARTIFACTS) return response({ error: "App builds can't be stored right now" }, 503);
197
+ const platform = url.searchParams.get("platform");
198
+ const filename = (url.searchParams.get("filename") ?? "").trim();
199
+ if (platform !== "android" && platform !== "ios") return response({ error: "platform must be android or ios" }, 422);
200
+ const extension = platform === "android" ? /\.(apk|aab)$/i : /\.ipa$/i;
201
+ if (!/^[A-Za-z0-9._ -]{1,120}$/.test(filename) || !extension.test(filename)) {
202
+ return response({ error: platform === "android" ? "Upload an .apk or .aab file" : "Upload an .ipa file" }, 422);
203
+ }
204
+ const declared = Number(request.headers.get("content-length") ?? NaN);
205
+ if (Number.isFinite(declared) && declared > MAX_APP_BYTES) return response({ error: "App builds can be up to 95 MB" }, 413);
206
+ const bytes = new Uint8Array(await request.arrayBuffer());
207
+ if (bytes.byteLength === 0) return response({ error: "The file is empty" }, 422);
208
+ if (bytes.byteLength > MAX_APP_BYTES) return response({ error: "App builds can be up to 95 MB" }, 413);
209
+ // APK, AAB and IPA are all zip files.
210
+ if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) return response({ error: "That isn't an app package" }, 422);
211
+ const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
212
+ const id = newId("app");
213
+ const objectKey = `projects/${encodeURIComponent(projectId)}/app-builds/${id}/${filename}`;
214
+ await env.ARTIFACTS.put(objectKey, bytes, { httpMetadata: { contentType: "application/octet-stream" } });
215
+ await env.DB.prepare(`
216
+ INSERT INTO app_builds (id, project_id, platform, filename, object_key, size_bytes, sha256, uploaded_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
217
+ `).bind(id, projectId, platform, filename, objectKey, bytes.byteLength, digest, principal.aegisId).run();
218
+ await auditProject(env, principal, projectId, { action: "app_build.uploaded", targetType: "app_build", targetId: id, data: { platform, filename } }, request);
219
+ return response({ build: { id, platform, filename, sizeBytes: bytes.byteLength, sha256: digest } }, 201);
220
+ }
221
+
222
+ async function listBuilds({ env, principal }: AuthedRouteContext, projectId: string): Promise<Response> {
223
+ if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
224
+ const rows = await env.DB.prepare(`
225
+ SELECT id, platform, filename, size_bytes AS sizeBytes, created_at AS createdAt FROM app_builds
226
+ WHERE project_id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 50
227
+ `).bind(projectId).all();
228
+ return response({ builds: rows.results });
229
+ }
230
+
231
+ async function deleteBuild({ env, principal, request }: AuthedRouteContext, projectId: string, id: string): Promise<Response> {
232
+ if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
233
+ const build = await env.DB.prepare("SELECT object_key AS key FROM app_builds WHERE id = ? AND project_id = ? AND deleted_at IS NULL").bind(id, projectId).first<{ key: string }>();
234
+ if (!build) return response({ error: "Not found" }, 404);
235
+ await env.ARTIFACTS?.delete(build.key);
236
+ await env.DB.prepare("UPDATE app_builds SET deleted_at = ? WHERE id = ?").bind(new Date().toISOString(), id).run();
237
+ await auditProject(env, principal, projectId, { action: "app_build.deleted", targetType: "app_build", targetId: id }, request);
238
+ return response({ deleted: true });
239
+ }
240
+
241
+ // --- What a running run may use --------------------------------------------------------------
242
+
243
+ type RunRow = { id: string; projectId: string; workspaceId: string | null; status: string; workerId: string | null; targetsJson: string | null };
244
+
245
+ async function runFor(env: Env, principal: Principal, runId: string): Promise<RunRow | Response> {
246
+ const run = await env.DB.prepare(`
247
+ SELECT id, project_id AS projectId, workspace_id AS workspaceId, status, worker_id AS workerId, targets_json AS targetsJson
248
+ FROM runs WHERE id = ?
249
+ `).bind(runId).first<RunRow>();
250
+ if (!run) return response({ error: "Run not found" }, 404);
251
+ const allowed = principal.runner
252
+ ? principal.runner.kind === "run" && principal.runner.id === runId
253
+ : await memberOfProject(env, principal, run.projectId, "project.write");
254
+ if (!allowed) return response({ error: "Run not found" }, 404);
255
+ // Credentials are only handed out while a worker is actually running the run.
256
+ if (run.status !== "running" || !run.workerId) return response({ error: "The run isn't running" }, 409);
257
+ return run;
258
+ }
259
+
260
+ function cloudTargets(run: RunRow): CloudDevice[] {
261
+ const targets = run.targetsJson ? JSON.parse(run.targetsJson) as Array<Record<string, unknown>> : [];
262
+ return targets.filter((target) => target.kind === "device") as unknown as CloudDevice[];
263
+ }
264
+
265
+ async function deviceAccess({ env, principal }: AuthedRouteContext, runId: string): Promise<Response> {
266
+ const run = await runFor(env, principal, runId);
267
+ if (run instanceof Response) return run;
268
+ const targets = cloudTargets(run);
269
+ const credentials: Record<string, DeviceCredential> = {};
270
+ for (const credentialId of new Set(targets.map((target) => target.credentialId).filter((id): id is string => Boolean(id)))) {
271
+ const credential = run.workspaceId ? await credentialFor(env, run.workspaceId, credentialId) : null;
272
+ if (credential) {
273
+ const { id: _id, ...rest } = credential;
274
+ credentials[credentialId] = rest;
275
+ }
276
+ }
277
+ const buildIds = [...new Set(targets.map((target) => target.appBuildId).filter((id): id is string => Boolean(id)))];
278
+ const appBuilds = [];
279
+ for (const id of buildIds) {
280
+ const build = await env.DB.prepare("SELECT id, platform, filename, sha256, object_key AS objectKey FROM app_builds WHERE id = ? AND project_id = ? AND deleted_at IS NULL")
281
+ .bind(id, run.projectId).first();
282
+ if (build) appBuilds.push(build);
283
+ }
284
+ return response({
285
+ credentials,
286
+ appBuilds,
287
+ // Bugmole's own devices are opened by the registry, and only for runs on Bugmole Cloud.
288
+ bugmole: Boolean(principal.runner) && targets.some((target) => target.provider === "bugmole"),
289
+ });
290
+ }
291
+
292
+ // --- Bugmole's own device cloud -------------------------------------------------------------
293
+
294
+ function ownOptions(env: Env): DeviceFarmOptions | null {
295
+ return bugmoleDeviceCloud(env);
296
+ }
297
+
298
+ async function appArnFor(env: Env, options: DeviceFarmOptions, projectId: string, buildId: string): Promise<string> {
299
+ const build = await env.DB.prepare("SELECT platform, filename, object_key AS key, provider_refs_json AS refs FROM app_builds WHERE id = ? AND project_id = ? AND deleted_at IS NULL")
300
+ .bind(buildId, projectId).first<{ platform: "android" | "ios"; filename: string; key: string; refs: string }>();
301
+ if (!build) throw new DeviceFarmError(404, "AppBuildMissing", "That app build isn't in this project");
302
+ const refs = JSON.parse(build.refs || "{}") as { bugmoleDeviceFarm?: { arn: string; at: number } };
303
+ if (refs.bugmoleDeviceFarm && Date.now() - refs.bugmoleDeviceFarm.at < UPLOAD_REUSE_MS) return refs.bugmoleDeviceFarm.arn;
304
+ const object = await env.ARTIFACTS?.get(build.key);
305
+ if (!object) throw new DeviceFarmError(404, "AppBuildMissing", "The app build's file is missing");
306
+ const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
307
+ const arn = await uploadApp(options, { name: build.filename, platform: build.platform, bytes });
308
+ refs.bugmoleDeviceFarm = { arn, at: Date.now() };
309
+ await env.DB.prepare("UPDATE app_builds SET provider_refs_json = ? WHERE id = ?").bind(JSON.stringify(refs), buildId).run();
310
+ return arn;
311
+ }
312
+
313
+ async function openBugmoleDevice({ env, principal, request }: AuthedRouteContext, runId: string): Promise<Response> {
314
+ if (!principal.runner) return response({ error: "Not allowed" }, 403);
315
+ const run = await runFor(env, principal, runId);
316
+ if (run instanceof Response) return run;
317
+ const options = ownOptions(env);
318
+ if (!options || !run.workspaceId) return response({ error: "Bugmole's device cloud isn't available" }, 503);
319
+ const body = await requestJson(request);
320
+ const asked = body?.device as CloudDevice | undefined;
321
+ const target = cloudTargets(run).find((item) => item.provider === "bugmole" && item.deviceName === asked?.deviceName
322
+ && (item.osVersion ?? "") === (asked?.osVersion ?? "") && (item.browserName ?? "") === (asked?.browserName ?? ""));
323
+ if (!target) return response({ error: "That device isn't one of this run's targets" }, 422);
324
+ const spend = await canSpend(env, run.workspaceId, "device_minute", 1);
325
+ if (!spend.allowed) return response({ error: spend.message, code: "plan_limit" }, 402);
326
+ try {
327
+ const deviceArn = target.deviceArn ?? (await listDevices(options)).find((device) =>
328
+ device.name.toLowerCase() === target.deviceName.toLowerCase() && (!target.osVersion || device.os === target.osVersion)
329
+ && device.platform === (target.platform === "ios" ? "IOS" : "ANDROID"))?.arn;
330
+ if (!deviceArn) return response({ error: `No ${target.deviceName} is available` }, 422);
331
+ const appArn = !target.browserName && target.appBuildId ? await appArnFor(env, options, run.projectId, target.appBuildId) : undefined;
332
+ const name = typeof body?.name === "string" ? body.name.slice(0, 200) : runId;
333
+ const created = await deviceFarmCall<{ remoteAccessSession: { arn: string } }>(options, "CreateRemoteAccessSession", {
334
+ projectArn: options.projectArn,
335
+ deviceArn,
336
+ name,
337
+ ...(appArn ? { appArn } : {}),
338
+ configuration: { parameters: { "appium:version": "3" } },
339
+ });
340
+ const id = newId("vds");
341
+ await env.DB.prepare(`
342
+ INSERT INTO bugmole_device_sessions (id, run_id, project_id, workspace_id, device_name, session_arn) VALUES (?, ?, ?, ?, ?, ?)
343
+ `).bind(id, runId, run.projectId, run.workspaceId, target.deviceName, created.remoteAccessSession.arn).run();
344
+ return response({ sessionId: id, status: "starting", capabilities: deviceFarmCapabilities(target) }, 201);
345
+ } catch (error) {
346
+ return response({ error: error instanceof Error ? error.message : "Device Farm didn't start a device" }, 502);
347
+ }
348
+ }
349
+
350
+ async function sessionRow(env: Env, runId: string, sessionId: string) {
351
+ return env.DB.prepare("SELECT * FROM bugmole_device_sessions WHERE id = ? AND run_id = ?").bind(sessionId, runId)
352
+ .first<{ id: string; run_id: string; project_id: string; workspace_id: string; session_arn: string; status: string; metered_minutes: number | null; billed_at: string | null }>();
353
+ }
354
+
355
+ async function bugmoleDeviceStatus({ env, principal }: AuthedRouteContext, runId: string, sessionId: string): Promise<Response> {
356
+ if (!principal.runner) return response({ error: "Not allowed" }, 403);
357
+ const run = await runFor(env, principal, runId);
358
+ if (run instanceof Response) return run;
359
+ const session = await sessionRow(env, runId, sessionId);
360
+ const options = ownOptions(env);
361
+ if (!session || !options) return response({ error: "Not found" }, 404);
362
+ const remote = (await deviceFarmCall<{ remoteAccessSession: any }>(options, "GetRemoteAccessSession", { arn: session.session_arn })).remoteAccessSession;
363
+ const hubUrl = remote.endpoints?.remoteDriverEndpoint;
364
+ if (remote.status === "RUNNING" && hubUrl) {
365
+ await env.DB.prepare("UPDATE bugmole_device_sessions SET status = 'running' WHERE id = ? AND status = 'starting'").bind(sessionId).run();
366
+ return response({ status: "running", hubUrl });
367
+ }
368
+ if (remote.status === "COMPLETED" || remote.result === "ERRORED" || remote.result === "FAILED") {
369
+ return response({ status: "failed", error: remote.message ?? "The device session ended before it started" });
370
+ }
371
+ return response({ status: "starting" });
372
+ }
373
+
374
+ /** Stops a session and bills its minutes once. */
375
+ export async function finishBugmoleDevice(env: Env, sessionId: string, waitMs = 60_000): Promise<{ meteredMinutes: number | null; videoUrl?: string }> {
376
+ const session = await env.DB.prepare("SELECT * FROM bugmole_device_sessions WHERE id = ?").bind(sessionId)
377
+ .first<{ id: string; run_id: string; project_id: string; workspace_id: string; session_arn: string; billed_at: string | null }>();
378
+ const options = ownOptions(env);
379
+ if (!session || !options) return { meteredMinutes: null };
380
+ await stopSession(options, session.session_arn).catch(() => undefined);
381
+ const done = await finishedSession(options, session.session_arn, Math.min(5_000, waitMs), waitMs);
382
+ const video = (await sessionArtifacts(options, session.session_arn).catch(() => [])).find((artifact) => artifact.type === "VIDEO");
383
+ if (done.status !== "COMPLETED" || done.meteredMinutes === undefined) {
384
+ await env.DB.prepare("UPDATE bugmole_device_sessions SET status = 'stopping' WHERE id = ?").bind(sessionId).run();
385
+ return { meteredMinutes: null, ...(video ? { videoUrl: video.url } : {}) };
386
+ }
387
+ const minutes = Math.ceil(done.meteredMinutes);
388
+ if (!session.billed_at && minutes > 0) {
389
+ await recordUsage(env, {
390
+ workspaceId: session.workspace_id,
391
+ projectId: session.project_id,
392
+ runId: session.run_id,
393
+ kind: "device_minute",
394
+ quantity: minutes,
395
+ source: "device_farm",
396
+ idempotencyKey: `devicefarm:${await sha256(session.session_arn)}`,
397
+ meta: { sessionId },
398
+ });
399
+ }
400
+ await env.DB.prepare("UPDATE bugmole_device_sessions SET status = 'finished', metered_minutes = ?, billed_at = COALESCE(billed_at, ?), finished_at = ? WHERE id = ?")
401
+ .bind(minutes, new Date().toISOString(), new Date().toISOString(), sessionId).run();
402
+ return { meteredMinutes: minutes, ...(video ? { videoUrl: video.url } : {}) };
403
+ }
404
+
405
+ async function finishDevice({ env, principal }: AuthedRouteContext, runId: string, sessionId: string): Promise<Response> {
406
+ if (!principal.runner) return response({ error: "Not allowed" }, 403);
407
+ const run = await runFor(env, principal, runId);
408
+ if (run instanceof Response) return run;
409
+ if (!(await sessionRow(env, runId, sessionId))) return response({ error: "Not found" }, 404);
410
+ const result = await finishBugmoleDevice(env, sessionId);
411
+ return response({ links: [], ...(result.videoUrl ? { videoUrl: result.videoUrl } : {}), ...(result.meteredMinutes !== null ? { meteredMinutes: result.meteredMinutes } : {}) });
412
+ }
413
+
414
+ /** Sessions a runner never finished (it crashed or was stopped) are stopped and billed. */
415
+ export async function sweepDeviceSessions(env: Env, now = new Date()): Promise<number> {
416
+ const rows = await env.DB.prepare(`
417
+ SELECT s.id FROM bugmole_device_sessions s LEFT JOIN runs r ON r.id = s.run_id
418
+ WHERE s.status IN ('starting', 'running', 'stopping')
419
+ AND (s.status = 'stopping' OR r.status IS NULL OR r.status NOT IN ('queued', 'running') OR s.created_at < ?)
420
+ `).bind(new Date(now.getTime() - SESSION_MAX_MS).toISOString().replace("T", " ").slice(0, 19)).all<{ id: string }>();
421
+ for (const row of rows.results) await finishBugmoleDevice(env, row.id, 0).catch((error) => console.error("[devices] sweep failed", error));
422
+ return rows.results.length;
423
+ }
424
+
425
+ registerJob("minute", "device-session-sweep", async (env, _ctx, now) => {
426
+ if (ownOptions(env)) await sweepDeviceSessions(env, now);
427
+ });
428
+
429
+ registerRoutes(async (context) => {
430
+ const { request, url } = context;
431
+ let params = matchPath(url.pathname, "/api/workspaces/:workspaceId/device-clouds");
432
+ if (params && request.method === "GET") return listConnections(context, params.workspaceId);
433
+ if (params && request.method === "POST") return addConnection(context, params.workspaceId);
434
+ params = matchPath(url.pathname, "/api/workspaces/:workspaceId/device-clouds/:connectionId");
435
+ if (params && request.method === "DELETE") return removeConnection(context, params.workspaceId, params.connectionId);
436
+ params = matchPath(url.pathname, "/api/workspaces/:workspaceId/device-clouds/:connectionId/devices");
437
+ if (params && request.method === "GET") return devicesFor(context, params.workspaceId, params.connectionId);
438
+ params = matchPath(url.pathname, "/api/projects/:projectId/app-builds");
439
+ if (params && request.method === "GET") return listBuilds(context, params.projectId);
440
+ if (params && request.method === "PUT") return uploadBuild(context, params.projectId);
441
+ params = matchPath(url.pathname, "/api/projects/:projectId/app-builds/:buildId");
442
+ if (params && request.method === "DELETE") return deleteBuild(context, params.projectId, params.buildId);
443
+ params = matchPath(url.pathname, "/api/runs/:runId/device-access");
444
+ if (params && request.method === "GET") return deviceAccess(context, params.runId);
445
+ params = matchPath(url.pathname, "/api/runs/:runId/device-sessions");
446
+ if (params && request.method === "POST") return openBugmoleDevice(context, params.runId);
447
+ params = matchPath(url.pathname, "/api/runs/:runId/device-sessions/:sessionId");
448
+ if (params && request.method === "GET") return bugmoleDeviceStatus(context, params.runId, params.sessionId);
449
+ params = matchPath(url.pathname, "/api/runs/:runId/device-sessions/:sessionId/finish");
450
+ if (params && request.method === "POST") return finishDevice(context, params.runId, params.sessionId);
451
+ return null;
452
+ });
453
+