@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,45 @@
|
|
|
1
|
+
-- GitLab merge request checks: each binding brings its own access token and
|
|
2
|
+
-- webhook secret, since there is no Valkyrie-operated GitLab App to install.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS project_gitlab_bindings (
|
|
4
|
+
id TEXT PRIMARY KEY,
|
|
5
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
6
|
+
gitlab_host TEXT NOT NULL DEFAULT 'gitlab.com',
|
|
7
|
+
gitlab_project_id INTEGER NOT NULL,
|
|
8
|
+
gitlab_project_path TEXT NOT NULL,
|
|
9
|
+
access_token_ciphertext TEXT NOT NULL,
|
|
10
|
+
access_token_iv TEXT NOT NULL,
|
|
11
|
+
webhook_secret_ciphertext TEXT NOT NULL,
|
|
12
|
+
webhook_secret_iv TEXT NOT NULL,
|
|
13
|
+
-- JSON array of plan ids; null runs every published plan.
|
|
14
|
+
plan_ids_json TEXT,
|
|
15
|
+
environment_id TEXT,
|
|
16
|
+
-- merge_request: every open/reopen/update on a merge request.
|
|
17
|
+
trigger TEXT NOT NULL DEFAULT 'merge_request',
|
|
18
|
+
-- e.g. https://pr-{number}.preview.example.com; null uses the environment's own URL.
|
|
19
|
+
base_url_template TEXT,
|
|
20
|
+
execution_mode TEXT NOT NULL DEFAULT 'cloud',
|
|
21
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
22
|
+
created_by TEXT NOT NULL,
|
|
23
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
24
|
+
UNIQUE (project_id, gitlab_host, gitlab_project_id, trigger)
|
|
25
|
+
);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS project_gitlab_bindings_lookup ON project_gitlab_bindings(gitlab_host, gitlab_project_id);
|
|
27
|
+
|
|
28
|
+
ALTER TABLE runs ADD COLUMN gitlab_binding_id TEXT;
|
|
29
|
+
CREATE INDEX IF NOT EXISTS runs_gitlab_binding ON runs(gitlab_binding_id);
|
|
30
|
+
|
|
31
|
+
-- Microsoft Teams alerts: a channel webhook URL from the Workflows app (the
|
|
32
|
+
-- successor to the retired Office 365 Connectors), the same as email or
|
|
33
|
+
-- Slack in alert_rules.channel.
|
|
34
|
+
CREATE TABLE IF NOT EXISTS teams_installations (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
37
|
+
name TEXT,
|
|
38
|
+
webhook_url_ciphertext TEXT NOT NULL,
|
|
39
|
+
webhook_url_iv TEXT NOT NULL,
|
|
40
|
+
installed_by TEXT NOT NULL,
|
|
41
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
42
|
+
revoked_at TEXT,
|
|
43
|
+
revoked_reason TEXT
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS teams_installations_workspace ON teams_installations(workspace_id);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Explore-with-AI journeys are written from a persona's point of view. The
|
|
2
|
+
-- built-in "Curious" persona is lazily created the first time a project's
|
|
3
|
+
-- personas are listed, rather than backfilled here, so every project (old
|
|
4
|
+
-- and new) gets it without a data migration.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS project_personas (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
8
|
+
name TEXT NOT NULL,
|
|
9
|
+
description TEXT NOT NULL,
|
|
10
|
+
is_default INTEGER NOT NULL DEFAULT 0,
|
|
11
|
+
created_by TEXT NOT NULL,
|
|
12
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
13
|
+
UNIQUE (project_id, name)
|
|
14
|
+
);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS project_personas_project ON project_personas(project_id);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Valkyrie is now Bugmole. Stored names that carried the old brand move to
|
|
2
|
+
-- the new one; the registry still accepts "valkyrie" from 0.3.x CLIs and
|
|
3
|
+
-- rewrites it on the way in (currentStorageProvider, parseCloudDevice).
|
|
4
|
+
UPDATE projects SET storage_provider = 'bugmole' WHERE storage_provider = 'valkyrie';
|
|
5
|
+
UPDATE device_authorizations SET storage_provider = 'bugmole' WHERE storage_provider = 'valkyrie';
|
|
6
|
+
UPDATE runs SET targets_json = REPLACE(targets_json, '"provider":"valkyrie"', '"provider":"bugmole"')
|
|
7
|
+
WHERE targets_json LIKE '%"provider":"valkyrie"%';
|
|
8
|
+
|
|
9
|
+
DROP INDEX IF EXISTS valkyrie_device_sessions_open;
|
|
10
|
+
ALTER TABLE valkyrie_device_sessions RENAME TO bugmole_device_sessions;
|
|
11
|
+
CREATE INDEX IF NOT EXISTS bugmole_device_sessions_open ON bugmole_device_sessions(status, created_at);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
MAX_TASK_ATTEMPTS,
|
|
5
|
+
backoffUntil,
|
|
6
|
+
isClaimableTask,
|
|
7
|
+
isResumableTask,
|
|
8
|
+
resumedTaskFields,
|
|
9
|
+
} from "./task-scheduling.js";
|
|
10
|
+
|
|
11
|
+
const NOW = Date.UTC(2026, 0, 1, 12, 0, 0);
|
|
12
|
+
|
|
13
|
+
test("a queued task with no history is claimable", () => {
|
|
14
|
+
assert.equal(isClaimableTask({ status: "queued" }, NOW), true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("a blocked task is retried — that is what makes progress possible after a transient failure", () => {
|
|
18
|
+
assert.equal(isClaimableTask({ status: "blocked", attempts: 1 }, NOW), true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a task that exhausted its attempts is parked, not re-served forever", () => {
|
|
22
|
+
assert.equal(isClaimableTask({ status: "blocked", attempts: MAX_TASK_ATTEMPTS }, NOW), false);
|
|
23
|
+
assert.equal(isClaimableTask({ status: "queued", attempts: MAX_TASK_ATTEMPTS + 1 }, NOW), false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a task inside its backoff window is not claimable yet", () => {
|
|
27
|
+
const future = new Date(NOW + 60_000).toISOString();
|
|
28
|
+
assert.equal(isClaimableTask({ status: "blocked", attempts: 1, nextAttemptAt: future }, NOW), false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a task whose backoff has elapsed becomes claimable again", () => {
|
|
32
|
+
const past = new Date(NOW - 1_000).toISOString();
|
|
33
|
+
assert.equal(isClaimableTask({ status: "blocked", attempts: 1, nextAttemptAt: past }, NOW), true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("terminal tasks are never claimable, so cancelling really stops the work", () => {
|
|
37
|
+
for (const status of ["completed", "error", "running"]) {
|
|
38
|
+
assert.equal(isClaimableTask({ status }, NOW), false, `${status} must not be claimable`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("backoff grows with attempts and is capped so a task never waits indefinitely", () => {
|
|
43
|
+
const first = Date.parse(backoffUntil(1, NOW)) - NOW;
|
|
44
|
+
const second = Date.parse(backoffUntil(2, NOW)) - NOW;
|
|
45
|
+
const huge = Date.parse(backoffUntil(50, NOW)) - NOW;
|
|
46
|
+
assert.ok(second > first, "later attempts should wait longer");
|
|
47
|
+
assert.equal(huge, 30 * 60_000, "backoff is capped at 30 minutes");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a running task whose lease lapsed is reclaimable, so one crashed worker cannot wedge the queue", () => {
|
|
51
|
+
const expired = new Date(NOW - 1_000).toISOString();
|
|
52
|
+
assert.equal(isClaimableTask({ status: "running", leaseExpiresAt: expired }, NOW), true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a running task with a live lease is left alone, so work is never double-run", () => {
|
|
56
|
+
const live = new Date(NOW + 30_000).toISOString();
|
|
57
|
+
assert.equal(isClaimableTask({ status: "running", leaseExpiresAt: live }, NOW), false);
|
|
58
|
+
// No lease recorded at all is treated as in-flight rather than free.
|
|
59
|
+
assert.equal(isClaimableTask({ status: "running" }, NOW), false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("an exhausted running task is not resurrected by a lapsed lease", () => {
|
|
63
|
+
const expired = new Date(NOW - 1_000).toISOString();
|
|
64
|
+
assert.equal(
|
|
65
|
+
isClaimableTask({ status: "running", leaseExpiresAt: expired, attempts: MAX_TASK_ATTEMPTS }, NOW),
|
|
66
|
+
false,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an exhausted task is resumable — parking it is a pause, not a one-way door", () => {
|
|
71
|
+
assert.equal(isResumableTask({ status: "blocked", attempts: MAX_TASK_ATTEMPTS }, NOW), true);
|
|
72
|
+
assert.equal(isResumableTask({ status: "error", attempts: MAX_TASK_ATTEMPTS }, NOW), true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("resuming does not stomp a worker that is still holding the task", () => {
|
|
76
|
+
const live = new Date(NOW + 30_000).toISOString();
|
|
77
|
+
assert.equal(isResumableTask({ status: "running", leaseExpiresAt: live }, NOW), false);
|
|
78
|
+
// A lease that already lapsed means the worker is gone, so the human wins.
|
|
79
|
+
const expired = new Date(NOW - 1_000).toISOString();
|
|
80
|
+
assert.equal(isResumableTask({ status: "running", leaseExpiresAt: expired }, NOW), true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("finished work is never resumed, so resume cannot undo a completed task", () => {
|
|
84
|
+
assert.equal(isResumableTask({ status: "completed" }, NOW), false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("resuming clears exactly what parked the task", () => {
|
|
88
|
+
const fields = resumedTaskFields();
|
|
89
|
+
assert.equal(fields.status, "queued");
|
|
90
|
+
assert.equal(fields.attempts, 0);
|
|
91
|
+
assert.equal(fields.nextAttemptAt, null);
|
|
92
|
+
assert.equal(fields.leaseExpiresAt, null);
|
|
93
|
+
assert.equal(fields.workerId, null);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a resumed task is immediately claimable again — the whole point of resuming", () => {
|
|
97
|
+
const parked = { status: "blocked", attempts: MAX_TASK_ATTEMPTS, nextAttemptAt: backoffUntil(3, NOW) };
|
|
98
|
+
assert.equal(isClaimableTask(parked, NOW), false);
|
|
99
|
+
assert.equal(isClaimableTask({ ...parked, ...resumedTaskFields() }, NOW), true);
|
|
100
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task retry scheduling, shared by every component that decides whether an
|
|
3
|
+
* agent task should run now.
|
|
4
|
+
*
|
|
5
|
+
* This lives in its own dependency-free module on purpose: the rules are
|
|
6
|
+
* needed by the Cloudflare Worker registry (which cannot import the Node
|
|
7
|
+
* `ws` dependency that control-plane-client pulls in), the local registry
|
|
8
|
+
* stub, the serve worker, and the MCP server. Previously each selection
|
|
9
|
+
* site independently accepted any queued/blocked task, so a permanently
|
|
10
|
+
* failing task was re-served on every 5s worker poll, forever.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A task that has burned this many attempts is parked instead of re-served. */
|
|
14
|
+
export const MAX_TASK_ATTEMPTS = 3;
|
|
15
|
+
|
|
16
|
+
/** Backoff before a blocked task becomes claimable again, capped at 30m. */
|
|
17
|
+
export function backoffUntil(attempts: number, now = Date.now()): string {
|
|
18
|
+
const delayMs = Math.min(30 * 60_000, 30_000 * 2 ** Math.max(0, attempts - 1));
|
|
19
|
+
return new Date(now + delayMs).toISOString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SchedulableTask = {
|
|
23
|
+
status: string;
|
|
24
|
+
attempts?: number;
|
|
25
|
+
nextAttemptAt?: string | null;
|
|
26
|
+
leaseExpiresAt?: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a human may release a parked task back to the queue.
|
|
31
|
+
*
|
|
32
|
+
* `isClaimableTask` parks a task for good once it burns MAX_TASK_ATTEMPTS.
|
|
33
|
+
* That is right for automatic retries — a task failing the same way every
|
|
34
|
+
* time should not be re-served on every poll. But nothing could ever
|
|
35
|
+
* un-park it: the update path writes status, lease and backoff, and never
|
|
36
|
+
* touches attempts. So a task that stopped to *ask for human input* stayed
|
|
37
|
+
* dead after the human answered, which made "needs your input" a one-way
|
|
38
|
+
* door rather than a pause.
|
|
39
|
+
*
|
|
40
|
+
* Resuming is an explicit human action, so the attempt cap is not the
|
|
41
|
+
* question here — the only thing it must not do is stomp a worker that is
|
|
42
|
+
* genuinely still running the task.
|
|
43
|
+
*/
|
|
44
|
+
export function isResumableTask(task: SchedulableTask, now = Date.now()): boolean {
|
|
45
|
+
if (task.status === "completed") return false;
|
|
46
|
+
const heldByLiveWorker = task.status === "running"
|
|
47
|
+
&& Boolean(task.leaseExpiresAt)
|
|
48
|
+
&& Date.parse(task.leaseExpiresAt!) > now;
|
|
49
|
+
return !heldByLiveWorker;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The fields that release a task: a clean slate on the retry budget, no
|
|
54
|
+
* pending backoff, and no lease left over from the worker that gave up.
|
|
55
|
+
*/
|
|
56
|
+
export function resumedTaskFields(): {
|
|
57
|
+
status: "queued";
|
|
58
|
+
attempts: number;
|
|
59
|
+
nextAttemptAt: null;
|
|
60
|
+
leaseExpiresAt: null;
|
|
61
|
+
workerId: null;
|
|
62
|
+
} {
|
|
63
|
+
return { status: "queued", attempts: 0, nextAttemptAt: null, leaseExpiresAt: null, workerId: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether a task should be picked up right now. */
|
|
67
|
+
export function isClaimableTask(task: SchedulableTask, now = Date.now()): boolean {
|
|
68
|
+
// A worker that dies mid-task leaves it "running" forever. The registry
|
|
69
|
+
// already allows reclaiming such a task once its lease lapses, but the
|
|
70
|
+
// client-side selection never offered it, so one crash could wedge the
|
|
71
|
+
// queue — and with it the pipeline, which will not queue a next stage
|
|
72
|
+
// while anything looks in flight.
|
|
73
|
+
const staleLease = task.status === "running"
|
|
74
|
+
&& Boolean(task.leaseExpiresAt)
|
|
75
|
+
&& Date.parse(task.leaseExpiresAt!) <= now;
|
|
76
|
+
if (task.status !== "queued" && task.status !== "blocked" && !staleLease) return false;
|
|
77
|
+
if ((task.attempts ?? 0) >= MAX_TASK_ATTEMPTS) return false;
|
|
78
|
+
if (task.nextAttemptAt && Date.parse(task.nextAttemptAt) > now) return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Bugmole's own model account, used when a project hasn't connected its own
|
|
2
|
+
// key. Anthropic's Messages API or any OpenAI-compatible gateway.
|
|
3
|
+
|
|
4
|
+
export type PlatformAiEnv = {
|
|
5
|
+
PLATFORM_AI_PROVIDER?: string;
|
|
6
|
+
PLATFORM_AI_KEY?: string;
|
|
7
|
+
PLATFORM_AI_BASE_URL?: string;
|
|
8
|
+
PLATFORM_AI_MODEL?: string;
|
|
9
|
+
/** Test seam. */
|
|
10
|
+
PLATFORM_AI_FETCH?: typeof fetch;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type PlatformReply = { text: string; model: string; inputTokens: number | null; outputTokens: number | null };
|
|
14
|
+
|
|
15
|
+
export function platformAiConfigured(env: PlatformAiEnv): boolean {
|
|
16
|
+
return Boolean(env.PLATFORM_AI_KEY && (env.PLATFORM_AI_PROVIDER ?? "anthropic"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function trimmed(url: string) {
|
|
20
|
+
return url.replace(/\/+$/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function callPlatformModel(
|
|
24
|
+
env: PlatformAiEnv,
|
|
25
|
+
prompt: string,
|
|
26
|
+
{ maxTokens, timeoutMs }: { maxTokens: number; timeoutMs: number },
|
|
27
|
+
): Promise<PlatformReply> {
|
|
28
|
+
const provider = (env.PLATFORM_AI_PROVIDER ?? "anthropic").toLowerCase();
|
|
29
|
+
const model = env.PLATFORM_AI_MODEL || "claude-sonnet-5";
|
|
30
|
+
const doFetch = env.PLATFORM_AI_FETCH ?? fetch;
|
|
31
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
32
|
+
if (provider === "anthropic") {
|
|
33
|
+
const base = env.PLATFORM_AI_BASE_URL ? trimmed(env.PLATFORM_AI_BASE_URL) : "https://api.anthropic.com";
|
|
34
|
+
const url = base.endsWith("/messages") ? base : base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
|
|
35
|
+
const response = await doFetch(url, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
signal,
|
|
38
|
+
headers: { "content-type": "application/json", "x-api-key": env.PLATFORM_AI_KEY!, "anthropic-version": "2023-06-01" },
|
|
39
|
+
body: JSON.stringify({ model, max_tokens: maxTokens, messages: [{ role: "user", content: prompt }] }),
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) throw new Error(`Model provider returned ${response.status}`);
|
|
42
|
+
const data = await response.json() as {
|
|
43
|
+
model?: string;
|
|
44
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
45
|
+
usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number };
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
text: (data.content ?? []).map((part) => part.text ?? "").join(""),
|
|
49
|
+
model: data.model ?? model,
|
|
50
|
+
inputTokens: data.usage ? (data.usage.input_tokens ?? 0) + (data.usage.cache_read_input_tokens ?? 0) : null,
|
|
51
|
+
outputTokens: data.usage?.output_tokens ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (provider === "openai") {
|
|
55
|
+
const base = env.PLATFORM_AI_BASE_URL ? trimmed(env.PLATFORM_AI_BASE_URL) : "https://api.openai.com/v1";
|
|
56
|
+
const url = base.endsWith("/chat/completions") ? base : `${base}/chat/completions`;
|
|
57
|
+
const response = await doFetch(url, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
signal,
|
|
60
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${env.PLATFORM_AI_KEY}` },
|
|
61
|
+
body: JSON.stringify({ model, max_tokens: maxTokens, messages: [{ role: "user", content: prompt }] }),
|
|
62
|
+
});
|
|
63
|
+
if (!response.ok) throw new Error(`Model provider returned ${response.status}`);
|
|
64
|
+
const data = await response.json() as {
|
|
65
|
+
model?: string;
|
|
66
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
67
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
text: data.choices?.[0]?.message?.content ?? "",
|
|
71
|
+
model: data.model ?? model,
|
|
72
|
+
inputTokens: data.usage?.prompt_tokens ?? null,
|
|
73
|
+
outputTokens: data.usage?.completion_tokens ?? null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Unsupported platform model provider: ${provider}`);
|
|
77
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, seedProject } from "../testing/harness.js";
|
|
4
|
+
import type { Env } from "../core.js";
|
|
5
|
+
|
|
6
|
+
function fakeModel(reply = "{\"title\":\"x\"}") {
|
|
7
|
+
const calls: Array<{ url: string; body: any }> = [];
|
|
8
|
+
const doFetch = (async (url: string, init: RequestInit) => {
|
|
9
|
+
calls.push({ url, body: JSON.parse(String(init.body)) });
|
|
10
|
+
return Response.json({ model: "claude-sonnet-5", content: [{ type: "text", text: reply }], usage: { input_tokens: 400, output_tokens: 60 } });
|
|
11
|
+
}) as unknown as typeof fetch;
|
|
12
|
+
return { calls, doFetch };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const aiEnv = (doFetch: typeof fetch) => ({ PLATFORM_AI_KEY: "k", PLATFORM_AI_FETCH: doFetch }) as Partial<Env>;
|
|
16
|
+
|
|
17
|
+
test("AI work is charged once per run, target and purpose", async () => {
|
|
18
|
+
const model = fakeModel();
|
|
19
|
+
const { env, db } = createTestEnv(aiEnv(model.doFetch));
|
|
20
|
+
const { owner, projectId, workspaceId } = await seedProject(env);
|
|
21
|
+
const ask = () => call(env, "POST", "/api/ai/complete", {
|
|
22
|
+
as: owner,
|
|
23
|
+
body: { purpose: "analysis", projectId, runId: "run_1", targetId: "chromium", prompt: "why?" },
|
|
24
|
+
});
|
|
25
|
+
const first = await ask();
|
|
26
|
+
assert.equal(first.status, 200);
|
|
27
|
+
assert.equal(first.json.credits, 1);
|
|
28
|
+
assert.equal(first.json.text, "{\"title\":\"x\"}");
|
|
29
|
+
const retry = await ask();
|
|
30
|
+
assert.equal(retry.json.credits, 0, "a retry isn't charged again");
|
|
31
|
+
assert.equal(model.calls.length, 2);
|
|
32
|
+
assert.equal(model.calls[0].url, "https://api.anthropic.com/v1/messages");
|
|
33
|
+
assert.equal(model.calls[0].body.max_tokens, 600);
|
|
34
|
+
const row = db.db.prepare("SELECT quantity, meta_json FROM usage_events WHERE workspace_id = ?").get(workspaceId) as { quantity: number; meta_json: string };
|
|
35
|
+
assert.equal(row.quantity, 1);
|
|
36
|
+
assert.deepEqual(JSON.parse(row.meta_json), { purpose: "analysis", targetId: "chromium", model: "claude-sonnet-5", inputTokens: 400, outputTokens: 60 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("Free can't buy past its credits, and AI repair needs a paid plan", async () => {
|
|
40
|
+
const model = fakeModel();
|
|
41
|
+
const { env } = createTestEnv(aiEnv(model.doFetch));
|
|
42
|
+
const { owner, projectId } = await seedProject(env);
|
|
43
|
+
const repair = await call(env, "POST", "/api/ai/complete", {
|
|
44
|
+
as: owner, body: { purpose: "repair", projectId, runId: "r", prompt: "fix" },
|
|
45
|
+
});
|
|
46
|
+
assert.equal(repair.status, 402);
|
|
47
|
+
assert.equal(repair.json.feature, "aiFixes");
|
|
48
|
+
|
|
49
|
+
const explore = (id: string) => call(env, "POST", "/api/ai/complete", {
|
|
50
|
+
as: owner, body: { purpose: "exploration", projectId, requestId: id, prompt: "map" },
|
|
51
|
+
});
|
|
52
|
+
assert.equal((await explore("t1")).json.credits, 100);
|
|
53
|
+
const second = await explore("t2");
|
|
54
|
+
assert.equal(second.status, 402, "the 100 Free credits are used up");
|
|
55
|
+
assert.equal(second.json.limit, "ai_credits");
|
|
56
|
+
assert.equal(model.calls.length, 1, "no model call when the work can't be paid for");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a failed model call costs nothing, and an unconfigured platform says so", async () => {
|
|
60
|
+
const failing = (async () => new Response("overloaded", { status: 529 })) as unknown as typeof fetch;
|
|
61
|
+
const { env, db } = createTestEnv(aiEnv(failing));
|
|
62
|
+
const { owner, projectId } = await seedProject(env);
|
|
63
|
+
const failed = await call(env, "POST", "/api/ai/complete", { as: owner, body: { purpose: "analysis", projectId, runId: "r", prompt: "x" } });
|
|
64
|
+
assert.equal(failed.status, 502);
|
|
65
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM usage_events").get()?.c, 0);
|
|
66
|
+
|
|
67
|
+
const bare = createTestEnv();
|
|
68
|
+
const seeded = await seedProject(bare.env);
|
|
69
|
+
const unavailable = await call(bare.env, "POST", "/api/ai/complete", {
|
|
70
|
+
as: seeded.owner, body: { purpose: "analysis", projectId: seeded.projectId, runId: "r", prompt: "x" },
|
|
71
|
+
});
|
|
72
|
+
assert.equal(unavailable.status, 503);
|
|
73
|
+
assert.equal(unavailable.json.code, "ai_unavailable");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("only project members can spend the project's credits", async () => {
|
|
77
|
+
const model = fakeModel();
|
|
78
|
+
const { env } = createTestEnv(aiEnv(model.doFetch));
|
|
79
|
+
const { projectId } = await seedProject(env);
|
|
80
|
+
const denied = await call(env, "POST", "/api/ai/complete", {
|
|
81
|
+
as: "user_stranger", body: { purpose: "analysis", projectId, runId: "r", prompt: "x" },
|
|
82
|
+
});
|
|
83
|
+
assert.equal(denied.status, 403);
|
|
84
|
+
const noKey = await call(env, "POST", "/api/ai/complete", {
|
|
85
|
+
as: "user_stranger", body: { purpose: "analysis", projectId, prompt: "x" },
|
|
86
|
+
});
|
|
87
|
+
assert.equal(noKey.status, 422);
|
|
88
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// POST /api/ai/complete — AI work on Bugmole's model account, charged in
|
|
2
|
+
// credits per action (analysis 1, repair 20, exploration 100). Projects that
|
|
3
|
+
// connect their own key call their provider directly and never come here.
|
|
4
|
+
import { AI_CREDIT_COST, type AiAction } from "../../billing/plan-catalog.js";
|
|
5
|
+
import { type Env, type Principal, memberOfProject, requestJson, response } from "../core.js";
|
|
6
|
+
import { getEntitlements, workspaceOfProject } from "../billing/entitlements.js";
|
|
7
|
+
import { canSpend, recordUsage } from "../billing/ledger.js";
|
|
8
|
+
import { checkUsageThresholds } from "../billing/thresholds.js";
|
|
9
|
+
import { type AuthedRouteContext, registerRoutes } from "../routes/index.js";
|
|
10
|
+
import { callPlatformModel, platformAiConfigured, type PlatformAiEnv } from "./platform-model.js";
|
|
11
|
+
|
|
12
|
+
const MAX_PROMPT_CHARS = 120_000;
|
|
13
|
+
const MAX_OUTPUT_TOKENS: Record<AiAction, number> = { analysis: 600, repair: 2_000, exploration: 8_000, discovery: 0 };
|
|
14
|
+
const TIMEOUT_MS: Record<AiAction, number> = { analysis: 30_000, repair: 60_000, exploration: 120_000, discovery: 0 };
|
|
15
|
+
const PURPOSES = new Set<AiAction>(["analysis", "repair", "exploration"]);
|
|
16
|
+
|
|
17
|
+
/** `runId` or `requestId` names the work being charged (a discovery task passes its id as the request). */
|
|
18
|
+
export type AiPrincipalCheck = (env: Env, principal: Principal, projectId: string, work: { runId: string | null; requestId: string | null }) => Promise<boolean>;
|
|
19
|
+
|
|
20
|
+
/** Replaced by the cloud-runner module so run tokens can call this route too. */
|
|
21
|
+
export const aiAccess: { check: AiPrincipalCheck } = {
|
|
22
|
+
check: (env, principal, projectId) => memberOfProject(env, principal, projectId, "project.write"),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
async function complete({ env, principal, request, ctx }: AuthedRouteContext): Promise<Response> {
|
|
26
|
+
const body = await requestJson(request);
|
|
27
|
+
const purpose = body?.purpose as AiAction;
|
|
28
|
+
const projectId = typeof body?.projectId === "string" ? body.projectId : "";
|
|
29
|
+
const runId = typeof body?.runId === "string" && body.runId ? body.runId : null;
|
|
30
|
+
const requestId = typeof body?.requestId === "string" && body.requestId ? body.requestId : null;
|
|
31
|
+
const targetId = typeof body?.targetId === "string" && body.targetId ? body.targetId : "-";
|
|
32
|
+
const prompt = typeof body?.prompt === "string" ? body.prompt : "";
|
|
33
|
+
if (!PURPOSES.has(purpose)) return response({ error: "purpose must be analysis, repair, or exploration" }, 422);
|
|
34
|
+
if (!projectId || !prompt) return response({ error: "projectId and prompt are required" }, 422);
|
|
35
|
+
if (!runId && !requestId) return response({ error: "runId or requestId is required so the work is charged once" }, 422);
|
|
36
|
+
if (prompt.length > MAX_PROMPT_CHARS) return response({ error: "prompt is too long" }, 413);
|
|
37
|
+
if (!(await aiAccess.check(env, principal, projectId, { runId, requestId }))) return response({ error: "Project scope is not allowed" }, 403);
|
|
38
|
+
const aiEnv = env as Env & PlatformAiEnv;
|
|
39
|
+
if (!platformAiConfigured(aiEnv)) {
|
|
40
|
+
return response({ error: "Bugmole AI isn't configured. Connect your own model key in bugmole.config.yaml.", code: "ai_unavailable" }, 503);
|
|
41
|
+
}
|
|
42
|
+
const workspaceId = await workspaceOfProject(env, projectId);
|
|
43
|
+
if (!workspaceId) return response({ error: "This project isn't in a workspace" }, 422);
|
|
44
|
+
const entitlements = await getEntitlements(env, workspaceId);
|
|
45
|
+
if (purpose === "repair" && !entitlements.features.aiFixes) {
|
|
46
|
+
return response({ error: "AI test repair is available on Team and Business plans.", code: "plan_feature", feature: "aiFixes", upgradeUrl: "/billing" }, 402);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const credits = AI_CREDIT_COST[purpose];
|
|
50
|
+
const idempotencyKey = `ai:${projectId}:${runId ?? `req:${requestId}`}:${targetId}:${purpose}`;
|
|
51
|
+
const alreadyCharged = await env.DB.prepare("SELECT 1 AS charged FROM usage_events WHERE idempotency_key = ?")
|
|
52
|
+
.bind(idempotencyKey).first<{ charged: number }>();
|
|
53
|
+
if (!alreadyCharged) {
|
|
54
|
+
const decision = await canSpend(env, workspaceId, "ai_credit", credits);
|
|
55
|
+
if (!decision.allowed) {
|
|
56
|
+
return response({ error: decision.message, code: "plan_limit", limit: decision.reason === "spending_limit" ? "spending_limit" : "ai_credits", upgradeUrl: "/billing" }, 402);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let reply;
|
|
61
|
+
try {
|
|
62
|
+
reply = await callPlatformModel(aiEnv, prompt, { maxTokens: MAX_OUTPUT_TOKENS[purpose], timeoutMs: TIMEOUT_MS[purpose] });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// Nothing is charged when the model call fails.
|
|
65
|
+
console.error("[ai] platform model failed", error);
|
|
66
|
+
return response({ error: "The AI service didn't respond. Try again shortly.", code: "ai_failed" }, 502);
|
|
67
|
+
}
|
|
68
|
+
const charged = await recordUsage(env, {
|
|
69
|
+
workspaceId,
|
|
70
|
+
projectId,
|
|
71
|
+
runId,
|
|
72
|
+
kind: "ai_credit",
|
|
73
|
+
quantity: credits,
|
|
74
|
+
source: "ai",
|
|
75
|
+
idempotencyKey,
|
|
76
|
+
meta: { purpose, targetId, model: reply.model, inputTokens: reply.inputTokens, outputTokens: reply.outputTokens },
|
|
77
|
+
});
|
|
78
|
+
if (charged) ctx.waitUntil(checkUsageThresholds(env, workspaceId));
|
|
79
|
+
return response({
|
|
80
|
+
text: reply.text,
|
|
81
|
+
model: reply.model,
|
|
82
|
+
usage: { inputTokens: reply.inputTokens, outputTokens: reply.outputTokens },
|
|
83
|
+
credits: charged ? credits : 0,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
registerRoutes(async (context) => {
|
|
88
|
+
if (context.request.method === "POST" && context.url.pathname === "/api/ai/complete") return complete(context);
|
|
89
|
+
return null;
|
|
90
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
ARTIFACT_STORAGE_UNCONFIGURED,
|
|
5
|
+
type ArtifactBucket,
|
|
6
|
+
getProjectArtifact,
|
|
7
|
+
isProjectArtifactKey,
|
|
8
|
+
isRunManifestKey,
|
|
9
|
+
listProjectArtifacts,
|
|
10
|
+
normalizeArtifactPrefix,
|
|
11
|
+
} from "./artifacts.js";
|
|
12
|
+
|
|
13
|
+
function fakeBucket(objects: Record<string, { body: string; contentType?: string }>, pageSize = 1000): ArtifactBucket {
|
|
14
|
+
const keys = Object.keys(objects).sort();
|
|
15
|
+
return {
|
|
16
|
+
async list({ prefix, cursor, limit }) {
|
|
17
|
+
const matching = keys.filter((key) => key.startsWith(prefix));
|
|
18
|
+
const start = cursor ? Number(cursor) : 0;
|
|
19
|
+
const size = Math.min(limit ?? pageSize, pageSize);
|
|
20
|
+
const page = matching.slice(start, start + size);
|
|
21
|
+
const truncated = start + size < matching.length;
|
|
22
|
+
return {
|
|
23
|
+
objects: page.map((key) => ({
|
|
24
|
+
key,
|
|
25
|
+
size: objects[key].body.length,
|
|
26
|
+
uploaded: new Date("2026-09-15T00:00:00Z"),
|
|
27
|
+
httpMetadata: { contentType: objects[key].contentType },
|
|
28
|
+
})),
|
|
29
|
+
truncated,
|
|
30
|
+
cursor: truncated ? String(start + size) : undefined,
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
async get(key) {
|
|
34
|
+
const object = objects[key];
|
|
35
|
+
if (!object) return null;
|
|
36
|
+
return { body: new Response(object.body).body!, size: object.body.length, httpMetadata: { contentType: object.contentType } };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const url = (path: string) => new URL(`https://registry.test${path}`);
|
|
42
|
+
|
|
43
|
+
type ListBody = { objects: Array<{ key: string; size: number; modifiedAt: string; contentType?: string }>; cursor?: string };
|
|
44
|
+
|
|
45
|
+
test("reads only keys under the project's own prefix", () => {
|
|
46
|
+
assert.equal(isProjectArtifactKey("e2e", "projects/e2e/runs/run_1/manifest.json"), true);
|
|
47
|
+
assert.equal(isProjectArtifactKey("e2e", "projects/other/runs/run_1/manifest.json"), false);
|
|
48
|
+
assert.equal(isProjectArtifactKey("e2e", "projects/e2e/../other/secret.json"), false);
|
|
49
|
+
assert.equal(isProjectArtifactKey("my project", "projects/my%20project/spec/flow.yaml"), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("lists the whole project by default and refuses a foreign prefix", () => {
|
|
53
|
+
assert.equal(normalizeArtifactPrefix("e2e", null), "projects/e2e/");
|
|
54
|
+
assert.equal(normalizeArtifactPrefix("e2e", "projects/e2e/runs/"), "projects/e2e/runs/");
|
|
55
|
+
assert.equal(normalizeArtifactPrefix("e2e", "projects/other/"), null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("accepts only the run's own manifest key", () => {
|
|
59
|
+
assert.equal(isRunManifestKey("e2e", "run_1", "projects/e2e/runs/run_1/manifest.json"), true);
|
|
60
|
+
assert.equal(isRunManifestKey("e2e", "run_1", "projects/e2e/runs/run_2/manifest.json"), false);
|
|
61
|
+
assert.equal(isRunManifestKey("e2e", "run_1", "e2e/runs/run_1"), false);
|
|
62
|
+
assert.equal(isRunManifestKey("e2e", "run_1", undefined), false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reports unconfigured storage as 503", async () => {
|
|
66
|
+
const listed = await listProjectArtifacts(undefined, "e2e", url("/api/projects/e2e/artifacts"));
|
|
67
|
+
assert.equal(listed.status, 503);
|
|
68
|
+
assert.deepEqual(await listed.json(), { error: ARTIFACT_STORAGE_UNCONFIGURED });
|
|
69
|
+
assert.equal((await getProjectArtifact(undefined, "e2e", url("/api/projects/e2e/artifacts/object?key=projects/e2e/x"))).status, 503);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("lists a project's artifacts page by page with a cursor", async () => {
|
|
73
|
+
const bucket = fakeBucket({
|
|
74
|
+
"projects/e2e/runs/run_1/manifest.json": { body: "{}", contentType: "application/json" },
|
|
75
|
+
"projects/e2e/runs/run_1/results.json": { body: "{}", contentType: "application/json" },
|
|
76
|
+
"projects/e2e/runs/run_1/artifacts/home.png": { body: "png", contentType: "image/png" },
|
|
77
|
+
"projects/other/runs/run_9/manifest.json": { body: "{}" },
|
|
78
|
+
}, 2);
|
|
79
|
+
const first = (await (await listProjectArtifacts(bucket, "e2e", url("/api/projects/e2e/artifacts?prefix=projects/e2e/runs/"))).json()) as ListBody;
|
|
80
|
+
assert.equal(first.objects.length, 2);
|
|
81
|
+
assert.equal(first.cursor, "2");
|
|
82
|
+
const second = (await (await listProjectArtifacts(bucket, "e2e", url(`/api/projects/e2e/artifacts?prefix=projects/e2e/runs/&cursor=${first.cursor}`))).json()) as ListBody;
|
|
83
|
+
assert.equal(second.objects.length, 1);
|
|
84
|
+
assert.equal(second.cursor, undefined);
|
|
85
|
+
const keys = [...first.objects, ...second.objects].map((object) => object.key);
|
|
86
|
+
assert.ok(keys.every((key) => key.startsWith("projects/e2e/")));
|
|
87
|
+
assert.equal((await listProjectArtifacts(bucket, "e2e", url("/api/projects/e2e/artifacts?prefix=projects/other/"))).status, 400);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("streams an artifact with its content type, and 404s a missing one", async () => {
|
|
91
|
+
const bucket = fakeBucket({ "projects/e2e/runs/run_1/artifacts/home.png": { body: "png-bytes", contentType: "image/png" } });
|
|
92
|
+
const found = await getProjectArtifact(bucket, "e2e", url("/api/projects/e2e/artifacts/object?key=projects/e2e/runs/run_1/artifacts/home.png"));
|
|
93
|
+
assert.equal(found.status, 200);
|
|
94
|
+
assert.equal(found.headers.get("content-type"), "image/png");
|
|
95
|
+
assert.equal(await found.text(), "png-bytes");
|
|
96
|
+
assert.equal((await getProjectArtifact(bucket, "e2e", url("/api/projects/e2e/artifacts/object?key=projects/e2e/missing.png"))).status, 404);
|
|
97
|
+
assert.equal((await getProjectArtifact(bucket, "e2e", url("/api/projects/e2e/artifacts/object?key=projects/other/secret.png"))).status, 400);
|
|
98
|
+
});
|