@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,158 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
interface ReviewOptions {
|
|
5
|
+
filePath: string;
|
|
6
|
+
changeSummary: string;
|
|
7
|
+
diff: string;
|
|
8
|
+
evidence: any;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ReviewResult {
|
|
12
|
+
status: "success" | "error";
|
|
13
|
+
verdict?: "APPROVED" | "REJECTED" | "REQUIRE_CLARIFICATION";
|
|
14
|
+
reasoning?: string;
|
|
15
|
+
riskAssessment?: {
|
|
16
|
+
level: "low" | "medium" | "high";
|
|
17
|
+
failureModes?: string[];
|
|
18
|
+
couldMaskDefect?: boolean;
|
|
19
|
+
};
|
|
20
|
+
conditions?: string[];
|
|
21
|
+
message?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Collects every string leaf value out of an arbitrarily-shaped evidence
|
|
27
|
+
* object. Callers pass evidence as free-form JSON (trace/video/screenshot/
|
|
28
|
+
* network-receipt references per docs/governor-policy.md §2); this makes no
|
|
29
|
+
* assumption about its exact keys, only that a real reference is a string.
|
|
30
|
+
*/
|
|
31
|
+
function collectEvidenceStrings(value: unknown, out: string[] = []): string[] {
|
|
32
|
+
if (typeof value === "string") {
|
|
33
|
+
if (value.trim()) out.push(value.trim());
|
|
34
|
+
} else if (Array.isArray(value)) {
|
|
35
|
+
for (const item of value) collectEvidenceStrings(item, out);
|
|
36
|
+
} else if (value && typeof value === "object") {
|
|
37
|
+
for (const item of Object.values(value)) collectEvidenceStrings(item, out);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A referenced evidence string is verifiable if it resolves to a real file,
|
|
44
|
+
* either as an absolute path or relative to the run's artifacts directory.
|
|
45
|
+
* Non-path strings (e.g. a short free-text note) never verify, by design:
|
|
46
|
+
* per docs/governor-policy.md §6, a proof token must be evidence, not prose.
|
|
47
|
+
*/
|
|
48
|
+
function evidenceStringExists(reference: string, artifactsDir: string): boolean {
|
|
49
|
+
const candidates = path.isAbsolute(reference)
|
|
50
|
+
? [reference]
|
|
51
|
+
: [path.join(artifactsDir, reference), path.resolve(reference)];
|
|
52
|
+
return candidates.some((candidate) => {
|
|
53
|
+
try {
|
|
54
|
+
return fs.statSync(candidate).isFile();
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reviewer: Validates proposed changes for safety and correctness.
|
|
63
|
+
*
|
|
64
|
+
* This is the anti-"false green" gate from docs/governor-policy.md §6: a
|
|
65
|
+
* change is never APPROVED on the strength of a non-empty `evidence` object
|
|
66
|
+
* alone — at least one referenced artifact must actually exist on disk.
|
|
67
|
+
*/
|
|
68
|
+
export async function review(cfg: any, options: ReviewOptions): Promise<ReviewResult> {
|
|
69
|
+
try {
|
|
70
|
+
const { filePath, changeSummary, diff, evidence } = options;
|
|
71
|
+
const specDir = cfg.runtime.spec_dir || "./spec";
|
|
72
|
+
const artifactsDir = cfg.runtime.artifacts_dir || "./artifacts";
|
|
73
|
+
const fullPath = path.join(specDir, filePath);
|
|
74
|
+
|
|
75
|
+
if (!fs.existsSync(fullPath)) {
|
|
76
|
+
return {
|
|
77
|
+
status: "error",
|
|
78
|
+
error: `File not found: ${filePath}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Loaded to confirm the target file is readable at review time; the
|
|
83
|
+
// content itself is not otherwise inspected here.
|
|
84
|
+
fs.readFileSync(fullPath, "utf-8");
|
|
85
|
+
|
|
86
|
+
let verdict: "APPROVED" | "REJECTED" | "REQUIRE_CLARIFICATION" = "REQUIRE_CLARIFICATION";
|
|
87
|
+
let riskLevel: "low" | "medium" | "high" = "medium";
|
|
88
|
+
|
|
89
|
+
const evidenceReferences = collectEvidenceStrings(evidence);
|
|
90
|
+
const verifiedEvidence = evidenceReferences.filter((reference) => evidenceStringExists(reference, artifactsDir));
|
|
91
|
+
|
|
92
|
+
const checks = {
|
|
93
|
+
evidenceReferenced: evidenceReferences.length > 0,
|
|
94
|
+
hasVerifiedEvidence: verifiedEvidence.length > 0,
|
|
95
|
+
hasDiff: !!diff && diff.length > 10,
|
|
96
|
+
hasSummary: !!changeSummary && changeSummary.length > 10,
|
|
97
|
+
diffReducesProofs: diff.includes("proofs:") && /proofs:\s*\[\s*\]/.test(diff),
|
|
98
|
+
diffRemovesJourney: diff.includes("- id:") && diff.includes("journey"),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (!checks.hasVerifiedEvidence || !checks.hasDiff || !checks.hasSummary) {
|
|
102
|
+
verdict = "REQUIRE_CLARIFICATION";
|
|
103
|
+
riskLevel = "high";
|
|
104
|
+
} else if (checks.diffReducesProofs || checks.diffRemovesJourney) {
|
|
105
|
+
verdict = "REJECTED";
|
|
106
|
+
riskLevel = "high";
|
|
107
|
+
} else {
|
|
108
|
+
verdict = "APPROVED";
|
|
109
|
+
riskLevel = "low";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const evidenceNote = !checks.evidenceReferenced
|
|
113
|
+
? "no evidence referenced"
|
|
114
|
+
: checks.hasVerifiedEvidence
|
|
115
|
+
? `${verifiedEvidence.length}/${evidenceReferences.length} referenced artifacts verified on disk`
|
|
116
|
+
: `${evidenceReferences.length} referenced artifact(s), none found on disk`;
|
|
117
|
+
const reasoning = `Review based on verified evidence presence, diff quality, and safety checks. Evidence: ${evidenceNote}. Diff quality: ${checks.hasDiff}, Summary: ${checks.hasSummary}.`;
|
|
118
|
+
|
|
119
|
+
const riskAssessment = {
|
|
120
|
+
level: riskLevel,
|
|
121
|
+
failureModes: [
|
|
122
|
+
...(checks.diffReducesProofs ? ["Proof requirements reduced"] : []),
|
|
123
|
+
...(checks.diffRemovesJourney ? ["Journey removed"] : []),
|
|
124
|
+
...(!checks.hasVerifiedEvidence ? ["No independently verifiable evidence"] : []),
|
|
125
|
+
],
|
|
126
|
+
couldMaskDefect: checks.diffReducesProofs || checks.diffRemovesJourney || !checks.hasVerifiedEvidence,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const conditions: string[] = [];
|
|
130
|
+
if (verdict === "APPROVED") {
|
|
131
|
+
conditions.push("Safe to auto-apply if risk is low and change type is auto-learnable");
|
|
132
|
+
} else if (verdict === "REQUIRE_CLARIFICATION") {
|
|
133
|
+
conditions.push(
|
|
134
|
+
checks.hasVerifiedEvidence
|
|
135
|
+
? "Missing or insufficient evidence"
|
|
136
|
+
: "Referenced evidence could not be verified on disk",
|
|
137
|
+
);
|
|
138
|
+
conditions.push("Diff quality needs improvement");
|
|
139
|
+
} else {
|
|
140
|
+
conditions.push("Change reduces coverage or weakens proofs");
|
|
141
|
+
conditions.push("Requires human approval");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
status: "success",
|
|
146
|
+
verdict,
|
|
147
|
+
reasoning,
|
|
148
|
+
riskAssessment,
|
|
149
|
+
conditions,
|
|
150
|
+
message: `Review completed. Verdict: ${verdict}`,
|
|
151
|
+
};
|
|
152
|
+
} catch (error: any) {
|
|
153
|
+
return {
|
|
154
|
+
status: "error",
|
|
155
|
+
error: error.message || "Review failed",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Executes one claimed registry run and reports it: heartbeats while it runs,
|
|
2
|
+
// then the final result. Shared by `bugmole serve` (the customer's own worker)
|
|
3
|
+
// and `bugmole run-once` (a Bugmole Cloud runner).
|
|
4
|
+
import type { ControlPlaneClient, RegistryRun } from "../registry/control-plane-client.js";
|
|
5
|
+
import { runKey } from "../storage/keys.js";
|
|
6
|
+
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
7
|
+
import { normalizeTargets } from "./browser-matrix.js";
|
|
8
|
+
import { execute } from "./executor.js";
|
|
9
|
+
import type { FailureAnalysis, FailureContext } from "./failure-analysis.js";
|
|
10
|
+
import { loadDeviceCloudAccess, type DeviceCloudAccess } from "./device-clouds/index.js";
|
|
11
|
+
|
|
12
|
+
/** The registry expires a claim 60s after its last update, so a running run renews it well inside that. */
|
|
13
|
+
export const RUN_HEARTBEAT_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
/** The registry asked the worker to stop (usage cap, time limit). */
|
|
16
|
+
export type StopSignal = { reason: string; message: string };
|
|
17
|
+
|
|
18
|
+
export type ExecuteResult = Awaited<ReturnType<typeof execute>>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The fix an earlier failed run suggested, read from that run's stored
|
|
22
|
+
* analysis. A missing or fix-less analysis applies nothing, so the run
|
|
23
|
+
* reports it rather than silently re-running the old flow.
|
|
24
|
+
*/
|
|
25
|
+
export async function loadSuggestedFix(cfg: any, projectId: string, fixFrom: { runId: string; targetId: string; mode?: "suggested" | "ai" }) {
|
|
26
|
+
const store = createProjectObjectStore(cfg);
|
|
27
|
+
const base = { sourceRunId: fixFrom.runId, targetId: fixFrom.targetId, mode: fixFrom.mode ?? "suggested" } as const;
|
|
28
|
+
const read = async <T>(name: string): Promise<T | undefined> => {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(await store.getText(runKey(projectId, fixFrom.runId, `artifacts/${fixFrom.targetId}/${name}`))) as T;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const analysis = await read<FailureAnalysis>("analysis.json");
|
|
36
|
+
if (base.mode !== "ai") return { ...base, changes: analysis?.suggestedFix?.changes ?? [] };
|
|
37
|
+
const failureContext = await read<FailureContext>("failure-context.json");
|
|
38
|
+
return { ...base, changes: [], ...(analysis ? { analysis } : {}), ...(failureContext ? { failureContext } : {}) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stopSignalOf(reply: unknown): StopSignal | null {
|
|
42
|
+
const body = reply as { stop?: unknown; reason?: unknown; message?: unknown } | null;
|
|
43
|
+
if (!body || body.stop !== true) return null;
|
|
44
|
+
return {
|
|
45
|
+
reason: typeof body.reason === "string" ? body.reason : "stopped",
|
|
46
|
+
message: typeof body.message === "string" ? body.message : "The registry stopped this run.",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ExecuteRunOptions = {
|
|
51
|
+
projectId: string;
|
|
52
|
+
workerId: string;
|
|
53
|
+
/** Called once if the registry answers an update with `stop`. */
|
|
54
|
+
onStop?: (signal: StopSignal) => void;
|
|
55
|
+
execute?: typeof execute;
|
|
56
|
+
heartbeatMs?: number;
|
|
57
|
+
/** Loads device-cloud credentials and app builds, for runs that target cloud devices. */
|
|
58
|
+
loadDeviceAccess?: () => Promise<DeviceCloudAccess>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** How a worker fetches a run's device access from the registry. */
|
|
62
|
+
export function registryDeviceAccess(cfg: any, registryUrl: string, token: string, projectId: string, runId: string): () => Promise<DeviceCloudAccess> {
|
|
63
|
+
return () => loadDeviceCloudAccess({
|
|
64
|
+
registryUrl,
|
|
65
|
+
token,
|
|
66
|
+
runId,
|
|
67
|
+
loadObject: (key) => createProjectObjectStore({ ...cfg, project: { ...(cfg.project ?? {}), id: projectId } }).get(key),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Runs a run this worker already holds and reports its result. Throws if execution itself fails. */
|
|
72
|
+
export async function executeRegistryRun(
|
|
73
|
+
cfg: any,
|
|
74
|
+
client: Pick<ControlPlaneClient, "updateRun">,
|
|
75
|
+
run: RegistryRun,
|
|
76
|
+
options: ExecuteRunOptions,
|
|
77
|
+
): Promise<ExecuteResult> {
|
|
78
|
+
const { workerId } = options;
|
|
79
|
+
let stopped = false;
|
|
80
|
+
const report = async (update: Record<string, unknown>) => {
|
|
81
|
+
const reply = await client.updateRun(run.id, update);
|
|
82
|
+
const signal = stopSignalOf(reply);
|
|
83
|
+
if (signal && !stopped) {
|
|
84
|
+
stopped = true;
|
|
85
|
+
options.onStop?.(signal);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
// An update overwrites the run's step counts, so a heartbeat resends the latest progress.
|
|
89
|
+
let lastProgress: Record<string, unknown> = {};
|
|
90
|
+
const heartbeat = setInterval(() => {
|
|
91
|
+
void report({ workerId, status: "running", eventType: "heartbeat", ...lastProgress }).catch(() => undefined);
|
|
92
|
+
}, options.heartbeatMs ?? RUN_HEARTBEAT_MS);
|
|
93
|
+
heartbeat.unref?.();
|
|
94
|
+
try {
|
|
95
|
+
// A malformed matrix is a request problem, not a reason to run a
|
|
96
|
+
// different set of browsers than the person asked for.
|
|
97
|
+
const targets = normalizeTargets(run.targets ?? undefined);
|
|
98
|
+
const applyFix = run.fixFrom ? await loadSuggestedFix(cfg, options.projectId, run.fixFrom) : undefined;
|
|
99
|
+
const deviceClouds = targets.some((target) => target.cloud) && options.loadDeviceAccess ? await options.loadDeviceAccess() : undefined;
|
|
100
|
+
const result = await (options.execute ?? execute)(cfg, {
|
|
101
|
+
...(applyFix ? { applyFix } : {}),
|
|
102
|
+
...(deviceClouds ? { deviceClouds } : {}),
|
|
103
|
+
planId: run.planId,
|
|
104
|
+
journeyId: run.journeyId,
|
|
105
|
+
runId: run.id,
|
|
106
|
+
baseUrl: run.environmentBaseUrl || undefined,
|
|
107
|
+
targets: targets.length > 0 ? targets : undefined,
|
|
108
|
+
onProgress: async (progress) => {
|
|
109
|
+
// Step-level updates don't carry the matrix; keep the last known one.
|
|
110
|
+
lastProgress = { ...lastProgress, ...progress };
|
|
111
|
+
await report({ workerId, status: "running", eventType: "progress", ...lastProgress });
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
clearInterval(heartbeat);
|
|
115
|
+
await client.updateRun(run.id, {
|
|
116
|
+
workerId,
|
|
117
|
+
status: result.status === "success" ? "success" : result.status === "partial" ? "partial" : "error",
|
|
118
|
+
eventType: "completed",
|
|
119
|
+
stepsExecuted: result.stepsExecuted,
|
|
120
|
+
stepsPassed: result.stepsPassed,
|
|
121
|
+
stepsFailed: result.stepsFailed,
|
|
122
|
+
driver: result.driver,
|
|
123
|
+
platform: result.platform,
|
|
124
|
+
recoveryAttempts: result.recoveryAttempts,
|
|
125
|
+
failureCode: result.failureCode,
|
|
126
|
+
artifactSummaryJson: result.manifestKey ? JSON.stringify({ manifest: result.manifestKey }) : undefined,
|
|
127
|
+
manifestKey: result.manifestKey,
|
|
128
|
+
errorMessage: result.error,
|
|
129
|
+
targetResults: result.targetResults,
|
|
130
|
+
durationMs: result.durationMs,
|
|
131
|
+
});
|
|
132
|
+
return result;
|
|
133
|
+
} finally {
|
|
134
|
+
clearInterval(heartbeat);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import worker from "../registry-worker/index.js";
|
|
4
|
+
import { call, createContext, createTestEnv, seedPlan, seedProject, seedVerifiedDomain } from "../registry-worker/testing/harness.js";
|
|
5
|
+
import { runnerLauncher, type LaunchRequest } from "../registry-worker/runner/dispatch.js";
|
|
6
|
+
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
7
|
+
import { RegistryObjectStore } from "../storage/registry-object-store.js";
|
|
8
|
+
import { parseArgs, resolveMode } from "../../scripts/bugmole.js";
|
|
9
|
+
import { cloudRunConfig, runOnce } from "./run-once.js";
|
|
10
|
+
import type { execute } from "./executor.js";
|
|
11
|
+
|
|
12
|
+
type Execute = typeof execute;
|
|
13
|
+
type Result = Awaited<ReturnType<Execute>>;
|
|
14
|
+
|
|
15
|
+
/** Sends every fetch to the in-process registry. */
|
|
16
|
+
function registryFetch(env: ReturnType<typeof createTestEnv>["env"]): typeof fetch {
|
|
17
|
+
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
18
|
+
const { ctx, flush } = createContext();
|
|
19
|
+
const reply = await worker.fetch(new Request(input, init), env, ctx);
|
|
20
|
+
await flush();
|
|
21
|
+
return reply;
|
|
22
|
+
}) as typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function withEnv<T>(values: Record<string, string>, body: () => Promise<T>): Promise<T> {
|
|
26
|
+
const saved = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]));
|
|
27
|
+
Object.assign(process.env, values);
|
|
28
|
+
return body().finally(() => {
|
|
29
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
30
|
+
if (value === undefined) delete process.env[key];
|
|
31
|
+
else process.env[key] = value;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function queuedCloudRun() {
|
|
37
|
+
const setup = createTestEnv({ RUNNER_TOKEN_SECRET: "s", FEATURE_CLOUD_RUNNERS: "on" });
|
|
38
|
+
const { env, artifacts, db } = setup;
|
|
39
|
+
const project = await seedProject(env, { storageProvider: "bugmole" });
|
|
40
|
+
await seedPlan(env, project.projectId, project.owner);
|
|
41
|
+
await artifacts.put(`projects/${project.projectId}/plans/checkout.flow.yaml`, "suites: []\n");
|
|
42
|
+
// The discovery test below explores https://shop.test.
|
|
43
|
+
seedVerifiedDomain(db, project.workspaceId, "shop.test");
|
|
44
|
+
const launches: LaunchRequest[] = [];
|
|
45
|
+
const original = runnerLauncher.start;
|
|
46
|
+
runnerLauncher.start = async (_env, launch) => {
|
|
47
|
+
launches.push(launch);
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
const created = await call(env, "POST", `/api/projects/${project.projectId}/runs`, {
|
|
51
|
+
as: project.owner,
|
|
52
|
+
body: { planId: "checkout", idempotencyKey: "k", executionMode: "cloud", targets: [{ browser: "firefox" }] },
|
|
53
|
+
});
|
|
54
|
+
assert.equal(created.status, 201);
|
|
55
|
+
} finally {
|
|
56
|
+
runnerLauncher.start = original;
|
|
57
|
+
}
|
|
58
|
+
return { ...setup, ...project, launch: launches[0] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("bugmole run-once is its own command", () => {
|
|
62
|
+
assert.equal(resolveMode(parseArgs(["node", "bugmole", "run-once"])), "run-once");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a cloud runner executes its run with Bugmole storage and reports back", async () => {
|
|
66
|
+
const { env, artifacts, owner, projectId, launch } = await queuedCloudRun();
|
|
67
|
+
const originalFetch = globalThis.fetch;
|
|
68
|
+
globalThis.fetch = registryFetch(env);
|
|
69
|
+
const seen: Array<{ plan: string; targets: unknown }> = [];
|
|
70
|
+
const fakeExecute = (async (cfg: any, options: any): Promise<Result> => {
|
|
71
|
+
const store = createProjectObjectStore(cfg);
|
|
72
|
+
seen.push({ plan: await store.getText(`projects/${projectId}/plans/checkout.flow.yaml`), targets: options.targets });
|
|
73
|
+
const manifestKey = `projects/${projectId}/runs/${options.runId}/manifest.json`;
|
|
74
|
+
await store.put(manifestKey, "{}", "application/json");
|
|
75
|
+
await options.onProgress({ stepsExecuted: 1 });
|
|
76
|
+
return { status: "success", stepsExecuted: 1, stepsPassed: 1, stepsFailed: 0, manifestKey, durationMs: 10 } as unknown as Result;
|
|
77
|
+
}) as Execute;
|
|
78
|
+
try {
|
|
79
|
+
const code = await withEnv({ BUGMOLE_REGISTRY_URL: launch.registryUrl, BUGMOLE_RUN_ID: launch.id, BUGMOLE_RUN_TOKEN: launch.token }, () =>
|
|
80
|
+
runOnce(process.env, { execute: fakeExecute, log: () => undefined }));
|
|
81
|
+
assert.equal(code, 0);
|
|
82
|
+
} finally {
|
|
83
|
+
globalThis.fetch = originalFetch;
|
|
84
|
+
}
|
|
85
|
+
assert.equal(seen[0].plan, "suites: []\n");
|
|
86
|
+
assert.deepEqual(seen[0].targets, [{ id: "firefox", browser: "firefox" }]);
|
|
87
|
+
const run = await call(env, "GET", `/api/runs/${launch.id}`, { as: owner });
|
|
88
|
+
assert.equal(run.json.run.status, "success");
|
|
89
|
+
assert.equal(run.json.run.manifestKey, `projects/${projectId}/runs/${launch.id}/manifest.json`);
|
|
90
|
+
assert.equal(run.json.run.billedMinutes, 1);
|
|
91
|
+
assert.ok(artifacts.objects.has(run.json.run.manifestKey));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a runner stops when the registry says so, and a crash is reported", async () => {
|
|
95
|
+
const updates: Array<Record<string, unknown>> = [];
|
|
96
|
+
const bundle = {
|
|
97
|
+
run: { id: "run_1", projectId: "shop", planId: "checkout", status: "running" },
|
|
98
|
+
project: { id: "shop" },
|
|
99
|
+
workerId: "cloud:run_1",
|
|
100
|
+
};
|
|
101
|
+
const fetchBundle = (async () => Response.json(bundle)) as unknown as typeof fetch;
|
|
102
|
+
const client = {
|
|
103
|
+
async updateRun(_id: string, update: Record<string, unknown>) {
|
|
104
|
+
updates.push(update);
|
|
105
|
+
return update.eventType === "progress" ? { updated: true, stop: true, reason: "quota", message: "Minutes are used up." } : { updated: true };
|
|
106
|
+
},
|
|
107
|
+
async updateTask() {
|
|
108
|
+
return { updated: true };
|
|
109
|
+
},
|
|
110
|
+
async publishPlan() {
|
|
111
|
+
return { plan: {} };
|
|
112
|
+
},
|
|
113
|
+
async claimTask() {
|
|
114
|
+
return { claimed: true };
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
const env = { BUGMOLE_REGISTRY_URL: "https://r", BUGMOLE_RUN_ID: "run_1", BUGMOLE_RUN_TOKEN: "vrt_x" };
|
|
118
|
+
const hangs = (async (_cfg: unknown, options: any) => {
|
|
119
|
+
await options.onProgress({ stepsExecuted: 1 });
|
|
120
|
+
return new Promise<Result>(() => undefined);
|
|
121
|
+
}) as Execute;
|
|
122
|
+
assert.equal(await runOnce(env, { fetch: fetchBundle, client: client as never, execute: hangs, log: () => undefined }), 0);
|
|
123
|
+
assert.deepEqual(updates.at(-1), { workerId: "cloud:run_1", status: "error", eventType: "stopped", failureCode: "usage_limit", errorMessage: "Minutes are used up." });
|
|
124
|
+
|
|
125
|
+
const crashes = (async () => {
|
|
126
|
+
throw new Error("browser crashed");
|
|
127
|
+
}) as Execute;
|
|
128
|
+
assert.equal(await runOnce(env, { fetch: fetchBundle, client: client as never, execute: crashes, log: () => undefined }), 1);
|
|
129
|
+
assert.equal(updates.at(-1)?.errorMessage, "browser crashed");
|
|
130
|
+
|
|
131
|
+
assert.equal(await runOnce({}, { log: () => undefined }), 2, "missing run settings");
|
|
132
|
+
const refused = (async () => new Response("{}", { status: 409 })) as unknown as typeof fetch;
|
|
133
|
+
assert.equal(await runOnce(env, { fetch: refused, log: () => undefined }), 1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a cloud runner explores a site, publishes the flows, and keeps the stored journey graph", async () => {
|
|
137
|
+
const { env, artifacts, owner, projectId } = await queuedCloudRun();
|
|
138
|
+
await artifacts.put(`projects/${projectId}/journeys/graph.json`, JSON.stringify({
|
|
139
|
+
version: 1, projectId, journeys: [], edges: [],
|
|
140
|
+
nodes: [{ id: "screen:/pricing", kind: "screen", title: "Pricing", route: "/pricing" }],
|
|
141
|
+
}));
|
|
142
|
+
const launches: LaunchRequest[] = [];
|
|
143
|
+
const original = runnerLauncher.start;
|
|
144
|
+
runnerLauncher.start = async (_env, launch) => {
|
|
145
|
+
launches.push(launch);
|
|
146
|
+
};
|
|
147
|
+
let taskId = "";
|
|
148
|
+
try {
|
|
149
|
+
const created = await call(env, "POST", `/api/projects/${projectId}/tasks`, {
|
|
150
|
+
as: owner, body: { title: "Explore https://shop.test", description: "Explore https://shop.test/", taskType: "discovery", idempotencyKey: "d", executionMode: "cloud" },
|
|
151
|
+
});
|
|
152
|
+
taskId = created.json.task.id;
|
|
153
|
+
} finally {
|
|
154
|
+
runnerLauncher.start = original;
|
|
155
|
+
}
|
|
156
|
+
const launch = launches.find((item) => item.kind === "task")!;
|
|
157
|
+
const discover = (async (url: string) => ({
|
|
158
|
+
baseUrl: url,
|
|
159
|
+
events: [{ at: new Date().toISOString(), kind: "page", text: "Opened /" }],
|
|
160
|
+
pages: [{ route: "/", title: "Shop", path: [], links: [], forms: [] }],
|
|
161
|
+
flows: [{ id: "discovered-home", name: "Open the shop", kind: "page", route: "/", steps: ["Open the shop"], file: "flows/discovered/home.yaml", content: "url: https://shop.test/\n---\n- launchApp\n" }],
|
|
162
|
+
})) as never;
|
|
163
|
+
const originalFetch = globalThis.fetch;
|
|
164
|
+
globalThis.fetch = registryFetch(env);
|
|
165
|
+
try {
|
|
166
|
+
const code = await withEnv({ BUGMOLE_REGISTRY_URL: launch.registryUrl, BUGMOLE_TASK_ID: launch.id, BUGMOLE_RUN_TOKEN: launch.token }, () =>
|
|
167
|
+
runOnce({ ...process.env, BUGMOLE_RUN_ID: "" }, { discover, log: () => undefined }));
|
|
168
|
+
assert.equal(code, 0);
|
|
169
|
+
} finally {
|
|
170
|
+
globalThis.fetch = originalFetch;
|
|
171
|
+
}
|
|
172
|
+
const tasks = await call(env, "GET", `/api/projects/${projectId}/tasks`, { as: owner });
|
|
173
|
+
const task = tasks.json.tasks.find((item: { id: string }) => item.id === taskId);
|
|
174
|
+
assert.equal(task.status, "completed");
|
|
175
|
+
assert.equal(task.billedMinutes, 1);
|
|
176
|
+
const plans = await call(env, "GET", `/api/projects/${projectId}/plans`, { as: owner });
|
|
177
|
+
assert.ok(plans.json.plans.some((plan: { planId: string }) => plan.planId === "discovered-home"));
|
|
178
|
+
assert.ok(artifacts.objects.has(`projects/${projectId}/spec/flows/discovered/home.yaml`));
|
|
179
|
+
assert.ok(artifacts.objects.has(`projects/${projectId}/plans/discovered-home.flow.yaml`));
|
|
180
|
+
const graph = JSON.parse(await (await artifacts.get(`projects/${projectId}/journeys/graph.json`))!.text());
|
|
181
|
+
const routes = graph.nodes.map((node: { route: string }) => node.route).sort();
|
|
182
|
+
assert.deepEqual(routes, ["/", "/pricing"], "the earlier graph is kept");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("Bugmole storage lists and deletes through the registry", async () => {
|
|
186
|
+
const { env, owner, projectId } = await queuedCloudRun();
|
|
187
|
+
const key = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "cli", projectIds: [projectId], scopes: ["runs:write", "projects:read"] } });
|
|
188
|
+
assert.equal(key.status, 201, JSON.stringify(key.json));
|
|
189
|
+
const store = new RegistryObjectStore({ registryUrl: "https://registry.test", token: key.json.token, projectId, fetch: registryFetch(env) });
|
|
190
|
+
await store.put(`projects/${projectId}/spec/flows/a.yaml`, "- launchApp\n", "text/yaml");
|
|
191
|
+
await store.put(`projects/${projectId}/spec/flows/b.yaml`, "- launchApp\n", "text/yaml");
|
|
192
|
+
assert.equal(await store.has(`projects/${projectId}/spec/flows/a.yaml`), true);
|
|
193
|
+
assert.equal(await store.has(`projects/${projectId}/spec/flows/missing.yaml`), false);
|
|
194
|
+
assert.deepEqual((await store.list(`projects/${projectId}/spec/`)).map((item) => item.key).sort(), [
|
|
195
|
+
`projects/${projectId}/spec/flows/a.yaml`,
|
|
196
|
+
`projects/${projectId}/spec/flows/b.yaml`,
|
|
197
|
+
]);
|
|
198
|
+
await store.deletePrefix(`projects/${projectId}/spec`);
|
|
199
|
+
assert.deepEqual(await store.list(`projects/${projectId}/spec/`), []);
|
|
200
|
+
await assert.rejects(store.get(`projects/${projectId}/spec/flows/a.yaml`), /404/);
|
|
201
|
+
await assert.rejects(store.put("projects/other/x.yaml", "x"), /400|403/);
|
|
202
|
+
|
|
203
|
+
const cfg = cloudRunConfig({ project: { id: projectId }, workerId: "w" }, "/tmp/x", "https://registry.test");
|
|
204
|
+
await withEnv({ BUGMOLE_RUN_TOKEN: "vrt_token" }, async () => {
|
|
205
|
+
assert.equal(createProjectObjectStore(cfg).provider, "bugmole");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// `bugmole run-once`: what a Bugmole Cloud container runs. It starts clean,
|
|
2
|
+
// fetches its job (a test run or a discovery task) from the registry with the
|
|
3
|
+
// token it was given, executes it against Bugmole storage, reports, and
|
|
4
|
+
// exits. The container stops with it.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { ControlPlaneClient, type RegistryRun, type RegistryTask } from "../registry/control-plane-client.js";
|
|
9
|
+
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
10
|
+
import { projectPrefix } from "../storage/keys.js";
|
|
11
|
+
import { executeRegistryRun, registryDeviceAccess, type StopSignal } from "./run-job.js";
|
|
12
|
+
import { runDiscoveryTask } from "./discovery-task.js";
|
|
13
|
+
import type { execute } from "./executor.js";
|
|
14
|
+
import type { discoverSite } from "./site-discovery.js";
|
|
15
|
+
|
|
16
|
+
type JobBundle = {
|
|
17
|
+
project: { id: string; name?: string; workspaceId?: string | null };
|
|
18
|
+
workerId: string;
|
|
19
|
+
maxMinutes?: number;
|
|
20
|
+
};
|
|
21
|
+
export type RunBundle = JobBundle & { run: RegistryRun };
|
|
22
|
+
export type TaskBundle = JobBundle & { task: RegistryTask };
|
|
23
|
+
|
|
24
|
+
type Env = Record<string, string | undefined>;
|
|
25
|
+
|
|
26
|
+
type RunnerClient = Pick<ControlPlaneClient, "updateRun" | "updateTask" | "publishPlan" | "claimTask">;
|
|
27
|
+
|
|
28
|
+
export type RunOnceDeps = {
|
|
29
|
+
fetch?: typeof fetch;
|
|
30
|
+
execute?: typeof execute;
|
|
31
|
+
discover?: typeof discoverSite;
|
|
32
|
+
client?: RunnerClient;
|
|
33
|
+
log?: (message: string) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The config cloud work executes with: web, headless, Bugmole storage, no local spec. */
|
|
37
|
+
export function cloudRunConfig(bundle: JobBundle, workdir: string, registryUrl: string) {
|
|
38
|
+
return {
|
|
39
|
+
version: 1,
|
|
40
|
+
project: {
|
|
41
|
+
id: bundle.project.id,
|
|
42
|
+
name: bundle.project.name ?? bundle.project.id,
|
|
43
|
+
workspace_id: bundle.project.workspaceId ?? undefined,
|
|
44
|
+
platform: "web",
|
|
45
|
+
},
|
|
46
|
+
runtime: {
|
|
47
|
+
artifacts_dir: path.join(workdir, "runs"),
|
|
48
|
+
spec_dir: path.join(workdir, "spec"),
|
|
49
|
+
},
|
|
50
|
+
execution: {
|
|
51
|
+
driver: "playwright",
|
|
52
|
+
playwright: { headless: true },
|
|
53
|
+
},
|
|
54
|
+
storage: {
|
|
55
|
+
provider: "bugmole",
|
|
56
|
+
bugmole: { registry_url: registryUrl },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FAILURE_CODES: Record<string, string> = {
|
|
62
|
+
time_limit: "time_limit",
|
|
63
|
+
quota: "usage_limit",
|
|
64
|
+
spending_limit: "usage_limit",
|
|
65
|
+
suspended: "usage_limit",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Discovery adds to the project's journey graph, which a clean container
|
|
70
|
+
* doesn't have on disk; start from the stored one so nothing is lost.
|
|
71
|
+
*/
|
|
72
|
+
async function restoreJourneyGraph(cfg: ReturnType<typeof cloudRunConfig>): Promise<void> {
|
|
73
|
+
const store = createProjectObjectStore(cfg);
|
|
74
|
+
const key = `${projectPrefix(cfg.project.id)}/journeys/graph.json`;
|
|
75
|
+
if (!(await store.has(key))) return;
|
|
76
|
+
const file = path.join(cfg.runtime.spec_dir, "journeys.graph.json");
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
fs.writeFileSync(file, await store.getText(key), "utf8");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returns the process exit code. */
|
|
82
|
+
export async function runOnce(env: Env = process.env, deps: RunOnceDeps = {}): Promise<number> {
|
|
83
|
+
const log = deps.log ?? ((message: string) => console.log(`[Bugmole] ${message}`));
|
|
84
|
+
const registryUrl = env.BUGMOLE_REGISTRY_URL?.trim().replace(/\/$/, "");
|
|
85
|
+
const runId = env.BUGMOLE_RUN_ID?.trim();
|
|
86
|
+
const taskId = env.BUGMOLE_TASK_ID?.trim();
|
|
87
|
+
const token = env.BUGMOLE_RUN_TOKEN?.trim();
|
|
88
|
+
const job = runId ? { kind: "run" as const, id: runId } : taskId ? { kind: "task" as const, id: taskId } : null;
|
|
89
|
+
if (!registryUrl || !job || !token) {
|
|
90
|
+
log("run-once needs BUGMOLE_REGISTRY_URL, BUGMOLE_RUN_TOKEN, and BUGMOLE_RUN_ID or BUGMOLE_TASK_ID");
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
const doFetch = deps.fetch ?? fetch;
|
|
94
|
+
const reply = await doFetch(`${registryUrl}/api/runner/${job.kind}s/${encodeURIComponent(job.id)}/bundle`, {
|
|
95
|
+
headers: { authorization: `Bearer ${token}` },
|
|
96
|
+
}).catch((error: unknown) => {
|
|
97
|
+
log(`Couldn't reach the registry: ${error instanceof Error ? error.message : error}`);
|
|
98
|
+
return null;
|
|
99
|
+
});
|
|
100
|
+
if (!reply?.ok) {
|
|
101
|
+
if (reply) log(`The registry refused ${job.kind} ${job.id} (${reply.status})`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
const bundle = await reply.json() as RunBundle & TaskBundle;
|
|
105
|
+
const projectId = bundle.project.id;
|
|
106
|
+
const workerId = bundle.workerId;
|
|
107
|
+
const client: RunnerClient = deps.client ?? new ControlPlaneClient({ registryUrl, apiKey: token, projectId });
|
|
108
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-run-"));
|
|
109
|
+
const cfg = cloudRunConfig(bundle, workdir, registryUrl);
|
|
110
|
+
|
|
111
|
+
let stop: (signal: StopSignal) => void = () => undefined;
|
|
112
|
+
const stopped = new Promise<StopSignal>((resolve) => {
|
|
113
|
+
stop = resolve;
|
|
114
|
+
});
|
|
115
|
+
const deadline = bundle.maxMinutes
|
|
116
|
+
? setTimeout(() => stop({ reason: "time_limit", message: `Cloud work stops after ${bundle.maxMinutes} minutes.` }), bundle.maxMinutes * 60_000)
|
|
117
|
+
: undefined;
|
|
118
|
+
deadline?.unref?.();
|
|
119
|
+
|
|
120
|
+
const report = (update: Record<string, unknown>) => job.kind === "run"
|
|
121
|
+
? client.updateRun(job.id, update)
|
|
122
|
+
: client.updateTask(job.id, { workerId, status: update.status, errorMessage: update.errorMessage, progressMessage: update.errorMessage });
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
let work: Promise<string>;
|
|
126
|
+
if (job.kind === "run") {
|
|
127
|
+
log(`Running ${bundle.run.planId} for ${projectId} (${job.id})`);
|
|
128
|
+
work = executeRegistryRun(cfg, client, bundle.run, {
|
|
129
|
+
projectId,
|
|
130
|
+
workerId,
|
|
131
|
+
onStop: stop,
|
|
132
|
+
execute: deps.execute,
|
|
133
|
+
loadDeviceAccess: registryDeviceAccess(cfg, registryUrl, token, projectId, job.id),
|
|
134
|
+
})
|
|
135
|
+
.then((result) => result.status);
|
|
136
|
+
} else {
|
|
137
|
+
log(`Exploring for ${projectId} (${job.id})`);
|
|
138
|
+
await restoreJourneyGraph(cfg);
|
|
139
|
+
work = runDiscoveryTask(cfg, client, bundle.task, workerId, deps.discover, { claimed: true, onStop: stop })
|
|
140
|
+
.then(() => "done");
|
|
141
|
+
}
|
|
142
|
+
const outcome = await Promise.race([
|
|
143
|
+
work.then((status) => ({ status })),
|
|
144
|
+
stopped.then((signal) => ({ signal })),
|
|
145
|
+
]);
|
|
146
|
+
if ("signal" in outcome) {
|
|
147
|
+
log(`Stopped: ${outcome.signal.message}`);
|
|
148
|
+
await report({
|
|
149
|
+
workerId,
|
|
150
|
+
status: "error",
|
|
151
|
+
eventType: "stopped",
|
|
152
|
+
failureCode: FAILURE_CODES[outcome.signal.reason] ?? "stopped",
|
|
153
|
+
errorMessage: outcome.signal.message,
|
|
154
|
+
}).catch(() => undefined);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
log(`Finished: ${outcome.status}`);
|
|
158
|
+
return 0;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const message = error instanceof Error ? error.message : "Cloud work failed";
|
|
161
|
+
log(`Failed: ${message}`);
|
|
162
|
+
await report({ workerId, status: "error", eventType: "failed", errorMessage: message }).catch(() => undefined);
|
|
163
|
+
return 1;
|
|
164
|
+
} finally {
|
|
165
|
+
if (deadline) clearTimeout(deadline);
|
|
166
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
}
|