@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,284 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import type { ControlPlaneClient, RegistryTask } from "../registry/control-plane-client.js";
5
+ import { createProjectObjectStore } from "../storage/create-object-store.js";
6
+ import { projectPrefix } from "../storage/keys.js";
7
+ import { readJourneyGraph, writeJourneyGraph } from "./journey-editor.js";
8
+ import type { JourneyEdge, JourneyNode, UserJourney } from "./journey-graph.js";
9
+ import { discoverSite, type DiscoveryResult } from "./site-discovery.js";
10
+ import { explorationPrompt, parseExploration, type ExplorationPersona } from "./ai-exploration.js";
11
+ import { modelCaller } from "./platform-ai.js";
12
+
13
+ /** Discovery tasks queued as "Explore with AI …" also have a model write multi-page journeys. */
14
+ export function discoveryUsesAi(task: Pick<RegistryTask, "title">): boolean {
15
+ return /^Explore with AI\b/i.test(task.title ?? "");
16
+ }
17
+
18
+ const PERSONA_MARKER = /<!-- persona:([A-Za-z0-9+/=]+) -->/;
19
+
20
+ /**
21
+ * base64 via TextEncoder/btoa rather than Buffer, so the exact same scheme
22
+ * works in the worker runtime and in the dashboard's browser bundle (which
23
+ * constructs this same marker before the task is even created — see
24
+ * apps/qa/src/lib/discovery-persona.ts).
25
+ */
26
+ function toBase64(text: string): string {
27
+ return btoa(String.fromCharCode(...new TextEncoder().encode(text)));
28
+ }
29
+ function fromBase64(encoded: string): string {
30
+ return new TextDecoder().decode(Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0)));
31
+ }
32
+
33
+ /** Packs the chosen persona into the task description, base64'd so its text can't break the marker. */
34
+ export function withPersonaMarker(description: string, persona: ExplorationPersona | null | undefined): string {
35
+ if (!persona) return description;
36
+ return `${description}\n\n<!-- persona:${toBase64(JSON.stringify(persona))} -->`;
37
+ }
38
+
39
+ /** The persona a discovery task was queued with, if any. */
40
+ export function discoveryPersona(task: Pick<RegistryTask, "description">): ExplorationPersona | null {
41
+ const match = PERSONA_MARKER.exec(task.description ?? "");
42
+ if (!match) return null;
43
+ try {
44
+ const parsed = JSON.parse(fromBase64(match[1])) as Partial<ExplorationPersona>;
45
+ return typeof parsed.name === "string" && typeof parsed.description === "string" ? { name: parsed.name, description: parsed.description } : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export const DISCOVERY_TASK_TYPE = "discovery";
52
+
53
+ /** The URL a discovery task was queued for: the first http(s) URL in its description. */
54
+ export function discoveryUrl(task: Pick<RegistryTask, "description">): string | null {
55
+ const match = /https?:\/\/[^\s"'<>]+/.exec(task.description ?? "");
56
+ if (!match) return null;
57
+ try {
58
+ const url = new URL(match[0]);
59
+ return /^https?:$/.test(url.protocol) ? url.toString() : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** What the dashboard shows while discovery runs; sent in full on every update. */
66
+ export type DiscoveryProgress = {
67
+ kind: "discovery";
68
+ url: string;
69
+ events: DiscoveryResult["events"];
70
+ pages: Array<{ route: string; title: string; heading?: string; forms: number }>;
71
+ flows: Array<{ id: string; name: string; kind: "page" | "form" | "journey"; route: string; steps: string[]; planId?: string }>;
72
+ };
73
+
74
+ export function progressOf(url: string, result: DiscoveryResult, planIds: Record<string, string> = {}): DiscoveryProgress {
75
+ return {
76
+ kind: "discovery",
77
+ url,
78
+ // The last 40 events are plenty for a live log and keep updates small.
79
+ events: result.events.slice(-40),
80
+ pages: result.pages.map((page) => ({ route: page.route, title: page.title, ...(page.heading ? { heading: page.heading } : {}), forms: page.forms.length })),
81
+ flows: result.flows.map((flow) => ({ id: flow.id, name: flow.name, kind: flow.kind, route: flow.route, steps: flow.steps, ...(planIds[flow.id] ? { planId: planIds[flow.id] } : {}) })),
82
+ };
83
+ }
84
+
85
+ type WorkerConfig = { project?: { id?: string }; runtime?: { spec_dir?: string; artifacts_dir?: string }; storage?: unknown };
86
+
87
+ /**
88
+ * Saves what discovery found as the project's own flows and plans, publishes
89
+ * the plans so the dashboard can run them, and adds the pages and clicks to
90
+ * the journey graph. Returns the plan id published for each flow.
91
+ */
92
+ export async function saveDiscovery(cfg: WorkerConfig, client: Pick<ControlPlaneClient, "publishPlan">, result: DiscoveryResult): Promise<Record<string, string>> {
93
+ const projectId = cfg.project?.id ?? "default";
94
+ const specDir = cfg.runtime?.spec_dir || "./spec";
95
+ const store = createProjectObjectStore(cfg as never);
96
+ const planIds: Record<string, string> = {};
97
+
98
+ for (const flow of result.flows) {
99
+ const flowFile = path.join(specDir, flow.file);
100
+ await fs.mkdir(path.dirname(flowFile), { recursive: true });
101
+ await fs.writeFile(flowFile, flow.content, "utf8");
102
+ await store.put(`${projectPrefix(projectId)}/spec/${flow.file}`, flow.content, "text/yaml");
103
+
104
+ const planId = flow.id;
105
+ const plan = YAML.stringify({
106
+ suites: [{
107
+ journeys: [{
108
+ id: flow.id,
109
+ name: flow.name,
110
+ driver: "playwright",
111
+ platform: "web",
112
+ flow: flow.file,
113
+ steps: flow.steps.map((label, index) => ({ node_id: `step-${index + 1}`, action: label })),
114
+ }],
115
+ }],
116
+ });
117
+ const planFile = path.join(specDir, "plans", `${planId}.yaml`);
118
+ await fs.mkdir(path.dirname(planFile), { recursive: true });
119
+ await fs.writeFile(planFile, plan, "utf8");
120
+ const objectKey = `${projectPrefix(projectId)}/plans/${planId}.flow.yaml`;
121
+ await store.put(objectKey, plan, "text/yaml");
122
+ await client.publishPlan({ planId, journeyId: flow.id, objectKey });
123
+ planIds[flow.id] = planId;
124
+ }
125
+
126
+ const graph = readJourneyGraph(cfg as never);
127
+ const nodeId = (route: string) => `screen:${route}`;
128
+ const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
129
+ for (const page of result.pages) {
130
+ const existing = nodes.get(nodeId(page.route));
131
+ nodes.set(nodeId(page.route), {
132
+ ...(existing ?? { kind: "screen" as const }),
133
+ id: nodeId(page.route),
134
+ title: existing?.title ?? (page.heading || page.title || page.route),
135
+ kind: existing?.kind ?? "screen",
136
+ route: page.route,
137
+ metadata: { ...(existing?.metadata ?? {}), discoveredBy: "site-discovery", forms: page.forms.map((form) => form.name) },
138
+ } as JourneyNode);
139
+ }
140
+ const edges = new Map(graph.edges.map((edge) => [edge.id, edge]));
141
+ for (const page of result.pages) {
142
+ for (const link of page.links) {
143
+ if (!result.pages.some((other) => other.route === link.route)) continue;
144
+ const id = `${nodeId(page.route)}->${nodeId(link.route)}:click`;
145
+ edges.set(id, { ...(edges.get(id) ?? {}), id, from: nodeId(page.route), to: nodeId(link.route), label: link.label } as JourneyEdge);
146
+ }
147
+ }
148
+ const journeys = new Map(graph.journeys.map((journey) => [journey.id, journey]));
149
+ for (const flow of result.flows) {
150
+ const page = result.pages.find((candidate) => candidate.route === flow.route);
151
+ if (!page) continue;
152
+ // The pages a visitor passes through: home, then each click on the path.
153
+ let current = result.pages.find((candidate) => candidate.path.length === 0);
154
+ const routes = current ? [current.route] : [];
155
+ for (const label of page.path) {
156
+ const link = current?.links.find((candidate) => candidate.label === label);
157
+ if (!link) break;
158
+ routes.push(link.route);
159
+ current = result.pages.find((candidate) => candidate.route === link.route);
160
+ }
161
+ const nodeIds = [...new Set(routes.map((route) => nodeId(route)))].filter((id) => nodes.has(id));
162
+ const edgeIds = nodeIds.slice(1).map((id, index) => `${nodeIds[index]}->${id}:click`).filter((id) => edges.has(id));
163
+ const previous = journeys.get(flow.id);
164
+ journeys.set(flow.id, {
165
+ id: flow.id,
166
+ name: flow.name,
167
+ actor: "visitor",
168
+ startNodeId: nodeIds[0],
169
+ nodeIds,
170
+ edgeIds,
171
+ revision: (previous?.revision ?? 0) + 1,
172
+ status: "draft",
173
+ sourceOfTruth: "canonical_flow",
174
+ stability: "discovered",
175
+ linkedToGraph: false,
176
+ metadata: { generatedBy: "site-discovery", flow: flow.file, planId: planIds[flow.id], kind: flow.kind },
177
+ } as UserJourney);
178
+ }
179
+ await writeJourneyGraph(cfg as never, {
180
+ ...graph,
181
+ nodes: [...nodes.values()],
182
+ edges: [...edges.values()],
183
+ journeys: [...journeys.values()],
184
+ });
185
+ return planIds;
186
+ }
187
+
188
+ /**
189
+ * Runs one queued discovery task end to end. Progress is streamed to the
190
+ * registry on every event, so the dashboard shows the exploration live.
191
+ */
192
+ export async function runDiscoveryTask(
193
+ cfg: WorkerConfig,
194
+ client: Pick<ControlPlaneClient, "claimTask" | "updateTask" | "publishPlan">,
195
+ task: RegistryTask,
196
+ workerId: string,
197
+ discover: typeof discoverSite = discoverSite,
198
+ options: {
199
+ /** A cloud runner's task was already claimed by the dispatcher. */
200
+ claimed?: boolean;
201
+ /** Called once if the registry answers an update with `stop`. */
202
+ onStop?: (signal: { reason: string; message: string }) => void;
203
+ } = {},
204
+ ): Promise<void> {
205
+ const url = discoveryUrl(task);
206
+ if (!options.claimed) {
207
+ try {
208
+ await client.claimTask(task.id, workerId);
209
+ } catch {
210
+ return;
211
+ }
212
+ }
213
+ let stopped = false;
214
+ const watchStop = (reply: unknown) => {
215
+ const body = reply as { stop?: unknown; reason?: unknown; message?: unknown } | null;
216
+ if (!body || body.stop !== true || stopped) return;
217
+ stopped = true;
218
+ options.onStop?.({
219
+ reason: typeof body.reason === "string" ? body.reason : "stopped",
220
+ message: typeof body.message === "string" ? body.message : "The registry stopped this task.",
221
+ });
222
+ };
223
+ if (!url) {
224
+ await client.updateTask(task.id, { workerId, status: "error", errorMessage: "No http(s) URL to explore in this task." });
225
+ return;
226
+ }
227
+ let latest: DiscoveryProgress = progressOf(url, { baseUrl: url, pages: [], flows: [], events: [] });
228
+ let sending: Promise<unknown> = Promise.resolve();
229
+ const send = (status: string, extra: Record<string, unknown> = {}) => {
230
+ const snapshot = latest;
231
+ const lastEvent = snapshot.events[snapshot.events.length - 1];
232
+ sending = sending.then(() => client.updateTask(task.id, {
233
+ workerId,
234
+ status,
235
+ progressMessage: lastEvent?.text ?? "Starting the browser…",
236
+ continuation: snapshot,
237
+ ...extra,
238
+ }).then(watchStop).catch(() => undefined));
239
+ return sending;
240
+ };
241
+ const heartbeat = setInterval(() => void send("running"), 20_000);
242
+ heartbeat.unref?.();
243
+ try {
244
+ await send("running");
245
+ const result = await discover(url, {
246
+ onEvent: async (_event, snapshot) => {
247
+ latest = progressOf(url, snapshot);
248
+ await send("running");
249
+ },
250
+ });
251
+ if (discoveryUsesAi(task)) {
252
+ const caller = modelCaller(cfg, { projectId: cfg.project?.id ?? "default", requestId: task.id });
253
+ const note = (text: string, kind: "flows" | "error") => {
254
+ result.events.push({ at: new Date().toISOString(), kind, text });
255
+ latest = progressOf(url, result);
256
+ return send("running");
257
+ };
258
+ if (!caller) {
259
+ await note("AI exploration needs a model: connect a model key or Bugmole AI.", "error");
260
+ } else {
261
+ const persona = discoveryPersona(task);
262
+ await note(persona ? `Asking AI for multi-page journeys as ${persona.name}…` : "Asking AI for multi-page journeys…", "flows");
263
+ try {
264
+ const reply = await caller(explorationPrompt(result, persona ?? undefined), "exploration");
265
+ const journeys = parseExploration(reply.text, result.baseUrl, result.flows);
266
+ result.flows.push(...journeys);
267
+ await note(`AI wrote ${journeys.length} journey${journeys.length === 1 ? "" : "s"}`, "flows");
268
+ } catch (error) {
269
+ await note(`AI exploration failed: ${error instanceof Error ? error.message : String(error)}`, "error");
270
+ }
271
+ }
272
+ }
273
+ const planIds = await saveDiscovery(cfg, client, result);
274
+ latest = progressOf(url, result, planIds);
275
+ await send("completed", {
276
+ completionSummary: `Explored ${result.pages.length} page(s) of ${url} and discovered ${result.flows.length} flow(s).`,
277
+ });
278
+ } catch (error) {
279
+ await send("error", { errorMessage: error instanceof Error ? error.message : String(error) });
280
+ } finally {
281
+ clearInterval(heartbeat);
282
+ await sending;
283
+ }
284
+ }
@@ -0,0 +1,69 @@
1
+ import { llmStructured } from "./llm.js";
2
+ import type { AppDriver, DriverContext, DriverResult } from "./driver.js";
3
+
4
+ export type RecoveryPolicy = {
5
+ enabled?: boolean;
6
+ maxAttempts?: number;
7
+ timeoutMs?: number;
8
+ };
9
+
10
+ export type RecoveryResult = {
11
+ result: DriverResult;
12
+ attempts: number;
13
+ explanation?: string;
14
+ };
15
+
16
+ type RecoveryDecision = {
17
+ retry?: boolean;
18
+ reason?: string;
19
+ };
20
+
21
+ function parseDecision(text: string): RecoveryDecision {
22
+ try {
23
+ const parsed = JSON.parse(text) as RecoveryDecision;
24
+ return { retry: parsed.retry === true, reason: parsed.reason };
25
+ } catch {
26
+ return { retry: false, reason: "LLM recovery response was not valid JSON." };
27
+ }
28
+ }
29
+
30
+ export async function recoverDriverFailure(
31
+ driver: AppDriver,
32
+ context: DriverContext,
33
+ initial: DriverResult,
34
+ policy: RecoveryPolicy = {},
35
+ ): Promise<RecoveryResult> {
36
+ if (!policy.enabled || !initial.recoverable) return { result: initial, attempts: 0 };
37
+ const maxAttempts = Math.max(0, Math.min(policy.maxAttempts ?? 1, 3));
38
+ let result = initial;
39
+ let attempts = 0;
40
+ let explanation: string | undefined;
41
+
42
+ while (attempts < maxAttempts && result.status !== "passed") {
43
+ attempts++;
44
+ try {
45
+ const response = await llmStructured(
46
+ [
47
+ "Classify whether this application E2E failure is safe to retry once.",
48
+ "Return JSON only: {\"retry\": boolean, \"reason\": string}.",
49
+ "Only retry transient UI/driver blockers. Never retry destructive or invariant failures.",
50
+ `Driver: ${driver.id}`,
51
+ `Platform: ${context.platform}`,
52
+ `Failure code: ${result.failureCode || "unknown"}`,
53
+ `Failure message: ${result.message}`,
54
+ `stderr: ${(result.stderr || "").slice(0, 4000)}`,
55
+ ].join("\n"),
56
+ { timeout: policy.timeoutMs ?? 30_000 },
57
+ );
58
+ const decision = parseDecision(response.text);
59
+ explanation = decision.reason;
60
+ if (!decision.retry) break;
61
+ result = await driver.run(context);
62
+ } catch (error) {
63
+ explanation = error instanceof Error ? error.message : "LLM recovery failed";
64
+ break;
65
+ }
66
+ }
67
+
68
+ return { result, attempts, explanation };
69
+ }
@@ -0,0 +1,79 @@
1
+ import type { RunTarget } from "./browser-matrix.js";
2
+
3
+ export type DriverPlatform = "web" | "android" | "ios";
4
+
5
+ export type DriverTarget = {
6
+ baseUrl?: string;
7
+ appId?: string;
8
+ deviceId?: string;
9
+ serial?: string;
10
+ };
11
+
12
+ export type DriverContext = {
13
+ runId: string;
14
+ projectId: string;
15
+ journeyId: string;
16
+ flowPath: string;
17
+ platform: DriverPlatform;
18
+ target: DriverTarget;
19
+ timeoutMs: number;
20
+ artifactsDir: string;
21
+ environment?: Record<string, string>;
22
+ /**
23
+ * Browser session state (cookies and local storage) shared across the flows
24
+ * of one suite, so a multi-flow journey can stay signed in the way a person
25
+ * would. Drivers that cannot carry session state ignore it.
26
+ */
27
+ sessionStatePath?: string;
28
+ /** Browser/device to run a web flow on. Drivers without a browser matrix ignore it. */
29
+ runTarget?: RunTarget;
30
+ /**
31
+ * When true, an absolute URL in the flow is moved onto target.baseUrl's
32
+ * origin (path kept), so a flow authored against one host runs against the
33
+ * selected environment.
34
+ */
35
+ rebaseOrigin?: boolean;
36
+ };
37
+
38
+ export type DriverArtifact = {
39
+ path: string;
40
+ contentType?: string;
41
+ name?: string;
42
+ };
43
+
44
+ /**
45
+ * Real, independently-observed outcome for a single flow step. Optional:
46
+ * drivers that cannot attribute pass/fail to individual steps simply omit
47
+ * `steps`, and callers must not fabricate one from the aggregate result.
48
+ */
49
+ export type DriverStepResult = {
50
+ sequenceNumber: number;
51
+ status: "passed" | "failed" | "blocked";
52
+ /** What the step does, in words a person reads in a trace. Typed text is masked. */
53
+ label?: string;
54
+ message?: string;
55
+ durationMs?: number;
56
+ timestamp?: string;
57
+ screenshotPath?: string;
58
+ };
59
+
60
+ export type DriverResult = {
61
+ status: "passed" | "failed" | "blocked";
62
+ exitCode?: number;
63
+ message: string;
64
+ stdout?: string;
65
+ stderr?: string;
66
+ artifacts: DriverArtifact[];
67
+ recoverable?: boolean;
68
+ failureCode?: string;
69
+ /** Per-step outcomes, when the driver can observe them independently. */
70
+ steps?: DriverStepResult[];
71
+ /** Wall-clock time the driver spent on the flow. */
72
+ durationMs?: number;
73
+ };
74
+
75
+ export interface AppDriver {
76
+ readonly id: string;
77
+ supports(platform: DriverPlatform): boolean;
78
+ run(context: DriverContext): Promise<DriverResult>;
79
+ }
@@ -0,0 +1,104 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { getEnvironmentContract, setEnvironmentContract } from "./environment.js";
7
+
8
+ test("environment contracts persist runnable access metadata without credentials", async () => {
9
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-environment-"));
10
+ try {
11
+ const cfg = {
12
+ project: { id: "qa-framework" },
13
+ runtime: { artifacts_dir: directory },
14
+ storage: { provider: "local", localDirectory: directory },
15
+ };
16
+ const saved = await setEnvironmentContract(cfg, {
17
+ environmentId: "android-local",
18
+ name: "Lumis Nokia",
19
+ platform: "android",
20
+ baseUrl: "http://127.0.0.1:8083",
21
+ mockUrl: "https://gcore.otlrs.com/lumis-consumer/",
22
+ status: "active",
23
+ access: {
24
+ transport: "adb",
25
+ deviceLabel: "Nokia",
26
+ serialHint: "DRGGAM58B1004777",
27
+ appPackage: "com.lumis.consumer",
28
+ startCommand: "adb -s DRGGAM58B1004777 shell monkey -p com.lumis.consumer 1",
29
+ },
30
+ });
31
+ const loaded = await getEnvironmentContract(cfg, "android-local");
32
+ assert.equal(loaded?.environmentId, saved.environmentId);
33
+ // Regression: setEnvironmentContract's type declared platform/driver/
34
+ // flowPath but the setter silently dropped them before persisting.
35
+ assert.equal(loaded?.platform, "android");
36
+ assert.equal(loaded?.mockUrl, saved.mockUrl);
37
+ assert.equal(loaded?.access.startCommand, saved.access.startCommand);
38
+ assert.equal("apiKey" in saved, false);
39
+ assert.equal("secret" in saved, false);
40
+ } finally {
41
+ await fs.rm(directory, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ test("re-registering the same base URL under a different id reuses the original environment", async () => {
46
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-environment-dedupe-"));
47
+ try {
48
+ const cfg = {
49
+ project: { id: "bugmole" },
50
+ runtime: { artifacts_dir: directory },
51
+ storage: { provider: "local", localDirectory: directory },
52
+ };
53
+ // Exactly the real failure: each exploration run invented a new
54
+ // synonym for the same localhost target, leaving four identical records.
55
+ const first = await setEnvironmentContract(cfg, {
56
+ environmentId: "local",
57
+ name: "Local QA Dashboard",
58
+ baseUrl: "http://localhost:3020",
59
+ access: { transport: "browser" },
60
+ });
61
+ for (const alias of ["local-dev", "browser-local", "local-browser"]) {
62
+ const again = await setEnvironmentContract(cfg, {
63
+ environmentId: alias,
64
+ name: "Local QA Dashboard",
65
+ baseUrl: "http://localhost:3020",
66
+ access: { transport: "browser" },
67
+ });
68
+ assert.equal(again.environmentId, first.environmentId, `${alias} must fold into the canonical id`);
69
+ }
70
+
71
+ const files = await fs.readdir(path.join(directory, "projects", "bugmole", "environments"));
72
+ assert.deepEqual(files, ["local.json"], "only one environment record should exist");
73
+ } finally {
74
+ await fs.rm(directory, { recursive: true, force: true });
75
+ }
76
+ });
77
+
78
+ test("a genuinely different target still registers as its own environment", async () => {
79
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-environment-distinct-"));
80
+ try {
81
+ const cfg = {
82
+ project: { id: "bugmole" },
83
+ runtime: { artifacts_dir: directory },
84
+ storage: { provider: "local", localDirectory: directory },
85
+ };
86
+ await setEnvironmentContract(cfg, {
87
+ environmentId: "local",
88
+ name: "Local",
89
+ baseUrl: "http://localhost:3020",
90
+ access: { transport: "browser" },
91
+ });
92
+ const staging = await setEnvironmentContract(cfg, {
93
+ environmentId: "staging",
94
+ name: "Staging",
95
+ baseUrl: "https://staging.example.com",
96
+ access: { transport: "browser" },
97
+ });
98
+ assert.equal(staging.environmentId, "staging");
99
+ const files = await fs.readdir(path.join(directory, "projects", "bugmole", "environments"));
100
+ assert.equal(files.length, 2);
101
+ } finally {
102
+ await fs.rm(directory, { recursive: true, force: true });
103
+ }
104
+ });
@@ -0,0 +1,137 @@
1
+ import { createProjectObjectStore } from "../storage/create-object-store.js";
2
+ import { projectPrefix } from "../storage/keys.js";
3
+
4
+ export type EnvironmentAccessContract = {
5
+ environmentId: string;
6
+ name: string;
7
+ platform?: "web" | "android" | "ios";
8
+ driver?: string;
9
+ flowPath?: string;
10
+ baseUrl?: string;
11
+ mockUrl?: string;
12
+ status: "active" | "blocked" | "planned";
13
+ access: {
14
+ transport: "adb" | "browser" | "manual" | "unknown";
15
+ deviceLabel?: string;
16
+ serialHint?: string;
17
+ appPackage?: string;
18
+ appId?: string;
19
+ baseUrl?: string;
20
+ startCommand?: string;
21
+ notes?: string;
22
+ };
23
+ lastVerifiedAt?: string;
24
+ blockedReason?: string;
25
+ };
26
+
27
+ function key(cfg: any, environmentId: string): string {
28
+ return `${projectPrefix(cfg.project?.id ?? "default")}/environments/${encodeURIComponent(environmentId)}.json`;
29
+ }
30
+
31
+ function environmentsPrefix(cfg: any): string {
32
+ return `${projectPrefix(cfg.project?.id ?? "default")}/environments/`;
33
+ }
34
+
35
+ /**
36
+ * What actually makes two environment records the same environment: the
37
+ * thing they point at, not the id someone chose for them.
38
+ */
39
+ function environmentTarget(contract: Partial<EnvironmentAccessContract>): string | null {
40
+ const transport = contract.access?.transport ?? "unknown";
41
+ if (transport === "adb") {
42
+ const device = contract.access?.serialHint ?? contract.access?.deviceLabel;
43
+ return device ? `adb:${device}` : null;
44
+ }
45
+ const baseUrl = (contract.baseUrl ?? contract.access?.baseUrl)?.trim().replace(/\/+$/, "");
46
+ return baseUrl ? `url:${baseUrl.toLowerCase()}` : null;
47
+ }
48
+
49
+ /**
50
+ * Find an already-registered environment pointing at the same target.
51
+ *
52
+ * The environment id is agent-chosen, and an LLM picks a different
53
+ * plausible synonym on each run — this project accumulated four identical
54
+ * records (`local`, `local-dev`, `browser-local`, `local-browser`), all
55
+ * http://localhost:3020, one per exploration attempt. Resolving by target
56
+ * keeps re-registration idempotent so a repeating pipeline can't multiply
57
+ * them further.
58
+ */
59
+ export async function findEnvironmentByTarget(
60
+ cfg: any,
61
+ contract: Partial<EnvironmentAccessContract>,
62
+ ): Promise<EnvironmentAccessContract | null> {
63
+ const target = environmentTarget(contract);
64
+ if (!target) return null;
65
+ const store = createProjectObjectStore(cfg);
66
+ let entries: Array<{ key: string }>;
67
+ try {
68
+ entries = await store.list(environmentsPrefix(cfg));
69
+ } catch {
70
+ return null;
71
+ }
72
+ const matches: EnvironmentAccessContract[] = [];
73
+ for (const entry of entries) {
74
+ if (!entry.key.endsWith(".json")) continue;
75
+ try {
76
+ const existing = JSON.parse(await store.getText(entry.key)) as EnvironmentAccessContract;
77
+ if (environmentTarget(existing) === target) matches.push(existing);
78
+ } catch {
79
+ // A malformed record must not block registration.
80
+ }
81
+ }
82
+ if (matches.length === 0) return null;
83
+ // Oldest wins, so the canonical id stays stable once chosen.
84
+ matches.sort((a, b) => (a.lastVerifiedAt ?? "").localeCompare(b.lastVerifiedAt ?? ""));
85
+ return matches[0];
86
+ }
87
+
88
+ export async function getEnvironmentContract(
89
+ cfg: any,
90
+ environmentId: string,
91
+ ): Promise<EnvironmentAccessContract | null> {
92
+ const store = createProjectObjectStore(cfg);
93
+ try {
94
+ return JSON.parse(await store.getText(key(cfg, environmentId))) as EnvironmentAccessContract;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export async function setEnvironmentContract(
101
+ cfg: any,
102
+ input: Partial<EnvironmentAccessContract> & { environmentId: string },
103
+ ): Promise<EnvironmentAccessContract> {
104
+ if (!input.environmentId.trim()) throw new Error("environmentId is required");
105
+ if (!input.name?.trim()) throw new Error("name is required");
106
+ if (input.baseUrl && !/^https?:\/\//.test(input.baseUrl)) throw new Error("baseUrl must be an HTTP(S) URL");
107
+ if (input.mockUrl && !/^https?:\/\//.test(input.mockUrl)) throw new Error("mockUrl must be an HTTP(S) URL");
108
+ // Re-registering the same target updates the record that already exists
109
+ // rather than minting a second one under a new synonym for its id.
110
+ const existing = await findEnvironmentByTarget(cfg, input);
111
+ const canonicalId = existing && existing.environmentId !== input.environmentId.trim()
112
+ ? existing.environmentId
113
+ : input.environmentId.trim();
114
+ const contract: EnvironmentAccessContract = {
115
+ environmentId: canonicalId,
116
+ name: input.name.trim(),
117
+ platform: input.platform,
118
+ driver: input.driver,
119
+ flowPath: input.flowPath,
120
+ baseUrl: input.baseUrl?.trim(),
121
+ mockUrl: input.mockUrl?.trim(),
122
+ status: input.status ?? "planned",
123
+ access: {
124
+ transport: input.access?.transport ?? "unknown",
125
+ deviceLabel: input.access?.deviceLabel,
126
+ serialHint: input.access?.serialHint,
127
+ appPackage: input.access?.appPackage,
128
+ startCommand: input.access?.startCommand,
129
+ notes: input.access?.notes,
130
+ },
131
+ lastVerifiedAt: input.lastVerifiedAt ?? new Date().toISOString(),
132
+ blockedReason: input.blockedReason,
133
+ };
134
+ const store = createProjectObjectStore(cfg);
135
+ await store.put(key(cfg, contract.environmentId), JSON.stringify(contract, null, 2) + "\n", "application/json");
136
+ return contract;
137
+ }