@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,171 @@
1
+ // Routes for hosted runs: the runner's bundle, writing to Bugmole storage,
2
+ // and a project's default place to run.
3
+ import type { Env, Principal } from "../core.js";
4
+ import { memberOfProject, response, requestJson } from "../core.js";
5
+ import { aiAccess } from "../ai/routes.js";
6
+ import { isProjectArtifactKey, ARTIFACT_STORAGE_UNCONFIGURED } from "../artifacts.js";
7
+ import { auditProject } from "../org/audit.js";
8
+ import { matchPath, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
9
+ import { cloudRunMaxMinutes } from "./policy.js";
10
+ import { cloudWorkerId } from "./jobs.js";
11
+ import "./dispatch.js";
12
+ import "./metering.js";
13
+
14
+ /** A Worker request body is capped at 100 MB; stay under it. */
15
+ export const MAX_UPLOAD_BYTES = 95 * 1024 * 1024;
16
+
17
+ // A runner may ask for AI work on its own run only.
18
+ const memberCheck = aiAccess.check;
19
+ aiAccess.check = async (env, principal, projectId, work) => {
20
+ const runner = principal.runner;
21
+ if (!runner) return memberCheck(env, principal, projectId, work);
22
+ if (runner.projectId !== projectId) return false;
23
+ return runner.kind === "run" ? work.runId === runner.id : !work.runId && work.requestId === runner.id;
24
+ };
25
+
26
+ function parseJson(value: string | null): unknown {
27
+ if (!value) return null;
28
+ try {
29
+ return JSON.parse(value);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ async function bundle(env: Env, principal: Principal, runId: string): Promise<Response> {
36
+ if (principal.runner?.kind !== "run" || principal.runner.id !== runId) return response({ error: "Run not found" }, 404);
37
+ const run = await env.DB.prepare(`
38
+ SELECT r.id, r.project_id AS projectId, r.workspace_id AS workspaceId, r.plan_id AS planId,
39
+ r.journey_id AS journeyId, r.status, r.worker_id AS workerId, r.execution_mode AS executionMode,
40
+ r.targets_json AS targetsJson, r.environment_base_url AS environmentBaseUrl, r.fix_from_json AS fixFromJson,
41
+ p.name AS projectName
42
+ FROM runs r JOIN projects p ON p.id = r.project_id
43
+ WHERE r.id = ?
44
+ `).bind(runId).first<Record<string, string | null>>();
45
+ if (!run || run.projectId !== principal.runner.projectId) return response({ error: "Run not found" }, 404);
46
+ if (run.status !== "running" || run.workerId !== `cloud:${runId}`) {
47
+ return response({ error: "This run isn't assigned to a cloud runner", status: run.status }, 409);
48
+ }
49
+ return response({
50
+ run: {
51
+ id: run.id,
52
+ projectId: run.projectId,
53
+ planId: run.planId,
54
+ journeyId: run.journeyId,
55
+ status: run.status,
56
+ executionMode: run.executionMode,
57
+ environmentBaseUrl: run.environmentBaseUrl,
58
+ targets: parseJson(run.targetsJson),
59
+ fixFrom: parseJson(run.fixFromJson),
60
+ },
61
+ project: { id: run.projectId, name: run.projectName, workspaceId: run.workspaceId },
62
+ workerId: run.workerId,
63
+ maxMinutes: cloudRunMaxMinutes(env),
64
+ });
65
+ }
66
+
67
+ async function taskBundle(env: Env, principal: Principal, taskId: string): Promise<Response> {
68
+ if (principal.runner?.kind !== "task" || principal.runner.id !== taskId) return response({ error: "Task not found" }, 404);
69
+ const task = await env.DB.prepare(`
70
+ SELECT t.id, t.project_id AS projectId, t.workspace_id AS workspaceId, t.title, t.description,
71
+ t.task_type AS taskType, t.status, t.worker_id AS workerId, t.attempts, p.name AS projectName
72
+ FROM agent_tasks t JOIN projects p ON p.id = t.project_id
73
+ WHERE t.id = ?
74
+ `).bind(taskId).first<Record<string, string | number | null>>();
75
+ if (!task || task.projectId !== principal.runner.projectId) return response({ error: "Task not found" }, 404);
76
+ if (task.status !== "running" || task.workerId !== cloudWorkerId({ kind: "task", id: taskId })) {
77
+ return response({ error: "This task isn't assigned to a cloud runner", status: task.status }, 409);
78
+ }
79
+ return response({
80
+ task: {
81
+ id: task.id,
82
+ projectId: task.projectId,
83
+ title: task.title,
84
+ description: task.description,
85
+ taskType: task.taskType,
86
+ status: task.status,
87
+ attempts: task.attempts,
88
+ executionMode: "cloud",
89
+ },
90
+ project: { id: task.projectId, name: task.projectName, workspaceId: task.workspaceId },
91
+ workerId: task.workerId,
92
+ maxMinutes: cloudRunMaxMinutes(env),
93
+ });
94
+ }
95
+
96
+ async function managedProject(env: Env, projectId: string): Promise<boolean> {
97
+ const row = await env.DB.prepare("SELECT storage_mode AS storageMode FROM projects WHERE id = ? AND deleted_at IS NULL")
98
+ .bind(projectId).first<{ storageMode: string }>();
99
+ return row?.storageMode === "managed";
100
+ }
101
+
102
+ async function putObject({ env, principal, request, url }: AuthedRouteContext, projectId: string): Promise<Response> {
103
+ if (!env.ARTIFACTS) return response({ error: ARTIFACT_STORAGE_UNCONFIGURED }, 503);
104
+ if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
105
+ if (!(await managedProject(env, projectId))) {
106
+ return response({ error: "This project keeps its files in its own storage, not Bugmole's.", code: "storage_not_managed" }, 409);
107
+ }
108
+ const key = url.searchParams.get("key") ?? "";
109
+ if (!isProjectArtifactKey(projectId, key)) return response({ error: "key must stay under this project's artifacts" }, 400);
110
+ const declared = request.headers.get("content-length");
111
+ if (declared !== null && !(Number(declared) <= MAX_UPLOAD_BYTES)) return response({ error: "The file is larger than 95 MB" }, 413);
112
+ const contentType = request.headers.get("content-type") || "application/octet-stream";
113
+ // R2 needs the length up front: stream when the client sent it, otherwise buffer.
114
+ let body: ReadableStream | ArrayBuffer | null = request.body;
115
+ if (declared === null) {
116
+ body = await request.arrayBuffer();
117
+ if (body.byteLength > MAX_UPLOAD_BYTES) return response({ error: "The file is larger than 95 MB" }, 413);
118
+ }
119
+ const stored = await env.ARTIFACTS.put(key, body, { httpMetadata: { contentType } });
120
+ return response({ key, size: stored?.size ?? Number(declared), contentType }, 201);
121
+ }
122
+
123
+ async function deletePrefix({ env, principal, url, request }: AuthedRouteContext, projectId: string): Promise<Response> {
124
+ if (!env.ARTIFACTS) return response({ error: ARTIFACT_STORAGE_UNCONFIGURED }, 503);
125
+ // Runners are limited to their own run's prefix before routing.
126
+ const permission = principal.runner ? "project.write" : "project.manage";
127
+ if (!(await memberOfProject(env, principal, projectId, permission))) return response({ error: "Project scope is not allowed" }, 403);
128
+ if (!(await managedProject(env, projectId))) return response({ error: "This project keeps its files in its own storage, not Bugmole's.", code: "storage_not_managed" }, 409);
129
+ const prefix = url.searchParams.get("prefix") ?? "";
130
+ if (!prefix.endsWith("/") || !isProjectArtifactKey(projectId, prefix)) {
131
+ return response({ error: "prefix must be a folder under this project's artifacts" }, 400);
132
+ }
133
+ let deleted = 0;
134
+ let cursor: string | undefined;
135
+ do {
136
+ const page = await env.ARTIFACTS.list({ prefix, cursor, limit: 1000 });
137
+ const keys = page.objects.map((object) => object.key);
138
+ if (keys.length) await env.ARTIFACTS.delete(keys);
139
+ deleted += keys.length;
140
+ cursor = page.truncated ? page.cursor : undefined;
141
+ } while (cursor);
142
+ if (!principal.runner) {
143
+ await auditProject(env, principal, projectId, { action: "storage.deleted", targetType: "prefix", targetId: prefix, data: { deleted } }, request);
144
+ }
145
+ return response({ deleted });
146
+ }
147
+
148
+ async function updateSettings({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
149
+ if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
150
+ const body = await requestJson(request);
151
+ const mode = body?.defaultExecutionMode;
152
+ if (mode !== "self" && mode !== "cloud") return response({ error: "defaultExecutionMode must be self or cloud" }, 422);
153
+ await env.DB.prepare("UPDATE projects SET default_execution_mode = ? WHERE id = ?").bind(mode, projectId).run();
154
+ await auditProject(env, principal, projectId, { action: "project.settings_updated", targetType: "project", targetId: projectId, data: { defaultExecutionMode: mode } }, request);
155
+ return response({ project: { id: projectId, defaultExecutionMode: mode } });
156
+ }
157
+
158
+ registerRoutes(async (context) => {
159
+ const { request, url, env, principal } = context;
160
+ const bundleMatch = matchPath(url.pathname, "/api/runner/runs/:runId/bundle");
161
+ if (bundleMatch && request.method === "GET") return bundle(env, principal, bundleMatch.runId);
162
+ const taskBundleMatch = matchPath(url.pathname, "/api/runner/tasks/:taskId/bundle");
163
+ if (taskBundleMatch && request.method === "GET") return taskBundle(env, principal, taskBundleMatch.taskId);
164
+ const objectMatch = matchPath(url.pathname, "/api/projects/:projectId/artifacts/object");
165
+ if (objectMatch && request.method === "PUT") return putObject(context, objectMatch.projectId);
166
+ const prefixMatch = matchPath(url.pathname, "/api/projects/:projectId/artifacts");
167
+ if (prefixMatch && request.method === "DELETE") return deletePrefix(context, prefixMatch.projectId);
168
+ const settingsMatch = matchPath(url.pathname, "/api/projects/:projectId/settings");
169
+ if (settingsMatch && request.method === "PATCH") return updateSettings(context, settingsMatch.projectId);
170
+ return null;
171
+ });
@@ -0,0 +1,358 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { call, createTestEnv, runScheduled, seedPlan, seedProject, seedVerifiedDomain } from "../testing/harness.js";
4
+ import { recordUsage } from "../billing/ledger.js";
5
+ import { runnerMayCall, signRunnerToken, verifyRunnerToken } from "./tokens.js";
6
+ import { dispatchCloudRuns, pickFairly, runnerLauncher, type LaunchRequest } from "./dispatch.js";
7
+ import { billableMinutes } from "./metering.js";
8
+
9
+ const SECRET = { RUNNER_TOKEN_SECRET: "runner-test-secret", FEATURE_CLOUD_RUNNERS: "on", REGISTRY_PUBLIC_URL: "https://registry.test" };
10
+
11
+ function fakeLauncher(fail = 0) {
12
+ const launches: LaunchRequest[] = [];
13
+ let failures = fail;
14
+ const original = runnerLauncher.start;
15
+ runnerLauncher.start = async (_env, launch) => {
16
+ if (failures > 0) {
17
+ failures -= 1;
18
+ throw new Error("no capacity");
19
+ }
20
+ launches.push(launch);
21
+ };
22
+ return { launches, restore: () => { runnerLauncher.start = original; } };
23
+ }
24
+
25
+ async function cloudProject(overrides: Record<string, string> = {}, seed: Parameters<typeof seedProject>[1] = {}) {
26
+ const setup = createTestEnv({ ...SECRET, ...overrides });
27
+ const project = await seedProject(setup.env, { storageProvider: "bugmole", ...seed });
28
+ await seedPlan(setup.env, project.projectId, project.owner);
29
+ // Several of this file's tests explore or run against https://shop.test.
30
+ seedVerifiedDomain(setup.db, project.workspaceId, "shop.test");
31
+ const queue = (key: string, projectId = project.projectId, owner = project.owner) =>
32
+ call(setup.env, "POST", `/api/projects/${projectId}/runs`, {
33
+ as: owner,
34
+ headers: { "x-aegis-email": "owner@acme.test", "x-aegis-email-verified": "true" },
35
+ body: { planId: "checkout", idempotencyKey: key, executionMode: "cloud" },
36
+ });
37
+ return { ...setup, ...project, queue };
38
+ }
39
+
40
+ test("runner tokens name one run, can't be altered and expire", async () => {
41
+ const { env } = createTestEnv(SECRET);
42
+ const identity = { kind: "run" as const, id: "run_1", projectId: "shop", workspaceId: "acme" };
43
+ const token = await signRunnerToken(env, identity, 1_000);
44
+ assert.deepEqual(await verifyRunnerToken(env, token, 2_000), identity);
45
+ assert.equal(await verifyRunnerToken(env, token, 1_000 + 2 * 3_600_000), null, "expired");
46
+ const [payload, signature] = token.slice(4).split(".");
47
+ const forged = Buffer.from(JSON.stringify({ ...identity, id: "run_2", exp: 9e15 })).toString("base64url");
48
+ assert.equal(await verifyRunnerToken(env, `vrt_${forged}.${signature}`, 2_000), null);
49
+ assert.equal(await verifyRunnerToken({ ...env, RUNNER_TOKEN_SECRET: "other" }, token, 2_000), null);
50
+ assert.ok(payload);
51
+
52
+ const allowed = (method: string, path: string) => runnerMayCall(identity, method, new URL(`https://r${path}`));
53
+ assert.equal(allowed("GET", "/api/runner/runs/run_1/bundle"), true);
54
+ assert.equal(allowed("GET", "/api/runner/runs/run_2/bundle"), false);
55
+ assert.equal(allowed("POST", "/api/runs/run_1"), true);
56
+ assert.equal(allowed("POST", "/api/runs/run_1/claim"), false);
57
+ assert.equal(allowed("GET", "/api/projects/shop/artifacts/object?key=projects/shop/plans/a.yaml"), true);
58
+ assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/plans/a.yaml"), false);
59
+ assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/runs/run_1/video.webm"), true);
60
+ assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/runs/run_10/video.webm"), false);
61
+ assert.equal(allowed("GET", "/api/projects/other/artifacts"), false);
62
+ assert.equal(allowed("GET", "/api/projects"), false);
63
+ assert.equal(allowed("POST", "/api/tasks/run_1"), false);
64
+
65
+ const task = { kind: "task" as const, id: "task_1", projectId: "shop", workspaceId: "acme" };
66
+ const taskAllowed = (method: string, path: string) => runnerMayCall(task, method, new URL(`https://r${path}`));
67
+ assert.equal(taskAllowed("GET", "/api/runner/tasks/task_1/bundle"), true);
68
+ assert.equal(taskAllowed("GET", "/api/runner/runs/task_1/bundle"), false);
69
+ assert.equal(taskAllowed("POST", "/api/tasks/task_1"), true);
70
+ assert.equal(taskAllowed("POST", "/api/tasks/task_1/claim"), false);
71
+ assert.equal(taskAllowed("POST", "/api/projects/shop/plans"), true);
72
+ assert.equal(taskAllowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/spec/flows/a.yaml"), true);
73
+ assert.equal(taskAllowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/journeys/graph.json"), true);
74
+ assert.equal(taskAllowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/runs/run_1/x.png"), false);
75
+ assert.equal(taskAllowed("DELETE", "/api/projects/shop/artifacts?prefix=projects/shop/spec/"), false);
76
+ assert.equal(taskAllowed("POST", "/api/runs/task_1"), false);
77
+ });
78
+
79
+ test("a cloud run is started, reads its plan, uploads evidence, and bills registry-measured minutes", async () => {
80
+ const launcher = fakeLauncher();
81
+ try {
82
+ const { env, db, artifacts, projectId, workspaceId, owner, queue } = await cloudProject();
83
+ await artifacts.put(`projects/${projectId}/plans/checkout.flow.yaml`, "suites: []\n");
84
+ const created = await queue("c1");
85
+ assert.equal(created.status, 201);
86
+ const runId = created.json.run.id;
87
+ assert.equal(launcher.launches.length, 1, "queuing starts a runner straight away");
88
+ assert.equal(launcher.launches[0].kind, "run");
89
+ const { token, registryUrl } = launcher.launches[0];
90
+ assert.equal(registryUrl, "https://registry.test");
91
+
92
+ const bundle = await call(env, "GET", `/api/runner/runs/${runId}/bundle`, { token });
93
+ assert.equal(bundle.status, 200);
94
+ assert.equal(bundle.json.workerId, `cloud:${runId}`);
95
+ assert.equal(bundle.json.run.planId, "checkout");
96
+ const plan = await call(env, "GET", `/api/projects/${projectId}/artifacts/object?key=projects/${projectId}/plans/checkout.flow.yaml`, { token });
97
+ assert.equal(plan.json, "suites: []\n");
98
+
99
+ const evidenceKey = `projects/${projectId}/runs/${runId}/artifacts/chromium/screen.png`;
100
+ const upload = await call(env, "PUT", `/api/projects/${projectId}/artifacts/object?key=${evidenceKey}`, {
101
+ token, body: "png-bytes", headers: { "content-type": "image/png", "content-length": "9" },
102
+ });
103
+ assert.equal(upload.status, 201);
104
+ assert.equal(artifacts.objects.get(evidenceKey)?.contentType, "image/png");
105
+ const outside = await call(env, "PUT", `/api/projects/${projectId}/artifacts/object?key=projects/${projectId}/plans/checkout.flow.yaml`, {
106
+ token, body: "x", headers: { "content-length": "1" },
107
+ });
108
+ assert.equal(outside.status, 403, "a runner can't rewrite plans");
109
+ assert.equal((await call(env, "GET", "/api/projects", { token })).status, 403);
110
+ assert.equal((await call(env, "GET", `/api/runner/runs/${runId}/bundle`, { as: owner })).status, 404, "bundles are for runners only");
111
+
112
+ const heartbeat = await call(env, "POST", `/api/runs/${runId}`, { token, body: { workerId: `cloud:${runId}`, status: "running", eventType: "heartbeat" } });
113
+ assert.deepEqual(heartbeat.json, { updated: true });
114
+ db.db.prepare("UPDATE runs SET claimed_at = ? WHERE id = ?").run(new Date(Date.now() - 150_000).toISOString(), runId);
115
+ const done = await call(env, "POST", `/api/runs/${runId}`, { token, body: { workerId: `cloud:${runId}`, status: "success", eventType: "completed" } });
116
+ assert.equal(done.status, 200);
117
+ const run = await call(env, "GET", `/api/runs/${runId}`, { as: owner });
118
+ assert.equal(run.json.run.billedMinutes, 3, "2.5 minutes rounds up");
119
+ const usage = await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: owner });
120
+ assert.equal(usage.json.usage.lines.browser_minute.used, 3);
121
+
122
+ // Finishing again (a retried request) doesn't bill twice.
123
+ await call(env, "POST", `/api/runs/${runId}`, { token, body: { workerId: `cloud:${runId}`, status: "success", eventType: "completed" } });
124
+ const again = await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: owner });
125
+ assert.equal(again.json.usage.lines.browser_minute.used, 3);
126
+ } finally {
127
+ launcher.restore();
128
+ }
129
+ });
130
+
131
+ test("discovery runs in the cloud too, publishes what it finds, and is billed", async () => {
132
+ const launcher = fakeLauncher();
133
+ try {
134
+ const { env, db, artifacts, projectId, owner, workspaceId } = await cloudProject();
135
+ const explore = (body: Record<string, unknown>) => call(env, "POST", `/api/projects/${projectId}/tasks`, {
136
+ as: owner, body: { title: "Explore with AI https://shop.test", description: "Explore https://shop.test", taskType: "discovery", idempotencyKey: "d1", ...body },
137
+ });
138
+ assert.equal((await explore({ taskType: "code", idempotencyKey: "c1", executionMode: "cloud" })).status, 422, "coding tasks need your own agent");
139
+ const created = await explore({ executionMode: "cloud" });
140
+ assert.equal(created.status, 201);
141
+ const taskId = created.json.task.id;
142
+ assert.equal(created.json.task.executionMode, "cloud");
143
+ assert.deepEqual(launcher.launches.map((item) => [item.kind, item.id]), [["task", taskId]]);
144
+ const { token } = launcher.launches[0];
145
+
146
+ assert.equal((await call(env, "POST", `/api/tasks/${taskId}/claim`, { as: owner, body: { workerId: "laptop" } })).status, 409);
147
+ const bundle = await call(env, "GET", `/api/runner/tasks/${taskId}/bundle`, { token });
148
+ assert.equal(bundle.status, 200);
149
+ assert.equal(bundle.json.task.description, "Explore https://shop.test");
150
+ assert.equal(bundle.json.workerId, `cloud:task:${taskId}`);
151
+ assert.equal((await call(env, "GET", `/api/runner/runs/${taskId}/bundle`, { token })).status, 403);
152
+
153
+ const flowKey = `projects/${projectId}/spec/flows/discovered/home.yaml`;
154
+ assert.equal((await call(env, "PUT", `/api/projects/${projectId}/artifacts/object?key=${flowKey}`, { token, body: "- launchApp\n", headers: { "content-type": "text/yaml" } })).status, 201);
155
+ assert.ok(artifacts.objects.has(flowKey));
156
+ const published = await call(env, "POST", `/api/projects/${projectId}/plans`, {
157
+ token, body: { planId: "discovered-home", journeyId: "discovered-home", objectKey: `projects/${projectId}/plans/discovered-home.flow.yaml` },
158
+ });
159
+ assert.equal(published.status, 201);
160
+
161
+ const workerId = `cloud:task:${taskId}`;
162
+ const running = await call(env, "POST", `/api/tasks/${taskId}`, { token, body: { workerId, status: "running", progressMessage: "Opened the home page" } });
163
+ assert.equal(running.json.stop, undefined);
164
+ db.db.prepare("UPDATE agent_tasks SET claimed_at = ? WHERE id = ?").run(new Date(Date.now() - 90_000).toISOString(), taskId);
165
+ await call(env, "POST", `/api/tasks/${taskId}`, { token, body: { workerId, status: "completed", completionSummary: "Explored 3 pages" } });
166
+ const task = db.db.prepare("SELECT status, billed_minutes AS minutes FROM agent_tasks WHERE id = ?").get(taskId) as any;
167
+ assert.deepEqual({ ...task }, { status: "completed", minutes: 2 });
168
+ const usage = await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: owner });
169
+ assert.equal(usage.json.usage.lines.browser_minute.used, 2);
170
+
171
+ // The project had no environment at all (createManagedProject doesn't
172
+ // create one) — a completed discovery run is the first time anyone
173
+ // knows the project's URL, so it fills the gap instead of leaving it.
174
+ const env_ = db.db.prepare("SELECT base_url AS baseUrl, is_default AS isDefault FROM project_environments WHERE project_id = ?").get(projectId) as any;
175
+ assert.equal(env_.baseUrl, "https://shop.test");
176
+ assert.equal(env_.isDefault, 1);
177
+ } finally {
178
+ launcher.restore();
179
+ }
180
+ });
181
+
182
+ test("a discovered URL fills a project's empty default environment but never overwrites one a human set", async () => {
183
+ const launcher = fakeLauncher();
184
+ try {
185
+ const { env, db, projectId, owner } = await cloudProject();
186
+ db.db.prepare(`
187
+ INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
188
+ VALUES ('default', ?, 'default', 'Production', '', 'active', 1)
189
+ `).run(projectId);
190
+ const explore = (idempotencyKey: string, url: string) => call(env, "POST", `/api/projects/${projectId}/tasks`, {
191
+ as: owner,
192
+ body: { title: `Explore ${url}`, description: `Explore ${url}`, taskType: "discovery", idempotencyKey, executionMode: "cloud" },
193
+ });
194
+ const complete = async (launchIndex: number, taskId: string) => {
195
+ const { token } = launcher.launches[launchIndex];
196
+ await call(env, "POST", `/api/tasks/${taskId}`, {
197
+ token, body: { workerId: `cloud:task:${taskId}`, status: "completed", completionSummary: "Explored 1 page" },
198
+ });
199
+ };
200
+ const readBaseUrl = () => (db.db.prepare("SELECT base_url AS baseUrl FROM project_environments WHERE project_id = ?").get(projectId) as any).baseUrl;
201
+
202
+ const first = await explore("d1", "https://shop.test");
203
+ await complete(0, first.json.task.id);
204
+ assert.equal(readBaseUrl(), "https://shop.test", "filled the empty default environment");
205
+
206
+ // A second, different discovery task completing must not clobber what's
207
+ // now a human-editable (and possibly since hand-changed) base_url.
208
+ db.db.prepare("UPDATE project_environments SET base_url = 'https://staging.shop.test' WHERE project_id = ?").run(projectId);
209
+ const second = await explore("d2", "https://shop.test");
210
+ await complete(1, second.json.task.id);
211
+ assert.equal(readBaseUrl(), "https://staging.shop.test", "an environment a human already pointed somewhere is left alone");
212
+ } finally {
213
+ launcher.restore();
214
+ }
215
+ });
216
+
217
+ test("runs and discovery share the workspace's parallel slots", async () => {
218
+ const launcher = fakeLauncher();
219
+ try {
220
+ const { env, projectId, owner, queue } = await cloudProject();
221
+ const run = (await queue("r1")).json.run.id;
222
+ const task = (await call(env, "POST", `/api/projects/${projectId}/tasks`, {
223
+ as: owner, body: { title: "Explore https://shop.test", description: "https://shop.test", taskType: "discovery", idempotencyKey: "d1", executionMode: "cloud" },
224
+ })).json.task.id;
225
+ const waiting = (await queue("r2")).json.run.id;
226
+ assert.deepEqual(launcher.launches.map((item) => item.id), [run, task]);
227
+ const token = launcher.launches[1].token;
228
+ await call(env, "POST", `/api/tasks/${task}`, { token, body: { workerId: `cloud:task:${task}`, status: "error", errorMessage: "site down" } });
229
+ assert.deepEqual(launcher.launches.map((item) => item.id), [run, task, waiting]);
230
+ } finally {
231
+ launcher.restore();
232
+ }
233
+ });
234
+
235
+ test("cloud runs need Bugmole storage", async () => {
236
+ const launcher = fakeLauncher();
237
+ try {
238
+ const { env, owner, projectId } = await cloudProject({}, { storageProvider: "r2" });
239
+ const created = await call(env, "POST", `/api/projects/${projectId}/runs`, {
240
+ as: owner, body: { planId: "checkout", idempotencyKey: "b1", executionMode: "cloud" },
241
+ });
242
+ assert.equal(created.status, 422);
243
+ assert.equal(created.json.code, "cloud_needs_managed_storage");
244
+ const upload = await call(env, "PUT", `/api/projects/${projectId}/artifacts/object?key=projects/${projectId}/plans/a.yaml`, {
245
+ as: owner, body: "x", headers: { "content-length": "1" },
246
+ });
247
+ assert.equal(upload.status, 409, "own-storage projects don't write to Bugmole's bucket");
248
+ } finally {
249
+ launcher.restore();
250
+ }
251
+ });
252
+
253
+ test("workspaces take turns within their parallel limit and the pool size", () => {
254
+ const run = (id: string, workspaceId: string) => ({ id, projectId: "p", workspaceId, attempts: 0 });
255
+ const queued = [run("a1", "a"), run("a2", "a"), run("a3", "a"), run("a4", "a"), run("b1", "b"), run("c1", "c")];
256
+ const free = new Map([["a", 2], ["b", 1], ["c", 0]]);
257
+ assert.deepEqual(pickFairly(queued, free, 10).map((item) => item.id), ["a1", "b1", "a2"]);
258
+ assert.deepEqual(pickFairly(queued, free, 2).map((item) => item.id), ["a1", "b1"]);
259
+ assert.deepEqual(pickFairly(queued, free, 0), []);
260
+ });
261
+
262
+ test("the plan's parallel limit holds runs in the queue until a slot frees up", async () => {
263
+ const launcher = fakeLauncher();
264
+ try {
265
+ const { env, queue } = await cloudProject();
266
+ const ids = [];
267
+ for (const key of ["p1", "p2", "p3"]) ids.push((await queue(key)).json.run.id);
268
+ assert.deepEqual(launcher.launches.map((item) => item.id), ids.slice(0, 2), "Free runs two at a time");
269
+ const token = launcher.launches[0].token;
270
+ await call(env, "POST", `/api/runs/${ids[0]}`, { token, body: { workerId: `cloud:${ids[0]}`, status: "error", eventType: "failed" } });
271
+ assert.deepEqual(launcher.launches.map((item) => item.id), ids, "a finished run starts the next");
272
+ } finally {
273
+ launcher.restore();
274
+ }
275
+ });
276
+
277
+ test("a runner that can't start is retried, then the run fails without a charge", async () => {
278
+ const launcher = fakeLauncher(10);
279
+ try {
280
+ const { env, db, owner, workspaceId, queue } = await cloudProject();
281
+ const runId = (await queue("f1")).json.run.id;
282
+ let run = db.db.prepare("SELECT status, dispatch_attempts AS attempts, next_dispatch_at AS next FROM runs WHERE id = ?").get(runId) as any;
283
+ assert.equal(run.status, "queued");
284
+ assert.equal(run.attempts, 1);
285
+ assert.ok(run.next);
286
+ const later = (ms: number) => new Date(Date.now() + ms);
287
+ await dispatchCloudRuns(env, later(15_000));
288
+ assert.equal((db.db.prepare("SELECT dispatch_attempts AS attempts FROM runs WHERE id = ?").get(runId) as any).attempts, 1, "waits out the retry delay");
289
+ await dispatchCloudRuns(env, later(31_000));
290
+ await dispatchCloudRuns(env, later(200_000));
291
+ run = db.db.prepare("SELECT status, failure_code AS code, billed_at AS billed FROM runs WHERE id = ?").get(runId);
292
+ assert.deepEqual({ ...run }, { status: "error", code: "runner_unavailable", billed: null });
293
+ const usage = await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: owner });
294
+ assert.equal(usage.json.usage.lines.browser_minute.used, 0);
295
+ } finally {
296
+ launcher.restore();
297
+ }
298
+ });
299
+
300
+ test("a runner that goes silent is failed and billed up to its last heartbeat", async () => {
301
+ const launcher = fakeLauncher();
302
+ try {
303
+ const { env, db, queue } = await cloudProject();
304
+ const runId = (await queue("l1")).json.run.id;
305
+ const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString();
306
+ db.db.prepare("UPDATE runs SET claimed_at = ?, heartbeat_at = ?, lease_expires_at = ? WHERE id = ?")
307
+ .run(minutesAgo(30), minutesAgo(25).replace("T", " ").slice(0, 19), minutesAgo(24), runId);
308
+ await runScheduled(env, "* * * * *");
309
+ const run = db.db.prepare("SELECT status, failure_code AS code, billed_minutes AS minutes FROM runs WHERE id = ?").get(runId) as any;
310
+ assert.equal(run.status, "error");
311
+ assert.equal(run.code, "runner_lost");
312
+ assert.equal(run.minutes, 6, "5 minutes of heartbeats plus the one-minute grace");
313
+ } finally {
314
+ launcher.restore();
315
+ }
316
+ });
317
+
318
+ test("a running cloud run is told to stop when the minutes or the time limit run out", async () => {
319
+ const launcher = fakeLauncher();
320
+ try {
321
+ const { env, db, workspaceId, queue } = await cloudProject({ FEATURE_ENTITLEMENTS: "on", CLOUD_RUN_MAX_MINUTES: "30" });
322
+ const runId = (await queue("s1")).json.run.id;
323
+ const token = launcher.launches[0].token;
324
+ const beat = () => call(env, "POST", `/api/runs/${runId}`, { token, body: { workerId: `cloud:${runId}`, status: "running", eventType: "heartbeat" } });
325
+ await recordUsage(env, { workspaceId, kind: "browser_minute", quantity: 296, source: "cloud_run", idempotencyKey: "earlier" });
326
+ db.db.prepare("UPDATE runs SET claimed_at = ? WHERE id = ?").run(new Date(Date.now() - 3 * 60_000).toISOString(), runId);
327
+ assert.deepEqual((await beat()).json, { updated: true });
328
+ db.db.prepare("UPDATE runs SET claimed_at = ? WHERE id = ?").run(new Date(Date.now() - 5 * 60_000).toISOString(), runId);
329
+ const stopped = await beat();
330
+ assert.equal(stopped.json.stop, true);
331
+ assert.equal(stopped.json.reason, "quota");
332
+ db.db.prepare("DELETE FROM usage_events").run();
333
+ db.db.prepare("UPDATE runs SET claimed_at = ? WHERE id = ?").run(new Date(Date.now() - 31 * 60_000).toISOString(), runId);
334
+ assert.equal((await beat()).json.reason, "time_limit");
335
+ } finally {
336
+ launcher.restore();
337
+ }
338
+ });
339
+
340
+ test("projects show their storage and default place to run, which managers can change", async () => {
341
+ const { env, owner, projectId } = await cloudProject();
342
+ const listed = await call(env, "GET", "/api/projects", { as: owner });
343
+ const project = listed.json.projects.find((item: { id: string }) => item.id === projectId);
344
+ assert.equal(project.storageMode, "managed");
345
+ assert.equal(project.defaultExecutionMode, "self");
346
+ assert.equal((await call(env, "PATCH", `/api/projects/${projectId}/settings`, { as: owner, body: { defaultExecutionMode: "moon" } })).status, 422);
347
+ const changed = await call(env, "PATCH", `/api/projects/${projectId}/settings`, { as: owner, body: { defaultExecutionMode: "cloud" } });
348
+ assert.equal(changed.status, 200);
349
+ assert.equal((await call(env, "PATCH", `/api/projects/${projectId}/settings`, { as: "user_stranger", body: { defaultExecutionMode: "self" } })).status, 403);
350
+ });
351
+
352
+ test("billable minutes round up, start at one, and stop a minute after the last heartbeat", () => {
353
+ const at = (seconds: number) => new Date(Date.UTC(2026, 8, 17, 10, 0, seconds)).toISOString();
354
+ assert.equal(billableMinutes({ claimedAt: at(0), completedAt: at(5), heartbeatAt: at(5) }), 1);
355
+ assert.equal(billableMinutes({ claimedAt: at(0), completedAt: at(61), heartbeatAt: at(61) }), 2);
356
+ assert.equal(billableMinutes({ claimedAt: at(0), completedAt: at(3000), heartbeatAt: "2026-09-17 10:01:00" }), 2);
357
+ assert.equal(billableMinutes({ claimedAt: at(0), completedAt: null, heartbeatAt: null }, Date.parse(at(125))), 3);
358
+ });
@@ -0,0 +1,93 @@
1
+ // Short-lived credentials for hosted runners. A token names one run and is
2
+ // signed with RUNNER_TOKEN_SECRET, so the registry needs no table for them and
3
+ // a leaked token expires with the run (two hours at most).
4
+ import type { Env } from "../core.js";
5
+
6
+ export const RUNNER_TOKEN_PREFIX = "vrt_";
7
+ export const RUNNER_TOKEN_TTL_MS = 2 * 60 * 60 * 1000;
8
+
9
+ /** A runner acts for exactly one run or one discovery task. */
10
+ export type RunnerIdentity = { kind: "run" | "task"; id: string; projectId: string; workspaceId: string | null };
11
+ type Claims = RunnerIdentity & { exp: number };
12
+
13
+ const encoder = new TextEncoder();
14
+
15
+ function base64Url(bytes: Uint8Array): string {
16
+ let binary = "";
17
+ for (const byte of bytes) binary += String.fromCharCode(byte);
18
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
19
+ }
20
+
21
+ function fromBase64Url(value: string): Uint8Array {
22
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - (value.length % 4)) % 4);
23
+ return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
24
+ }
25
+
26
+ async function hmacKey(secret: string, usage: "sign" | "verify"): Promise<CryptoKey> {
27
+ return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
28
+ }
29
+
30
+ export async function signRunnerToken(env: Env, identity: RunnerIdentity, now = Date.now()): Promise<string> {
31
+ if (!env.RUNNER_TOKEN_SECRET) throw new Error("RUNNER_TOKEN_SECRET is not configured");
32
+ const claims: Claims = { ...identity, exp: now + RUNNER_TOKEN_TTL_MS };
33
+ const payload = base64Url(encoder.encode(JSON.stringify(claims)));
34
+ const signature = await crypto.subtle.sign("HMAC", await hmacKey(env.RUNNER_TOKEN_SECRET, "sign"), encoder.encode(payload));
35
+ return `${RUNNER_TOKEN_PREFIX}${payload}.${base64Url(new Uint8Array(signature))}`;
36
+ }
37
+
38
+ export async function verifyRunnerToken(env: Env, token: string, now = Date.now()): Promise<RunnerIdentity | null> {
39
+ if (!env.RUNNER_TOKEN_SECRET || !token.startsWith(RUNNER_TOKEN_PREFIX)) return null;
40
+ const [payload, signature, extra] = token.slice(RUNNER_TOKEN_PREFIX.length).split(".");
41
+ if (!payload || !signature || extra !== undefined) return null;
42
+ try {
43
+ const valid = await crypto.subtle.verify(
44
+ "HMAC",
45
+ await hmacKey(env.RUNNER_TOKEN_SECRET, "verify"),
46
+ fromBase64Url(signature),
47
+ encoder.encode(payload),
48
+ );
49
+ if (!valid) return null;
50
+ const claims = JSON.parse(new TextDecoder().decode(fromBase64Url(payload))) as Partial<Claims>;
51
+ if (typeof claims.id !== "string" || typeof claims.projectId !== "string" || typeof claims.exp !== "number") return null;
52
+ if (claims.kind !== "run" && claims.kind !== "task") return null;
53
+ if (claims.exp <= now) return null;
54
+ return { kind: claims.kind, id: claims.id, projectId: claims.projectId, workspaceId: typeof claims.workspaceId === "string" ? claims.workspaceId : null };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The only calls a runner makes. A run reads its bundle and the project's
62
+ * stored plans, writes evidence under its own run, and reports. A discovery
63
+ * task writes the flows, plans and journey graph it found, and publishes the
64
+ * plans. Everything else is refused before routing.
65
+ */
66
+ export function runnerMayCall(identity: RunnerIdentity, method: string, url: URL): boolean {
67
+ const id = encodeURIComponent(identity.id);
68
+ const project = encodeURIComponent(identity.projectId);
69
+ const path = url.pathname;
70
+ if (path === "/api/ai/complete") return method === "POST";
71
+ const artifacts = path === `/api/projects/${project}/artifacts` || path === `/api/projects/${project}/artifacts/object`;
72
+ const key = url.searchParams.get(path.endsWith("/object") ? "key" : "prefix") ?? "";
73
+ if (identity.kind === "run") {
74
+ if (path === `/api/runner/runs/${id}/bundle`) return method === "GET";
75
+ if (path === `/api/runs/${id}`) return method === "GET" || method === "POST";
76
+ if (path === `/api/runs/${id}/device-access`) return method === "GET";
77
+ if (path === `/api/runs/${id}/device-sessions`) return method === "POST";
78
+ if (path.startsWith(`/api/runs/${id}/device-sessions/`)) return method === "GET" || method === "POST";
79
+ if (artifacts) {
80
+ if (method === "GET") return true;
81
+ return (method === "PUT" || method === "DELETE") && key.startsWith(`projects/${project}/runs/${id}/`);
82
+ }
83
+ return false;
84
+ }
85
+ if (path === `/api/runner/tasks/${id}/bundle`) return method === "GET";
86
+ if (path === `/api/tasks/${id}`) return method === "POST";
87
+ if (path === `/api/projects/${project}/plans`) return method === "POST";
88
+ if (artifacts) {
89
+ if (method === "GET") return true;
90
+ return method === "PUT" && ["spec", "plans", "journeys"].some((folder) => key.startsWith(`projects/${project}/${folder}/`));
91
+ }
92
+ return false;
93
+ }