@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,278 @@
1
+ import os from "node:os";
2
+ import type WebSocket from "ws";
3
+ import { ControlPlaneClient, RegistryRequestError, type RegistryRun } from "../registry/control-plane-client.js";
4
+ import { executeRegistryRun, registryDeviceAccess } from "./run-job.js";
5
+ import { connectServeGateway } from "./serve-gateway.js";
6
+ import { runNextAgentTask } from "./cursor-driver-run.js";
7
+ import { reconcilePipeline } from "./pipeline.js";
8
+ import { publishProjectSpec } from "./project-spec.js";
9
+ import { recordRunVerdicts } from "./record-run-verdicts.js";
10
+ import { isClaimableTask, MAX_TASK_ATTEMPTS } from "../registry/task-scheduling.js";
11
+ import { DISCOVERY_TASK_TYPE, runDiscoveryTask } from "./discovery-task.js";
12
+
13
+ export { RUN_HEARTBEAT_MS } from "./run-job.js";
14
+
15
+ /**
16
+ * Runs executed at once. Defaults to one: a browser run with video recording is heavy, and an
17
+ * unbounded worker used to start every queued run simultaneously.
18
+ */
19
+ export function resolveMaxConcurrentRuns(env: Record<string, string | undefined> = process.env): number {
20
+ const value = Number(env.BUGMOLE_MAX_CONCURRENT_RUNS);
21
+ return Number.isInteger(value) && value > 0 ? value : 1;
22
+ }
23
+
24
+ /**
25
+ * Agent tasks and the autonomous pipeline need a coding agent. A cloud worker without one sets
26
+ * BUGMOLE_WORKER_TASKS=false and only executes runs.
27
+ */
28
+ export function workerTasksEnabled(env: Record<string, string | undefined> = process.env): boolean {
29
+ return env.BUGMOLE_WORKER_TASKS?.trim().toLowerCase() !== "false";
30
+ }
31
+
32
+ /** Another worker claimed the run first. That is not a failure of the run. */
33
+ export function isClaimConflict(error: unknown): boolean {
34
+ return error instanceof RegistryRequestError && error.status === 409;
35
+ }
36
+
37
+ export async function startServeWorker(cfg: any): Promise<void> {
38
+ const apiKey = process.env.BUGMOLE_API_KEY?.trim();
39
+ const registryUrl = process.env.BUGMOLE_REGISTRY_URL?.trim();
40
+ const projectId = cfg.project?.id;
41
+ const workspaceId = cfg.project?.workspace_id ?? cfg.project?.workspaceId;
42
+ if (!apiKey || !registryUrl || !projectId) {
43
+ console.warn("[Bugmole] Worker idle: registry URL, API key, and project id are required");
44
+ return;
45
+ }
46
+ const workerId = `worker_${os.hostname()}_${process.pid}`;
47
+ const client = new ControlPlaneClient({ registryUrl, apiKey, projectId, workspaceId });
48
+ const active = new Set<string>();
49
+ const maxConcurrentRuns = resolveMaxConcurrentRuns();
50
+ const tasksEnabled = workerTasksEnabled();
51
+ if (!tasksEnabled) {
52
+ console.log("[Bugmole] Agent tasks are off (BUGMOLE_WORKER_TASKS=false); this worker only executes runs.");
53
+ }
54
+ // Consecutive failed runs per flow, so a run that keeps crashing stops
55
+ // being retried forever. See the catch in processRun.
56
+ const runFailures = new Map<string, number>();
57
+
58
+ const processRun = async (run: RegistryRun) => {
59
+ if (active.has(run.id)) return;
60
+ active.add(run.id);
61
+ try {
62
+ try {
63
+ await client.claimRun(run.id, workerId);
64
+ } catch (error) {
65
+ if (isClaimConflict(error)) return;
66
+ throw error;
67
+ }
68
+ const result = await executeRegistryRun(cfg, client, run, {
69
+ projectId,
70
+ workerId,
71
+ loadDeviceAccess: registryDeviceAccess(cfg, registryUrl, apiKey, projectId, run.id),
72
+ });
73
+ // A run that does not judge its cases leaves them "planned", and the
74
+ // pipeline keeps re-queueing the flow waiting for verdicts that no run
75
+ // ever writes. Recording here is what lets the run stage ever finish.
76
+ if (run.journeyId) {
77
+ // The flow completed a run, so earlier crashes were transient and
78
+ // should not count toward giving up on it later.
79
+ runFailures.delete(run.journeyId);
80
+ try {
81
+ const judged = await recordRunVerdicts(
82
+ cfg,
83
+ projectId,
84
+ run.journeyId,
85
+ (result.results ?? []).map((item) => ({
86
+ nodeId: item.nodeId,
87
+ outcome: item.outcome,
88
+ status: item.status,
89
+ reason: (item as { reason?: { code?: string; message?: string } }).reason,
90
+ })),
91
+ run.id,
92
+ );
93
+ if (judged) {
94
+ console.log("[Bugmole] Recorded " + judged.judged + " case verdicts for " + judged.caseSetId);
95
+ }
96
+ } catch (error) {
97
+ // Never fail a completed run over its bookkeeping — the run really
98
+ // did happen. The flow stays unjudged and gets picked up again.
99
+ console.warn(
100
+ "[Bugmole] Could not record verdicts for " + run.journeyId + ":",
101
+ error instanceof Error ? error.message : error,
102
+ );
103
+ }
104
+ }
105
+ } catch (error) {
106
+ const message = error instanceof Error ? error.message : "Worker execution failed";
107
+ await client.updateRun(run.id, {
108
+ workerId,
109
+ status: "error",
110
+ eventType: "failed",
111
+ errorMessage: message,
112
+ }).catch(() => undefined);
113
+ // A run that throws records no verdicts, so its flow stays unjudged and
114
+ // the pipeline queues it again — which is right for a flaky driver and
115
+ // catastrophic for a broken one, because nothing ever breaks the cycle.
116
+ //
117
+ // Retry a bounded number of times, then judge the flow blocked with the
118
+ // real error so the pipeline converges and surfaces it instead of
119
+ // spinning. Held in memory on purpose: restarting `bugmole serve` is a
120
+ // deliberate "try again", and should not inherit an old grudge.
121
+ if (run.journeyId) {
122
+ const failures = (runFailures.get(run.journeyId) ?? 0) + 1;
123
+ runFailures.set(run.journeyId, failures);
124
+ if (failures >= MAX_TASK_ATTEMPTS) {
125
+ await recordRunVerdicts(
126
+ cfg,
127
+ projectId,
128
+ run.journeyId,
129
+ [],
130
+ run.id,
131
+ `The run failed ${failures} times and never reached this step. Last error: ${message}`,
132
+ ).catch(() => undefined);
133
+ console.warn(
134
+ "[Bugmole] " + run.journeyId + " failed " + failures
135
+ + " runs; its cases are recorded blocked so the pipeline can move on.",
136
+ );
137
+ }
138
+ }
139
+ } finally {
140
+ active.delete(run.id);
141
+ }
142
+ };
143
+
144
+ // Previously, queuing an agent task only wrote a database row — nothing
145
+ // claimed or ran it until a human separately went and typed into their own
146
+ // coding-agent chat. This makes the worker do for tasks what it already
147
+ // did for runs: claim and execute the next queued one on its own.
148
+ let taskInFlight = false;
149
+ let lastPipelineReason = "";
150
+ const pollTasks = async () => {
151
+ if (taskInFlight) return;
152
+ taskInFlight = true;
153
+ try {
154
+ const { task } = await runNextAgentTask({
155
+ registryUrl,
156
+ apiKey,
157
+ projectId,
158
+ workerId,
159
+ artifactsDir: cfg.runtime?.artifacts_dir || "./artifacts",
160
+ projectDirectory: cfg.cursor_driver?.project_directory,
161
+ });
162
+ if (task) console.log(`[Bugmole] Worker ran agent task ${task.id}`);
163
+
164
+ // Advance the pipeline once the queue is idle. Running this inside
165
+ // the same in-flight guard means a stage is only ever queued when
166
+ // nothing else is executing, so stages can't race each other.
167
+ if (!task) {
168
+ const { state, enqueued, startedRun } = await reconcilePipeline(cfg, client);
169
+ if (enqueued) {
170
+ console.log(`[Bugmole] Pipeline queued ${enqueued.stage}${enqueued.journeyId ? ` for ${enqueued.journeyId}` : ""}`);
171
+ lastPipelineReason = "";
172
+ } else if (startedRun) {
173
+ console.log(`[Bugmole] Pipeline started run of ${startedRun.planId} for ${startedRun.journeyId}`);
174
+ lastPipelineReason = "";
175
+ } else if (state.reason !== lastPipelineReason) {
176
+ // Only log a standing state once, or an idle project would print
177
+ // the same line every 5 seconds.
178
+ console.log(`[Bugmole] Pipeline ${state.status}: ${state.reason}`);
179
+ lastPipelineReason = state.reason;
180
+ }
181
+ }
182
+ } catch (error) {
183
+ console.warn(`[Bugmole] Worker task poll failed: ${error instanceof Error ? error.message : error}`);
184
+ } finally {
185
+ taskInFlight = false;
186
+ }
187
+ };
188
+
189
+ // Discovery needs only a browser, so it runs even where agent tasks are off.
190
+ let discoveryInFlight = false;
191
+ const pollDiscovery = async () => {
192
+ if (discoveryInFlight) return;
193
+ discoveryInFlight = true;
194
+ try {
195
+ const { tasks } = await client.listTasks();
196
+ const next = tasks.find((task) => task.taskType === DISCOVERY_TASK_TYPE && task.executionMode !== "cloud" && isClaimableTask(task));
197
+ if (next) {
198
+ console.log(`[Bugmole] Exploring for discovery task ${next.id}`);
199
+ await runDiscoveryTask(cfg, client, next, workerId);
200
+ }
201
+ } catch (error) {
202
+ console.warn(`[Bugmole] Discovery poll failed: ${error instanceof Error ? error.message : error}`);
203
+ } finally {
204
+ discoveryInFlight = false;
205
+ }
206
+ };
207
+
208
+ const poll = async () => {
209
+ try {
210
+ const { runs } = await client.listRuns();
211
+ for (const run of runs.filter((item) => item.status === "queued" && item.executionMode !== "cloud")) {
212
+ if (active.size >= maxConcurrentRuns) break;
213
+ void processRun(run);
214
+ }
215
+ } catch (error) {
216
+ console.warn(`[Bugmole] Worker registry poll failed: ${error instanceof Error ? error.message : error}`);
217
+ }
218
+ void pollDiscovery();
219
+ if (tasksEnabled) void pollTasks();
220
+ };
221
+
222
+ let socket: WebSocket | undefined;
223
+ let reconnectTimer: NodeJS.Timeout | undefined;
224
+ const gatewayUrl = process.env.BUGMOLE_GATEWAY_URL?.trim();
225
+ const gatewaySecret = process.env.BUGMOLE_GATEWAY_SECRET?.trim();
226
+ const gatewayAvailable = async (): Promise<boolean> => {
227
+ if (!gatewayUrl || !gatewaySecret) return false;
228
+ try {
229
+ const response = await fetch(`${gatewayUrl.replace(/^ws/, "http")}/health`, {
230
+ signal: AbortSignal.timeout(500),
231
+ });
232
+ return response.ok;
233
+ } catch {
234
+ return false;
235
+ }
236
+ };
237
+ const connect = async () => {
238
+ socket?.close();
239
+ const onMessage = (message: Record<string, unknown>) => {
240
+ if (message.type === "job_queued" && typeof message.runId === "string") {
241
+ void poll();
242
+ }
243
+ // A queued discovery starts right away instead of on the next poll.
244
+ if (message.type === "task_event" && message.status === "queued") {
245
+ void pollDiscovery();
246
+ }
247
+ };
248
+ if (await gatewayAvailable()) {
249
+ socket = connectServeGateway(
250
+ gatewayUrl!,
251
+ gatewaySecret!,
252
+ {
253
+ workerId,
254
+ projectId,
255
+ workspaceId,
256
+ registryUrl,
257
+ apiKey,
258
+ },
259
+ onMessage,
260
+ );
261
+ } else {
262
+ socket = client.connectWorker(workerId, onMessage);
263
+ }
264
+ socket.on("close", () => {
265
+ reconnectTimer = setTimeout(() => void connect(), 2_000);
266
+ });
267
+ socket.on("error", () => undefined);
268
+ };
269
+ // Publish the project's spec once at startup so the dashboard shows this
270
+ // project's real roles and blockers rather than falling back to fixtures.
271
+ await publishProjectSpec(cfg).catch((error) => {
272
+ console.warn(`[Bugmole] Could not publish project spec: ${error instanceof Error ? error.message : error}`);
273
+ return null;
274
+ });
275
+ void connect();
276
+ await poll();
277
+ setInterval(() => void poll(), 5_000);
278
+ }
@@ -0,0 +1,168 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import http from "node:http";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { chromium } from "playwright";
8
+ import { discoverableRoute, discoverSite, flowsFor, slugify } from "./site-discovery.js";
9
+ import { discoveryUrl, runDiscoveryTask } from "./discovery-task.js";
10
+ import { PlaywrightDriver } from "./playwright-driver.js";
11
+ import { startLocalRegistryStub } from "./local-registry-stub.js";
12
+ import { ControlPlaneClient } from "../registry/control-plane-client.js";
13
+
14
+ const pages: Record<string, string> = {
15
+ "/": `<title>Acme</title><nav><a href="/pricing">Pricing</a> <a href="/signup">Sign up</a> <a href="/logout">Log out</a> <a href="https://elsewhere.example/">Blog</a></nav><h1>Welcome to Acme</h1>`,
16
+ "/pricing": `<title>Pricing · Acme</title><a href="/">Home</a><h1>Plans for every team</h1>`,
17
+ "/signup": `<title>Sign up · Acme</title><h1>Create your account</h1>
18
+ <form aria-label="Registration"><label>Email <input type="email" required></label><label>Password <input type="password" required></label><button type="submit">Create account</button></form>`,
19
+ "/logout": `<title>Bye</title><h1>You are signed out</h1>`,
20
+ };
21
+
22
+ async function withSite(run: (baseUrl: string, hits: string[]) => Promise<void>) {
23
+ const hits: string[] = [];
24
+ const server = http.createServer((request, response) => {
25
+ const route = new URL(request.url ?? "/", "http://x").pathname;
26
+ hits.push(route);
27
+ const body = pages[route];
28
+ response.writeHead(body ? 200 : 404, { "content-type": "text/html" });
29
+ response.end(`<!doctype html><html><body>${body ?? "Not found"}</body></html>`);
30
+ });
31
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
32
+ try {
33
+ await run(`http://127.0.0.1:${(server.address() as { port: number }).port}/`, hits);
34
+ } finally {
35
+ server.close();
36
+ }
37
+ }
38
+
39
+ async function chromiumInstalled() {
40
+ try {
41
+ await fs.access(chromium.executablePath());
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ test("only same-origin page links are followed", () => {
49
+ const base = "https://app.example.com/";
50
+ assert.equal(discoverableRoute("/pricing/", base), "/pricing");
51
+ assert.equal(discoverableRoute("https://app.example.com/?tab=1#x", base), "/");
52
+ assert.equal(discoverableRoute("https://other.example.com/", base), null);
53
+ assert.equal(discoverableRoute("mailto:hi@example.com", base), null);
54
+ assert.equal(discoverableRoute("/logo.png", base), null);
55
+ assert.equal(slugify("Sign up · Acme!"), "sign-up-acme");
56
+ assert.equal(discoveryUrl({ description: "Explore https://app.example.com/start please" }), "https://app.example.com/start");
57
+ assert.equal(discoveryUrl({ description: "javascript:alert(1)" }), null);
58
+ });
59
+
60
+ test("forms become fill-in flows that never submit, and passwords are referenced", () => {
61
+ const flows = flowsFor("https://app.example.com/", [{
62
+ route: "/signup",
63
+ url: "https://app.example.com/signup",
64
+ title: "Sign up",
65
+ heading: "Create your account",
66
+ path: ["Sign up"],
67
+ links: [],
68
+ forms: [{ name: "Registration", submit: "Create account", fields: [{ label: "Email", type: "email", required: true }, { label: "Password", type: "password", required: true }] }],
69
+ }]);
70
+ const form = flows.find((flow) => flow.kind === "form")!;
71
+ assert.match(form.content, /tapOn: Sign up/);
72
+ assert.match(form.content, /inputText: qa@example\.com/);
73
+ assert.match(form.content, /\{\{secret\.APP_PASSWORD\}\}/);
74
+ assert.match(form.content, /assertClickable: Create account/);
75
+ assert.doesNotMatch(form.content, /tapOn: Create account/);
76
+ assert.ok(form.steps.includes("Type the password"));
77
+ });
78
+
79
+ test("explores a site from its URL like a visitor, and the flows it writes pass", async (t) => {
80
+ if (!(await chromiumInstalled())) {
81
+ t.skip("Chromium is not installed");
82
+ return;
83
+ }
84
+ await withSite(async (baseUrl, hits) => {
85
+ const events: string[] = [];
86
+ const result = await discoverSite(baseUrl, { dwellMs: 0, onEvent: (event) => { events.push(event.text); } });
87
+ assert.deepEqual(result.pages.map((page) => page.route).sort(), ["/", "/pricing", "/signup"]);
88
+ assert.ok(!hits.includes("/logout"), "never opens a sign-out link");
89
+ assert.ok(events.includes("Visiting home page"));
90
+ assert.ok(events.includes("Clicking “Pricing”"));
91
+ assert.ok(events.some((text) => text.startsWith("Skipping “Log out”")));
92
+ assert.ok(events.includes("Found a form: Registration (Email, Password)"));
93
+ assert.equal(events.at(-1), "Exploration complete");
94
+ assert.deepEqual(result.pages.find((page) => page.route === "/signup")?.path, ["Sign up"]);
95
+
96
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-discovered-"));
97
+ process.env.BUGMOLE_FLOW_SECRET_APP_PASSWORD = "discovered-secret";
98
+ try {
99
+ for (const flow of result.flows) {
100
+ const flowPath = path.join(directory, `${flow.id}.yaml`);
101
+ await fs.writeFile(flowPath, flow.content);
102
+ const run = await new PlaywrightDriver({ headless: true }).run({
103
+ runId: "run-discovered",
104
+ projectId: "demo",
105
+ journeyId: flow.id,
106
+ flowPath,
107
+ platform: "web",
108
+ target: {},
109
+ timeoutMs: 20_000,
110
+ artifactsDir: directory,
111
+ });
112
+ assert.equal(run.status, "passed", `${flow.id}: ${run.message}`);
113
+ }
114
+ } finally {
115
+ delete process.env.BUGMOLE_FLOW_SECRET_APP_PASSWORD;
116
+ await fs.rm(directory, { recursive: true, force: true });
117
+ }
118
+ });
119
+ });
120
+
121
+ test("a discovery task streams progress, then publishes plans and a valid journey graph", async (t) => {
122
+ if (!(await chromiumInstalled())) {
123
+ t.skip("Chromium is not installed");
124
+ return;
125
+ }
126
+ await withSite(async (baseUrl) => {
127
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-discovery-task-"));
128
+ const stub = await startLocalRegistryStub({ directory: path.join(directory, "registry") });
129
+ try {
130
+ const client = new ControlPlaneClient({ registryUrl: stub.origin, apiKey: "local", projectId: "demo" });
131
+ const { task } = await client.createTask({ title: "Explore", description: `Explore ${baseUrl}`, taskType: "discovery", idempotencyKey: "discover-1" });
132
+ const cfg = {
133
+ project: { id: "demo" },
134
+ runtime: { spec_dir: path.join(directory, "spec"), artifacts_dir: path.join(directory, "artifacts") },
135
+ storage: { provider: "local" },
136
+ };
137
+ const updates: Array<Record<string, unknown>> = [];
138
+ const recording = {
139
+ claimTask: client.claimTask.bind(client),
140
+ publishPlan: client.publishPlan.bind(client),
141
+ updateTask: async (id: string, update: Record<string, unknown>) => {
142
+ updates.push(update);
143
+ return client.updateTask(id, update);
144
+ },
145
+ };
146
+ await runDiscoveryTask(cfg, recording, task, "worker-1");
147
+
148
+ const final = updates.at(-1)!;
149
+ assert.equal(final.status, "completed", String(final.errorMessage));
150
+ const progress = final.continuation as { events: Array<{ text: string }>; flows: Array<{ planId?: string }> };
151
+ assert.equal(progress.events.at(-1)?.text, "Exploration complete");
152
+ assert.ok(progress.flows.length >= 3 && progress.flows.every((flow) => flow.planId));
153
+ assert.ok(updates.some((update) => update.status === "running" && String(update.progressMessage).startsWith("Clicking")));
154
+
155
+ const { plans } = await client.listPlans();
156
+ assert.equal(plans.length, progress.flows.length);
157
+ const graph = JSON.parse(await fs.readFile(path.join(directory, "spec", "journeys.graph.json"), "utf8"));
158
+ assert.ok(graph.nodes.some((node: { id: string }) => node.id === "screen:/signup"));
159
+ assert.ok(graph.edges.some((edge: { label?: string }) => edge.label === "Sign up"));
160
+ const plan = await fs.readFile(path.join(directory, "spec", "plans", "discovered-home.yaml"), "utf8");
161
+ assert.match(plan, /flow: flows\/discovered\/discovered-home\.yaml/);
162
+ await fs.access(path.join(directory, "artifacts", "projects", "demo", "spec", "flows", "discovered", "discovered-home.yaml"));
163
+ } finally {
164
+ await stub.close();
165
+ await fs.rm(directory, { recursive: true, force: true });
166
+ }
167
+ });
168
+ });