@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.
- package/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Shared by GitHub and GitLab checks: turning a preview-URL template into a
|
|
2
|
+
// real URL for a given commit, safely.
|
|
3
|
+
|
|
4
|
+
/** Fills {number}, {sha}, {short_sha} and {branch} into a preview URL template. */
|
|
5
|
+
export function previewUrl(template: string, values: { number?: number; sha: string; branch?: string }): string | null {
|
|
6
|
+
const branch = (values.branch ?? "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
7
|
+
if (template.includes("{number}") && values.number === undefined) return null;
|
|
8
|
+
if (template.includes("{branch}") && !branch) return null;
|
|
9
|
+
const filled = template
|
|
10
|
+
.replaceAll("{number}", String(values.number ?? ""))
|
|
11
|
+
.replaceAll("{sha}", values.sha)
|
|
12
|
+
.replaceAll("{short_sha}", values.sha.slice(0, 7))
|
|
13
|
+
.replaceAll("{branch}", branch);
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(filled);
|
|
16
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Router for the registry features added after the original index.ts routes.
|
|
2
|
+
// Each feature module exposes `route(ctx)` and returns null when the request
|
|
3
|
+
// isn't one of its own, so index.ts stays a thin dispatcher.
|
|
4
|
+
import type { Env, Principal } from "../core.js";
|
|
5
|
+
|
|
6
|
+
export type RouteContext = {
|
|
7
|
+
request: Request;
|
|
8
|
+
env: Env;
|
|
9
|
+
url: URL;
|
|
10
|
+
ctx: ExecutionContext;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AuthedRouteContext = RouteContext & { principal: Principal };
|
|
14
|
+
|
|
15
|
+
type PublicRoute = (context: RouteContext) => Promise<Response | null>;
|
|
16
|
+
type AuthedRoute = (context: AuthedRouteContext) => Promise<Response | null>;
|
|
17
|
+
|
|
18
|
+
const publicRoutes: PublicRoute[] = [];
|
|
19
|
+
const authedRoutes: AuthedRoute[] = [];
|
|
20
|
+
|
|
21
|
+
export function registerPublicRoutes(route: PublicRoute): void {
|
|
22
|
+
publicRoutes.push(route);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerRoutes(route: AuthedRoute): void {
|
|
26
|
+
authedRoutes.push(route);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function handlePublicRoutes(request: Request, env: Env, url: URL, ctx: ExecutionContext): Promise<Response | null> {
|
|
30
|
+
for (const route of publicRoutes) {
|
|
31
|
+
const result = await route({ request, env, url, ctx });
|
|
32
|
+
if (result) return result;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function handleExtendedRoutes(
|
|
38
|
+
request: Request,
|
|
39
|
+
env: Env,
|
|
40
|
+
principal: Principal,
|
|
41
|
+
url: URL,
|
|
42
|
+
ctx: ExecutionContext,
|
|
43
|
+
): Promise<Response | null> {
|
|
44
|
+
for (const route of authedRoutes) {
|
|
45
|
+
const result = await route({ request, env, principal, url, ctx });
|
|
46
|
+
if (result) return result;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** For callers without an ExecutionContext: background work simply runs inline. */
|
|
52
|
+
export function detachedContext(): ExecutionContext {
|
|
53
|
+
return {
|
|
54
|
+
waitUntil(promise: Promise<unknown>) {
|
|
55
|
+
promise.catch((error) => console.error("[registry] background task failed", error));
|
|
56
|
+
},
|
|
57
|
+
passThroughOnException() {},
|
|
58
|
+
props: {},
|
|
59
|
+
} as unknown as ExecutionContext;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Matches `pattern` (e.g. "/api/workspaces/:id/members") and returns decoded params. */
|
|
63
|
+
export function matchPath(pathname: string, pattern: string): Record<string, string> | null {
|
|
64
|
+
const names: string[] = [];
|
|
65
|
+
const regex = new RegExp(
|
|
66
|
+
`^${pattern.replace(/:[a-zA-Z]+/g, (token) => {
|
|
67
|
+
names.push(token.slice(1));
|
|
68
|
+
return "([^/]+)";
|
|
69
|
+
})}$`,
|
|
70
|
+
);
|
|
71
|
+
const match = pathname.match(regex);
|
|
72
|
+
if (!match) return null;
|
|
73
|
+
return Object.fromEntries(names.map((name, index) => [name, decodeURIComponent(match[index + 1])]));
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Run event log and live broadcast, shared by the routes and the cloud dispatcher.
|
|
2
|
+
import type { Env } from "./core.js";
|
|
3
|
+
|
|
4
|
+
export async function appendRunEvent(env: Env, runId: string, type: string, payload: unknown): Promise<number> {
|
|
5
|
+
// One statement, so concurrent updates (a run fanned out across browsers
|
|
6
|
+
// reports from several lanes at once) can't both read the same MAX and
|
|
7
|
+
// collide on (run_id, sequence).
|
|
8
|
+
const row = await env.DB.prepare(`
|
|
9
|
+
INSERT INTO run_events (run_id, sequence, type, payload_json)
|
|
10
|
+
SELECT ?, COALESCE(MAX(sequence), 0) + 1, ?, ? FROM run_events WHERE run_id = ?
|
|
11
|
+
RETURNING sequence
|
|
12
|
+
`).bind(runId, type, JSON.stringify(payload), runId).first<{ sequence: number }>();
|
|
13
|
+
return row?.sequence ?? 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function broadcastProjectEvent(env: Env, projectId: string, event: unknown): Promise<void> {
|
|
17
|
+
if (!env.RUNS_COORDINATOR) return;
|
|
18
|
+
const stub = env.RUNS_COORDINATOR.get(env.RUNS_COORDINATOR.idFromName(projectId));
|
|
19
|
+
await stub.fetch("https://runs-coordinator/broadcast", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
body: JSON.stringify(event),
|
|
22
|
+
headers: { "content-type": "application/json" },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Starts queued cloud work (test runs and discovery tasks) on Bugmole's
|
|
2
|
+
// runner pool. Runs on every cron tick, when cloud work is queued, and when
|
|
3
|
+
// some finishes. Each workspace gets at most its plan's parallel slots, the
|
|
4
|
+
// whole pool at most CLOUD_POOL_MAX, and workspaces take turns so one big
|
|
5
|
+
// queue can't starve the rest.
|
|
6
|
+
import type { Env } from "../core.js";
|
|
7
|
+
import { getEntitlements } from "../billing/entitlements.js";
|
|
8
|
+
import { canSpend } from "../billing/ledger.js";
|
|
9
|
+
import { entitlementMode } from "../billing/enforcement.js";
|
|
10
|
+
import { runQueuedHooks, taskQueuedHooks, type CloudJobKind } from "../hooks.js";
|
|
11
|
+
import { registerJob } from "../jobs/index.js";
|
|
12
|
+
import { announceJob, cloudWorkerId, JOB_TABLE, type CloudJob } from "./jobs.js";
|
|
13
|
+
import { signRunnerToken } from "./tokens.js";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_CLOUD_POOL_MAX = 20;
|
|
16
|
+
export const MAX_DISPATCH_ATTEMPTS = 3;
|
|
17
|
+
/** Time a started container has to send its first update. */
|
|
18
|
+
export const START_LEASE_MS = 5 * 60_000;
|
|
19
|
+
const RETRY_DELAY_MS = 30_000;
|
|
20
|
+
/** Plans without a parallel limit (Enterprise) still get a ceiling. */
|
|
21
|
+
const UNLIMITED_PARALLEL = 100;
|
|
22
|
+
|
|
23
|
+
export type LaunchRequest = { kind: CloudJobKind; id: string; projectId: string; token: string; registryUrl: string };
|
|
24
|
+
|
|
25
|
+
/** Asks the runner Worker to start a container. Tests replace `start`. */
|
|
26
|
+
export const runnerLauncher = {
|
|
27
|
+
async start(env: Env, launch: LaunchRequest): Promise<void> {
|
|
28
|
+
if (!env.RUNNER) throw new Error("The runner service isn't bound to this registry");
|
|
29
|
+
const result = await env.RUNNER.fetch(`https://runner.internal/jobs/${launch.kind}/${encodeURIComponent(launch.id)}/start`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "content-type": "application/json" },
|
|
32
|
+
body: JSON.stringify(launch),
|
|
33
|
+
});
|
|
34
|
+
if (!result.ok) throw new Error(`The runner service answered ${result.status}`);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function cloudPoolMax(env: Env): number {
|
|
39
|
+
const value = Number(env.CLOUD_POOL_MAX);
|
|
40
|
+
return Number.isInteger(value) && value >= 0 ? value : DEFAULT_CLOUD_POOL_MAX;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type QueuedJob = CloudJob & { workspaceId: string; attempts: number; requestedAt: string };
|
|
44
|
+
type Outcome = { started: string[]; retrying: string[]; failed: string[]; expired: string[] };
|
|
45
|
+
|
|
46
|
+
/** Takes one job per workspace per round until slots or jobs run out. */
|
|
47
|
+
export function pickFairly<T extends { workspaceId: string }>(queued: T[], freeByWorkspace: Map<string, number>, poolFree: number): T[] {
|
|
48
|
+
const queues = new Map<string, T[]>();
|
|
49
|
+
for (const job of queued) {
|
|
50
|
+
const queue = queues.get(job.workspaceId) ?? [];
|
|
51
|
+
queue.push(job);
|
|
52
|
+
queues.set(job.workspaceId, queue);
|
|
53
|
+
}
|
|
54
|
+
const picked: T[] = [];
|
|
55
|
+
const free = new Map(freeByWorkspace);
|
|
56
|
+
let progress = true;
|
|
57
|
+
while (poolFree > 0 && progress) {
|
|
58
|
+
progress = false;
|
|
59
|
+
for (const [workspaceId, queue] of queues) {
|
|
60
|
+
if (poolFree <= 0) break;
|
|
61
|
+
if (!queue.length || (free.get(workspaceId) ?? 0) <= 0) continue;
|
|
62
|
+
picked.push(queue.shift()!);
|
|
63
|
+
free.set(workspaceId, (free.get(workspaceId) ?? 0) - 1);
|
|
64
|
+
poolFree -= 1;
|
|
65
|
+
progress = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return picked;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function failJob(env: Env, job: CloudJob, previousStatus: string, failureCode: string, message: string) {
|
|
72
|
+
if (job.kind === "run") {
|
|
73
|
+
await env.DB.prepare(`
|
|
74
|
+
UPDATE runs SET status = 'error', failure_code = ?, error_message = ?, completed_at = CURRENT_TIMESTAMP, lease_expires_at = NULL
|
|
75
|
+
WHERE id = ?
|
|
76
|
+
`).bind(failureCode, message, job.id).run();
|
|
77
|
+
} else {
|
|
78
|
+
await env.DB.prepare(`
|
|
79
|
+
UPDATE agent_tasks SET status = 'error', error_message = ?, progress_message = ?, completed_at = CURRENT_TIMESTAMP, lease_expires_at = NULL
|
|
80
|
+
WHERE id = ?
|
|
81
|
+
`).bind(message, message, job.id).run();
|
|
82
|
+
}
|
|
83
|
+
await announceJob(env, job, { status: "error", previousStatus, payload: { status: "error", failureCode, errorMessage: message } });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Work whose runner stopped reporting is failed (and billed up to its last heartbeat). */
|
|
87
|
+
async function expireLostJobs(env: Env, now: Date): Promise<string[]> {
|
|
88
|
+
const nowIso = now.toISOString();
|
|
89
|
+
const expired: string[] = [];
|
|
90
|
+
const message = "The cloud runner stopped responding.";
|
|
91
|
+
for (const kind of ["run", "task"] as const) {
|
|
92
|
+
const table = JOB_TABLE[kind];
|
|
93
|
+
const lost = await env.DB.prepare(`
|
|
94
|
+
SELECT id, project_id AS projectId, workspace_id AS workspaceId FROM ${table}
|
|
95
|
+
WHERE execution_mode = 'cloud' AND status = 'running' AND lease_expires_at < ?
|
|
96
|
+
`).bind(nowIso).all<{ id: string; projectId: string; workspaceId: string | null }>();
|
|
97
|
+
for (const row of lost.results) {
|
|
98
|
+
const changed = await env.DB.prepare(`
|
|
99
|
+
UPDATE ${table} SET status = 'error', error_message = ?, completed_at = CURRENT_TIMESTAMP
|
|
100
|
+
${kind === "run" ? ", failure_code = 'runner_lost'" : ""}
|
|
101
|
+
WHERE id = ? AND status = 'running' AND lease_expires_at < ?
|
|
102
|
+
`).bind(message, row.id, nowIso).run();
|
|
103
|
+
if ((changed.meta?.changes ?? 0) !== 1) continue;
|
|
104
|
+
expired.push(row.id);
|
|
105
|
+
await announceJob(env, { kind, ...row }, { status: "error", previousStatus: "running", payload: { status: "error", failureCode: "runner_lost", errorMessage: message } });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return expired;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function queuedJobs(env: Env, nowIso: string): Promise<QueuedJob[]> {
|
|
112
|
+
const select = (kind: CloudJobKind) => env.DB.prepare(`
|
|
113
|
+
SELECT '${kind}' AS kind, id, project_id AS projectId, workspace_id AS workspaceId,
|
|
114
|
+
dispatch_attempts AS attempts, requested_at AS requestedAt
|
|
115
|
+
FROM ${JOB_TABLE[kind]}
|
|
116
|
+
WHERE execution_mode = 'cloud' AND status = 'queued' AND workspace_id IS NOT NULL
|
|
117
|
+
AND (next_dispatch_at IS NULL OR next_dispatch_at <= ?)
|
|
118
|
+
ORDER BY requested_at ASC LIMIT 500
|
|
119
|
+
`).bind(nowIso).all<QueuedJob>();
|
|
120
|
+
const [runs, tasks] = await Promise.all([select("run"), select("task")]);
|
|
121
|
+
return [...runs.results, ...tasks.results].sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function activeByWorkspace(env: Env): Promise<Map<string, number>> {
|
|
125
|
+
const rows = await env.DB.prepare(`
|
|
126
|
+
SELECT workspace_id AS workspaceId, SUM(count) AS count FROM (
|
|
127
|
+
SELECT workspace_id, COUNT(*) AS count FROM runs WHERE execution_mode = 'cloud' AND status = 'running' GROUP BY workspace_id
|
|
128
|
+
UNION ALL
|
|
129
|
+
SELECT workspace_id, COUNT(*) AS count FROM agent_tasks WHERE execution_mode = 'cloud' AND status = 'running' GROUP BY workspace_id
|
|
130
|
+
) GROUP BY workspace_id
|
|
131
|
+
`).all<{ workspaceId: string; count: number }>();
|
|
132
|
+
return new Map(rows.results.map((row) => [row.workspaceId, Number(row.count)]));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function claim(env: Env, job: QueuedJob, now: Date): Promise<boolean> {
|
|
136
|
+
const table = JOB_TABLE[job.kind];
|
|
137
|
+
const claimed = await env.DB.prepare(`
|
|
138
|
+
UPDATE ${table} SET status = 'running', worker_id = ?, claimed_at = ?, lease_expires_at = ?,
|
|
139
|
+
heartbeat_at = CURRENT_TIMESTAMP, started_at = COALESCE(started_at, CURRENT_TIMESTAMP),
|
|
140
|
+
dispatch_attempts = dispatch_attempts + 1
|
|
141
|
+
${job.kind === "task" ? ", attempts = attempts + 1" : ""}
|
|
142
|
+
WHERE id = ? AND status = 'queued' AND execution_mode = 'cloud'
|
|
143
|
+
`).bind(cloudWorkerId(job), now.toISOString(), new Date(now.getTime() + START_LEASE_MS).toISOString(), job.id).run();
|
|
144
|
+
return (claimed.meta?.changes ?? 0) === 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function requeue(env: Env, job: QueuedJob, attempts: number, now: Date) {
|
|
148
|
+
await env.DB.prepare(`
|
|
149
|
+
UPDATE ${JOB_TABLE[job.kind]} SET status = 'queued', worker_id = NULL, claimed_at = NULL, lease_expires_at = NULL,
|
|
150
|
+
started_at = NULL, heartbeat_at = NULL, next_dispatch_at = ?
|
|
151
|
+
WHERE id = ?
|
|
152
|
+
`).bind(new Date(now.getTime() + RETRY_DELAY_MS * attempts).toISOString(), job.id).run();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function dispatchCloudRuns(env: Env, now = new Date()): Promise<Outcome> {
|
|
156
|
+
const outcome: Outcome = { started: [], retrying: [], failed: [], expired: [] };
|
|
157
|
+
outcome.expired = await expireLostJobs(env, now);
|
|
158
|
+
const queued = await queuedJobs(env, now.toISOString());
|
|
159
|
+
if (!queued.length) return outcome;
|
|
160
|
+
if (!env.RUNNER_TOKEN_SECRET) {
|
|
161
|
+
console.warn("[dispatch] RUNNER_TOKEN_SECRET is not set; cloud work stays queued");
|
|
162
|
+
return outcome;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const active = await activeByWorkspace(env);
|
|
166
|
+
const poolFree = cloudPoolMax(env) - [...active.values()].reduce((sum, count) => sum + count, 0);
|
|
167
|
+
if (poolFree <= 0) return outcome;
|
|
168
|
+
const freeByWorkspace = new Map<string, number>();
|
|
169
|
+
for (const workspaceId of new Set(queued.map((job) => job.workspaceId))) {
|
|
170
|
+
const { parallel } = await getEntitlements(env, workspaceId);
|
|
171
|
+
freeByWorkspace.set(workspaceId, (parallel ?? UNLIMITED_PARALLEL) - (active.get(workspaceId) ?? 0));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const registryUrl = (env.REGISTRY_PUBLIC_URL || "https://api.bugmole.com").replace(/\/$/, "");
|
|
175
|
+
for (const job of pickFairly(queued, freeByWorkspace, poolFree)) {
|
|
176
|
+
// Minutes may have run out while the job waited.
|
|
177
|
+
if ((await entitlementMode(env, job.workspaceId)) === "enforce") {
|
|
178
|
+
const decision = await canSpend(env, job.workspaceId, "browser_minute", 1, now);
|
|
179
|
+
if (!decision.allowed) {
|
|
180
|
+
await env.DB.prepare(`UPDATE ${JOB_TABLE[job.kind]} SET blocked_reason = ? WHERE id = ?`).bind(decision.reason, job.id).run();
|
|
181
|
+
await failJob(env, job, "queued", "plan_limit", decision.message);
|
|
182
|
+
outcome.failed.push(job.id);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!(await claim(env, job, now))) continue;
|
|
187
|
+
const attempts = job.attempts + 1;
|
|
188
|
+
try {
|
|
189
|
+
const token = await signRunnerToken(env, { kind: job.kind, id: job.id, projectId: job.projectId, workspaceId: job.workspaceId }, now.getTime());
|
|
190
|
+
await runnerLauncher.start(env, { kind: job.kind, id: job.id, projectId: job.projectId, token, registryUrl });
|
|
191
|
+
outcome.started.push(job.id);
|
|
192
|
+
await announceJob(env, job, { status: "running", previousStatus: "queued", payload: { status: "running", workerId: cloudWorkerId(job), attempt: attempts } });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
195
|
+
console.warn(`[dispatch] could not start ${job.kind} ${job.id} (attempt ${attempts}): ${reason}`);
|
|
196
|
+
if (attempts >= MAX_DISPATCH_ATTEMPTS) {
|
|
197
|
+
// Never started, so never billed.
|
|
198
|
+
await env.DB.prepare(`UPDATE ${JOB_TABLE[job.kind]} SET claimed_at = NULL, worker_id = NULL WHERE id = ?`).bind(job.id).run();
|
|
199
|
+
await failJob(env, job, "queued", "runner_unavailable", "Bugmole Cloud couldn't start a runner. Try again, or run on your own machine.");
|
|
200
|
+
outcome.failed.push(job.id);
|
|
201
|
+
} else {
|
|
202
|
+
await requeue(env, job, attempts, now);
|
|
203
|
+
outcome.retrying.push(job.id);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return outcome;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
registerJob("minute", "cloud-dispatch", async (env, _ctx, now) => {
|
|
211
|
+
await dispatchCloudRuns(env, now);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
runQueuedHooks.push(async (env, event) => {
|
|
215
|
+
if (event.executionMode === "cloud") await dispatchCloudRuns(env);
|
|
216
|
+
});
|
|
217
|
+
taskQueuedHooks.push(async (env, event) => {
|
|
218
|
+
if (event.executionMode === "cloud") await dispatchCloudRuns(env);
|
|
219
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Runs and discovery tasks share one cloud pipeline: the same slots, lease,
|
|
2
|
+
// meter and stop rules. This describes where each kind lives and how its
|
|
3
|
+
// changes are announced.
|
|
4
|
+
import type { Env } from "../core.js";
|
|
5
|
+
import { runChangedHooks, taskChangedHooks, type CloudJobKind } from "../hooks.js";
|
|
6
|
+
import { appendRunEvent, broadcastProjectEvent } from "../run-events.js";
|
|
7
|
+
|
|
8
|
+
export type CloudJob = { kind: CloudJobKind; id: string; projectId: string; workspaceId: string | null };
|
|
9
|
+
|
|
10
|
+
export const JOB_TABLE: Record<CloudJobKind, "runs" | "agent_tasks"> = { run: "runs", task: "agent_tasks" };
|
|
11
|
+
/** The status a job ends in when Bugmole gives up on it. */
|
|
12
|
+
export const FAILED_STATUS: Record<CloudJobKind, string> = { run: "error", task: "error" };
|
|
13
|
+
export const TERMINAL_STATUSES: Record<CloudJobKind, Set<string>> = {
|
|
14
|
+
run: new Set(["success", "partial", "error", "cancelled"]),
|
|
15
|
+
task: new Set(["completed", "error"]),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function cloudWorkerId(job: Pick<CloudJob, "kind" | "id">): string {
|
|
19
|
+
return job.kind === "run" ? `cloud:${job.id}` : `cloud:task:${job.id}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type JobChange = { status: string; previousStatus: string; payload: Record<string, unknown> };
|
|
23
|
+
|
|
24
|
+
/** Tells the dashboard and the lifecycle hooks what the dispatcher did. */
|
|
25
|
+
export async function announceJob(env: Env, job: CloudJob, change: JobChange): Promise<void> {
|
|
26
|
+
const terminal = TERMINAL_STATUSES[job.kind].has(change.status);
|
|
27
|
+
const base = { projectId: job.projectId, workspaceId: job.workspaceId, executionMode: "cloud", status: change.status, previousStatus: change.previousStatus, terminal, fromDispatcher: true };
|
|
28
|
+
const run = async (work: () => Promise<void>) => {
|
|
29
|
+
try {
|
|
30
|
+
await work();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error("[dispatch] lifecycle hook failed", error);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
if (job.kind === "run") {
|
|
36
|
+
await appendRunEvent(env, job.id, change.status === "running" ? "running" : "failed", change.payload);
|
|
37
|
+
await broadcastProjectEvent(env, job.projectId, { type: "run_event", runId: job.id, eventType: change.status, ...change.payload });
|
|
38
|
+
for (const hook of runChangedHooks) await run(() => hook(env, { ...base, runId: job.id }));
|
|
39
|
+
} else {
|
|
40
|
+
await broadcastProjectEvent(env, job.projectId, { type: "task_event", taskId: job.id, projectId: job.projectId, ...change.payload });
|
|
41
|
+
for (const hook of taskChangedHooks) await run(() => hook(env, { ...base, taskId: job.id }));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Bills finished cloud work from times the registry recorded itself, never
|
|
2
|
+
// from what the runner reports: claimed → finished, but no later than a
|
|
3
|
+
// minute after the last heartbeat, so a runner that vanished isn't billed
|
|
4
|
+
// for the time it took to notice.
|
|
5
|
+
import type { Env } from "../core.js";
|
|
6
|
+
import { recordUsage } from "../billing/ledger.js";
|
|
7
|
+
import { checkUsageThresholds } from "../billing/thresholds.js";
|
|
8
|
+
import { runChangedHooks, taskChangedHooks, type CloudJobKind } from "../hooks.js";
|
|
9
|
+
import { dispatchCloudRuns } from "./dispatch.js";
|
|
10
|
+
import { JOB_TABLE } from "./jobs.js";
|
|
11
|
+
|
|
12
|
+
const MINUTE = 60_000;
|
|
13
|
+
|
|
14
|
+
/** Reads both ISO strings and SQLite's CURRENT_TIMESTAMP ("YYYY-MM-DD HH:MM:SS", UTC). */
|
|
15
|
+
export function sqlTime(value: string | null | undefined): number {
|
|
16
|
+
if (!value) return NaN;
|
|
17
|
+
return Date.parse(/[zZ]|[+-]\d\d:\d\d$/.test(value) ? value : `${value.replace(" ", "T")}Z`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function elapsedMinutes(claimedAt: string, now: number): number {
|
|
21
|
+
const start = sqlTime(claimedAt);
|
|
22
|
+
return Number.isFinite(start) ? Math.max(0, Math.ceil((now - start) / MINUTE)) : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function billableMinutes(
|
|
26
|
+
times: { claimedAt: string; completedAt: string | null; heartbeatAt: string | null },
|
|
27
|
+
now = Date.now(),
|
|
28
|
+
): number {
|
|
29
|
+
const start = sqlTime(times.claimedAt);
|
|
30
|
+
if (!Number.isFinite(start)) return 0;
|
|
31
|
+
const completed = sqlTime(times.completedAt);
|
|
32
|
+
const heartbeat = sqlTime(times.heartbeatAt);
|
|
33
|
+
let end = Number.isFinite(completed) ? completed : now;
|
|
34
|
+
if (Number.isFinite(heartbeat)) end = Math.min(end, heartbeat + MINUTE);
|
|
35
|
+
return Math.max(1, Math.ceil(Math.max(0, end - start) / MINUTE));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Records the job's minutes once. Returns them, or null when there was nothing to bill. */
|
|
39
|
+
export async function meterCloudJob(env: Env, kind: CloudJobKind, id: string, now = new Date()): Promise<number | null> {
|
|
40
|
+
const table = JOB_TABLE[kind];
|
|
41
|
+
const job = await env.DB.prepare(`
|
|
42
|
+
SELECT project_id AS projectId, workspace_id AS workspaceId, execution_mode AS executionMode,
|
|
43
|
+
claimed_at AS claimedAt, completed_at AS completedAt, heartbeat_at AS heartbeatAt, billed_at AS billedAt
|
|
44
|
+
FROM ${table} WHERE id = ?
|
|
45
|
+
`).bind(id).first<{
|
|
46
|
+
projectId: string; workspaceId: string | null; executionMode: string;
|
|
47
|
+
claimedAt: string | null; completedAt: string | null; heartbeatAt: string | null; billedAt: string | null;
|
|
48
|
+
}>();
|
|
49
|
+
// Work that never reached a runner costs nothing.
|
|
50
|
+
if (!job || job.executionMode !== "cloud" || job.billedAt || !job.claimedAt || !job.workspaceId) return null;
|
|
51
|
+
const minutes = billableMinutes({ claimedAt: job.claimedAt, completedAt: job.completedAt, heartbeatAt: job.heartbeatAt }, now.getTime());
|
|
52
|
+
await recordUsage(env, {
|
|
53
|
+
workspaceId: job.workspaceId,
|
|
54
|
+
projectId: job.projectId,
|
|
55
|
+
runId: kind === "run" ? id : null,
|
|
56
|
+
kind: "browser_minute",
|
|
57
|
+
quantity: minutes,
|
|
58
|
+
source: "cloud_run",
|
|
59
|
+
idempotencyKey: `${kind}:${id}`,
|
|
60
|
+
...(kind === "task" ? { meta: { taskId: id } } : {}),
|
|
61
|
+
now,
|
|
62
|
+
});
|
|
63
|
+
await env.DB.prepare(`UPDATE ${table} SET billed_at = ?, billed_minutes = ? WHERE id = ? AND billed_at IS NULL`)
|
|
64
|
+
.bind(now.toISOString(), minutes, id).run();
|
|
65
|
+
return minutes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Kept for callers that only deal with runs. */
|
|
69
|
+
export function meterCloudRun(env: Env, runId: string, now = new Date()): Promise<number | null> {
|
|
70
|
+
return meterCloudJob(env, "run", runId, now);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function finished(env: Env, kind: CloudJobKind, event: { executionMode: string; terminal: boolean; workspaceId: string | null; fromDispatcher?: boolean }, id: string) {
|
|
74
|
+
if (event.executionMode !== "cloud" || !event.terminal) return;
|
|
75
|
+
const minutes = await meterCloudJob(env, kind, id);
|
|
76
|
+
if (minutes !== null && event.workspaceId) await checkUsageThresholds(env, event.workspaceId);
|
|
77
|
+
// A slot just opened up.
|
|
78
|
+
if (!event.fromDispatcher) await dispatchCloudRuns(env);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
runChangedHooks.push((env, event) => finished(env, "run", event, event.runId));
|
|
82
|
+
taskChangedHooks.push((env, event) => finished(env, "task", event, event.taskId));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Rules cloud work must satisfy before and while it runs.
|
|
2
|
+
import type { Env } from "../core.js";
|
|
3
|
+
import { response } from "../core.js";
|
|
4
|
+
import { canSpend } from "../billing/ledger.js";
|
|
5
|
+
import { entitlementMode } from "../billing/enforcement.js";
|
|
6
|
+
import { heartbeatChecks, type JobHeartbeat } from "../hooks.js";
|
|
7
|
+
import { JOB_TABLE } from "./jobs.js";
|
|
8
|
+
import { elapsedMinutes } from "./metering.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_CLOUD_RUN_MAX_MINUTES = 60;
|
|
11
|
+
|
|
12
|
+
export function cloudRunMaxMinutes(env: Env): number {
|
|
13
|
+
const value = Number(env.CLOUD_RUN_MAX_MINUTES);
|
|
14
|
+
return Number.isInteger(value) && value > 0 ? value : DEFAULT_CLOUD_RUN_MAX_MINUTES;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A cloud runner starts from a clean container, so it can only read plans and
|
|
19
|
+
* save evidence where Bugmole can reach them: Bugmole storage.
|
|
20
|
+
*/
|
|
21
|
+
export async function cloudStorageError(env: Env, projectId: string): Promise<Response | null> {
|
|
22
|
+
const row = await env.DB.prepare("SELECT storage_mode AS storageMode FROM projects WHERE id = ?")
|
|
23
|
+
.bind(projectId).first<{ storageMode: string }>();
|
|
24
|
+
if (row?.storageMode === "managed") return null;
|
|
25
|
+
return response({
|
|
26
|
+
error: "Cloud runs need this project to use Bugmole storage, so the runner can read its plans and save evidence. Change storage in Manage → Projects, or run on your own machine.",
|
|
27
|
+
code: "cloud_needs_managed_storage",
|
|
28
|
+
}, 422);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Stops cloud work that outlived the time limit or the workspace's minutes. */
|
|
32
|
+
async function cloudLimits(env: Env, job: JobHeartbeat) {
|
|
33
|
+
if (job.executionMode !== "cloud" || !job.workspaceId) return null;
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const active = await env.DB.prepare(`
|
|
36
|
+
SELECT 'run' AS kind, id, claimed_at AS claimedAt FROM runs
|
|
37
|
+
WHERE workspace_id = ? AND execution_mode = 'cloud' AND status = 'running' AND claimed_at IS NOT NULL
|
|
38
|
+
UNION ALL
|
|
39
|
+
SELECT 'task' AS kind, id, claimed_at AS claimedAt FROM agent_tasks
|
|
40
|
+
WHERE workspace_id = ? AND execution_mode = 'cloud' AND status = 'running' AND claimed_at IS NOT NULL
|
|
41
|
+
`).bind(job.workspaceId, job.workspaceId).all<{ kind: string; id: string; claimedAt: string }>();
|
|
42
|
+
const own = active.results.find((row) => row.kind === job.kind && row.id === job.id);
|
|
43
|
+
if (!own) return null;
|
|
44
|
+
let stop: { reason: string; message: string } | null = null;
|
|
45
|
+
if (elapsedMinutes(own.claimedAt, now) > cloudRunMaxMinutes(env)) {
|
|
46
|
+
stop = { reason: "time_limit", message: `Cloud work stops after ${cloudRunMaxMinutes(env)} minutes.` };
|
|
47
|
+
} else if ((await entitlementMode(env, job.workspaceId)) === "enforce") {
|
|
48
|
+
// Minutes of everything still going count, not just this job.
|
|
49
|
+
const inFlight = active.results.reduce((sum, row) => sum + elapsedMinutes(row.claimedAt, now), 0);
|
|
50
|
+
const decision = await canSpend(env, job.workspaceId, "browser_minute", inFlight);
|
|
51
|
+
if (!decision.allowed) stop = { reason: decision.reason, message: decision.message };
|
|
52
|
+
}
|
|
53
|
+
if (stop) {
|
|
54
|
+
await env.DB.prepare(`UPDATE ${JOB_TABLE[job.kind]} SET blocked_reason = ? WHERE id = ?`).bind(stop.reason, job.id).run();
|
|
55
|
+
}
|
|
56
|
+
return stop;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
heartbeatChecks.push(cloudLimits);
|