@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,2077 @@
|
|
|
1
|
+
import { MAX_TASK_ATTEMPTS, backoffUntil, isResumableTask, resumedTaskFields } from "../registry/task-scheduling.js";
|
|
2
|
+
import { getProjectArtifact, isRunManifestKey, listProjectArtifacts } from "./artifacts.js";
|
|
3
|
+
import {
|
|
4
|
+
type Env,
|
|
5
|
+
type Principal,
|
|
6
|
+
jsonHeaders,
|
|
7
|
+
response,
|
|
8
|
+
bearer,
|
|
9
|
+
sha256,
|
|
10
|
+
subscriptionPrincipal,
|
|
11
|
+
apiPrincipal,
|
|
12
|
+
constantTimeEqual,
|
|
13
|
+
can,
|
|
14
|
+
projectAllowed,
|
|
15
|
+
memberOfProject,
|
|
16
|
+
memberOfWorkspace,
|
|
17
|
+
randomToken,
|
|
18
|
+
base64,
|
|
19
|
+
fromBase64,
|
|
20
|
+
encryptForSecret,
|
|
21
|
+
decryptForSecret,
|
|
22
|
+
} from "./core.js";
|
|
23
|
+
|
|
24
|
+
export type { Env } from "./core.js";
|
|
25
|
+
import { detachedContext, handleExtendedRoutes, handlePublicRoutes } from "./routes/index.js";
|
|
26
|
+
import { runScheduledJobs } from "./jobs/index.js";
|
|
27
|
+
import { actorOf, auditProject, recordAudit } from "./org/audit.js";
|
|
28
|
+
import { checkCloudRun, checkProjectLimit, executionModeOf, historyCutoffFor } from "./billing/enforcement.js";
|
|
29
|
+
import { workspaceOfProject } from "./billing/entitlements.js";
|
|
30
|
+
import { heartbeatChecks, registryDispatch, runChangedHooks, runQueuedHooks, runService, taskChangedHooks, taskQueuedHooks, type InternalRunFields } from "./hooks.js";
|
|
31
|
+
import { appendRunEvent, broadcastProjectEvent } from "./run-events.js";
|
|
32
|
+
import { runnerMayCall } from "./runner/tokens.js";
|
|
33
|
+
import { cloudStorageError } from "./runner/policy.js";
|
|
34
|
+
import { deviceTargetsError } from "./devices/policy.js";
|
|
35
|
+
import { requireVerifiedDomainForCloud } from "./domains/routes.js";
|
|
36
|
+
import { cloudTargetId, parseCloudDevice } from "../runtime/device-clouds/targets.js";
|
|
37
|
+
import type { CloudDevice } from "../runtime/device-clouds/types.js";
|
|
38
|
+
import { cloudAccountError, freeWorkspaceLimit } from "./signup/policy.js";
|
|
39
|
+
import { applyOrgMembership, isDeprovisioned } from "./sso/membership.js";
|
|
40
|
+
import "./features.js";
|
|
41
|
+
|
|
42
|
+
function deviceCode(): string {
|
|
43
|
+
const bytes = crypto.getRandomValues(new Uint8Array(2));
|
|
44
|
+
return String(((bytes[0] << 8) | bytes[1]) % 10_000).padStart(4, "0");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function createDeviceAuthorization(request: Request, env: Env): Promise<Response> {
|
|
48
|
+
const body = await request.json().catch(() => null) as {
|
|
49
|
+
projectId?: string;
|
|
50
|
+
projectName?: string;
|
|
51
|
+
environment?: string;
|
|
52
|
+
storageProvider?: string;
|
|
53
|
+
} | null;
|
|
54
|
+
if (!body?.projectId || !body.projectName || !body.environment || !body.storageProvider) {
|
|
55
|
+
return response({ error: "projectId, projectName, environment, and storageProvider are required" }, 422);
|
|
56
|
+
}
|
|
57
|
+
const sourceIp = request.headers.get("CF-Connecting-IP") ?? "unknown";
|
|
58
|
+
const recent = await env.DB.prepare(`
|
|
59
|
+
SELECT COUNT(*) AS count FROM device_authorizations
|
|
60
|
+
WHERE source_ip = ? AND created_at > datetime('now', '-10 minutes')
|
|
61
|
+
`).bind(sourceIp).first<{ count: number }>();
|
|
62
|
+
if ((recent?.count ?? 0) >= 10) return response({ error: "Too many device registration attempts" }, 429);
|
|
63
|
+
const attemptId = `dev_${randomToken(18)}`;
|
|
64
|
+
const code = deviceCode();
|
|
65
|
+
const pollSecret = randomToken(32);
|
|
66
|
+
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
|
67
|
+
await env.DB.prepare(`
|
|
68
|
+
INSERT INTO device_authorizations
|
|
69
|
+
(attempt_id, code_hash, poll_secret_hash, project_id, project_name, environment, storage_provider, source_ip, expires_at)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
71
|
+
`).bind(
|
|
72
|
+
attemptId,
|
|
73
|
+
await sha256(code),
|
|
74
|
+
await sha256(pollSecret),
|
|
75
|
+
body.projectId,
|
|
76
|
+
body.projectName,
|
|
77
|
+
body.environment,
|
|
78
|
+
currentStorageProvider(body.storageProvider),
|
|
79
|
+
sourceIp,
|
|
80
|
+
expiresAt,
|
|
81
|
+
).run();
|
|
82
|
+
return response({ attemptId, code, pollSecret, expiresAt }, 201);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A friendly default from the email's domain: ada@northwind.io -> "Northwind".
|
|
87
|
+
* Empty for the big free providers, where the domain says nothing about the
|
|
88
|
+
* person. Mirrors apps/qa/src/lib/signup-intent.ts's workspaceNameFromEmail —
|
|
89
|
+
* duplicated rather than imported, since the dashboard and the registry
|
|
90
|
+
* worker build separately and don't share a module graph.
|
|
91
|
+
*/
|
|
92
|
+
function workspaceNameFromEmail(email: string | null | undefined): string {
|
|
93
|
+
const domain = email?.split("@")[1]?.split(".")[0] ?? "";
|
|
94
|
+
if (!domain || ["gmail", "outlook", "hotmail", "yahoo", "icloud", "proton", "protonmail"].includes(domain)) return "";
|
|
95
|
+
return domain.charAt(0).toUpperCase() + domain.slice(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function claimDeviceAuthorization(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
99
|
+
const body = await request.json().catch(() => null) as {
|
|
100
|
+
attemptId?: string;
|
|
101
|
+
code?: string;
|
|
102
|
+
deviceId?: string;
|
|
103
|
+
email?: string;
|
|
104
|
+
} | null;
|
|
105
|
+
if (!body?.attemptId || !body.code || !/^\d{4}$/.test(body.code)) {
|
|
106
|
+
return response({ error: "attemptId and a four-digit code are required" }, 422);
|
|
107
|
+
}
|
|
108
|
+
const attempt = await env.DB.prepare(`
|
|
109
|
+
SELECT attempt_id AS attemptId, code_hash AS codeHash, project_id AS projectId,
|
|
110
|
+
project_name AS projectName, environment, storage_provider AS storageProvider,
|
|
111
|
+
status, failed_attempts AS failedAttempts, expires_at AS expiresAt
|
|
112
|
+
FROM device_authorizations WHERE attempt_id = ?
|
|
113
|
+
`).bind(body.attemptId).first<{
|
|
114
|
+
attemptId: string;
|
|
115
|
+
codeHash: string;
|
|
116
|
+
projectId: string;
|
|
117
|
+
projectName: string;
|
|
118
|
+
environment: string;
|
|
119
|
+
storageProvider: string;
|
|
120
|
+
status: string;
|
|
121
|
+
failedAttempts: number;
|
|
122
|
+
expiresAt: string;
|
|
123
|
+
}>();
|
|
124
|
+
if (!attempt || attempt.status !== "pending" || Date.parse(attempt.expiresAt) <= Date.now()) {
|
|
125
|
+
return response({ error: "Device authorization expired or already completed" }, 410);
|
|
126
|
+
}
|
|
127
|
+
if (attempt.failedAttempts >= 5) return response({ error: "Too many code attempts" }, 429);
|
|
128
|
+
if (!constantTimeEqual(await sha256(body.code), attempt.codeHash)) {
|
|
129
|
+
await env.DB.prepare("UPDATE device_authorizations SET failed_attempts = failed_attempts + 1 WHERE attempt_id = ?")
|
|
130
|
+
.bind(body.attemptId).run();
|
|
131
|
+
return response({ error: "Incorrect authorization code" }, 401);
|
|
132
|
+
}
|
|
133
|
+
if (body.deviceId !== attempt.projectId) {
|
|
134
|
+
return response({ error: "Device binding does not match the Bugmole project" }, 403);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const claimResult = await env.DB.prepare(`
|
|
138
|
+
UPDATE device_authorizations SET status = 'claimed', aegis_id = ?, aegis_email = ?, claimed_at = CURRENT_TIMESTAMP
|
|
139
|
+
WHERE attempt_id = ? AND status = 'pending'
|
|
140
|
+
`).bind(principal.aegisId, body.email?.trim() || null, body.attemptId).run() as { meta?: { changes?: number } };
|
|
141
|
+
if (claimResult.meta?.changes !== 1) {
|
|
142
|
+
return response({ error: "Device authorization was claimed by another session" }, 409);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const existingKeys = await env.DB.prepare(`
|
|
146
|
+
SELECT id, project_ids_json AS projectIds
|
|
147
|
+
FROM api_keys WHERE aegis_id = ? AND revoked_at IS NULL
|
|
148
|
+
`).bind(principal.aegisId).all<{ id: string; projectIds: string }>();
|
|
149
|
+
const existingKey = existingKeys.results.find((key) => {
|
|
150
|
+
try {
|
|
151
|
+
const projectIds = JSON.parse(key.projectIds) as unknown;
|
|
152
|
+
return Array.isArray(projectIds) && projectIds.includes(attempt.projectId);
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
const now = new Date().toISOString();
|
|
158
|
+
const workspaceId = `workspace_${principal.aegisId}`;
|
|
159
|
+
const workspaceName = workspaceNameFromEmail(body.email) || (attempt.projectName ? `${attempt.projectName} workspace` : "My workspace");
|
|
160
|
+
const environmentId = attempt.environment.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "default";
|
|
161
|
+
if (existingKey) {
|
|
162
|
+
try {
|
|
163
|
+
await env.DB.batch([
|
|
164
|
+
env.DB.prepare("INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING").bind(principal.aegisId),
|
|
165
|
+
env.DB.prepare(`
|
|
166
|
+
INSERT INTO workspaces (id, name, created_by)
|
|
167
|
+
VALUES (?, ?, ?)
|
|
168
|
+
ON CONFLICT(id) DO NOTHING
|
|
169
|
+
`).bind(workspaceId, workspaceName, principal.aegisId),
|
|
170
|
+
env.DB.prepare(`
|
|
171
|
+
INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status)
|
|
172
|
+
VALUES (?, ?, 'owner', 'active')
|
|
173
|
+
ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET status = 'active'
|
|
174
|
+
`).bind(workspaceId, principal.aegisId),
|
|
175
|
+
env.DB.prepare(`
|
|
176
|
+
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
|
|
177
|
+
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
178
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
|
|
179
|
+
storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
|
|
180
|
+
`).bind(attempt.projectId, workspaceId, workspaceId, attempt.projectName, attempt.environment, currentStorageProvider(attempt.storageProvider)),
|
|
181
|
+
env.DB.prepare(`
|
|
182
|
+
INSERT INTO project_apps (id, project_id, name, type, is_default)
|
|
183
|
+
VALUES ('default', ?, ?, 'web', 1)
|
|
184
|
+
ON CONFLICT(project_id, id) DO UPDATE SET name = excluded.name
|
|
185
|
+
`).bind(attempt.projectId, attempt.projectName),
|
|
186
|
+
env.DB.prepare(`
|
|
187
|
+
INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
|
|
188
|
+
VALUES (?, ?, 'default', ?, '', 'active', 1)
|
|
189
|
+
ON CONFLICT(project_id, id) DO UPDATE SET app_id = excluded.app_id, name = excluded.name, is_default = 1
|
|
190
|
+
`).bind(environmentId, attempt.projectId, attempt.environment),
|
|
191
|
+
env.DB.prepare(`
|
|
192
|
+
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
193
|
+
VALUES (?, ?, 'owner', 'active')
|
|
194
|
+
ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
|
|
195
|
+
`).bind(attempt.projectId, principal.aegisId),
|
|
196
|
+
env.DB.prepare(`
|
|
197
|
+
UPDATE device_authorizations SET status = 'completed', aegis_id = ?, api_key_id = ?, completed_at = ?
|
|
198
|
+
WHERE attempt_id = ? AND status = 'claimed'
|
|
199
|
+
`).bind(principal.aegisId, existingKey.id, now, body.attemptId),
|
|
200
|
+
]);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
await releaseClaimedDeviceAuthorization(env, body.attemptId);
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
return response({ claimed: true, alreadyInitialized: true }, 200);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const id = `key_${randomToken(12)}`;
|
|
209
|
+
const token = `bm_live_${id}_${randomToken()}`;
|
|
210
|
+
const encrypted = await encryptForSecret(token, await pollSecretForAttempt(env, body.attemptId));
|
|
211
|
+
try {
|
|
212
|
+
await env.DB.batch([
|
|
213
|
+
env.DB.prepare("INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING").bind(principal.aegisId),
|
|
214
|
+
env.DB.prepare(`
|
|
215
|
+
INSERT INTO workspaces (id, name, created_by)
|
|
216
|
+
VALUES (?, ? ,?)
|
|
217
|
+
ON CONFLICT(id) DO NOTHING
|
|
218
|
+
`).bind(workspaceId, workspaceName, principal.aegisId),
|
|
219
|
+
env.DB.prepare(`
|
|
220
|
+
INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status)
|
|
221
|
+
VALUES (?, ?, 'owner', 'active')
|
|
222
|
+
ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET status = 'active'
|
|
223
|
+
`).bind(workspaceId, principal.aegisId),
|
|
224
|
+
env.DB.prepare(`
|
|
225
|
+
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
|
|
226
|
+
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
227
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
|
|
228
|
+
storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
|
|
229
|
+
`).bind(attempt.projectId, workspaceId, workspaceId, attempt.projectName, attempt.environment, currentStorageProvider(attempt.storageProvider)),
|
|
230
|
+
env.DB.prepare(`
|
|
231
|
+
INSERT INTO project_apps (id, project_id, name, type, is_default)
|
|
232
|
+
VALUES ('default', ?, ?, 'web', 1)
|
|
233
|
+
ON CONFLICT(project_id, id) DO UPDATE SET name = excluded.name
|
|
234
|
+
`).bind(attempt.projectId, attempt.projectName),
|
|
235
|
+
env.DB.prepare(`
|
|
236
|
+
INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
|
|
237
|
+
VALUES (?, ?, 'default', ?, '', 'active', 1)
|
|
238
|
+
ON CONFLICT(project_id, id) DO UPDATE SET app_id = excluded.app_id, name = excluded.name, is_default = 1
|
|
239
|
+
`).bind(environmentId, attempt.projectId, attempt.environment),
|
|
240
|
+
env.DB.prepare(`
|
|
241
|
+
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
242
|
+
VALUES (?, ?, 'owner', 'active')
|
|
243
|
+
ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
|
|
244
|
+
`).bind(attempt.projectId, principal.aegisId),
|
|
245
|
+
env.DB.prepare(`
|
|
246
|
+
INSERT INTO api_keys (id, aegis_id, name, token_hash, scopes_json, project_ids_json, expires_at)
|
|
247
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
|
248
|
+
`).bind(id, principal.aegisId, `${attempt.projectName} CLI`, await sha256(token),
|
|
249
|
+
JSON.stringify(["projects:read", "projects:write", "runs:write", "artifacts:write"]), JSON.stringify([attempt.projectId])),
|
|
250
|
+
env.DB.prepare(`
|
|
251
|
+
UPDATE device_authorizations
|
|
252
|
+
SET status = 'completed', aegis_id = ?, api_key_id = ?, encrypted_token = ?, token_iv = ?, completed_at = ?
|
|
253
|
+
WHERE attempt_id = ? AND status = 'claimed'
|
|
254
|
+
`).bind(principal.aegisId, id, encrypted.ciphertext, encrypted.iv, now, body.attemptId),
|
|
255
|
+
]);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
await releaseClaimedDeviceAuthorization(env, body.attemptId);
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
return response({ claimed: true, alreadyInitialized: false }, 200);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function releaseClaimedDeviceAuthorization(env: Env, attemptId: string): Promise<void> {
|
|
264
|
+
await env.DB.prepare(`
|
|
265
|
+
UPDATE device_authorizations
|
|
266
|
+
SET status = 'pending', aegis_id = NULL, aegis_email = NULL, claimed_at = NULL
|
|
267
|
+
WHERE attempt_id = ? AND status = 'claimed' AND api_key_id IS NULL
|
|
268
|
+
`).bind(attemptId).run();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function pollSecretForAttempt(env: Env, attemptId: string): Promise<string> {
|
|
272
|
+
// The poll secret is never stored in plaintext. The token encryption key is
|
|
273
|
+
// derived from the poll secret supplied on the poll request and re-encrypted
|
|
274
|
+
// during claim through the short-lived claim handoff below.
|
|
275
|
+
const row = await env.DB.prepare("SELECT poll_secret_hash AS pollSecretHash FROM device_authorizations WHERE attempt_id = ?")
|
|
276
|
+
.bind(attemptId).first<{ pollSecretHash: string }>();
|
|
277
|
+
if (!row) throw new Error("Device authorization not found");
|
|
278
|
+
return row.pollSecretHash;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function pollDeviceAuthorization(request: Request, env: Env): Promise<Response> {
|
|
282
|
+
const body = await request.json().catch(() => null) as { attemptId?: string; pollSecret?: string } | null;
|
|
283
|
+
if (!body?.attemptId || !body.pollSecret) return response({ error: "attemptId and pollSecret are required" }, 422);
|
|
284
|
+
const attempt = await env.DB.prepare(`
|
|
285
|
+
SELECT status, expires_at AS expiresAt, encrypted_token AS encryptedToken, token_iv AS tokenIv,
|
|
286
|
+
poll_secret_hash AS pollSecretHash, aegis_email AS aegisEmail
|
|
287
|
+
FROM device_authorizations WHERE attempt_id = ?
|
|
288
|
+
`).bind(body.attemptId).first<{
|
|
289
|
+
status: string;
|
|
290
|
+
expiresAt: string;
|
|
291
|
+
encryptedToken: string | null;
|
|
292
|
+
tokenIv: string | null;
|
|
293
|
+
pollSecretHash: string;
|
|
294
|
+
aegisEmail: string | null;
|
|
295
|
+
}>();
|
|
296
|
+
if (!attempt || !constantTimeEqual(await sha256(body.pollSecret), attempt.pollSecretHash)) {
|
|
297
|
+
return response({ error: "Device authorization not found" }, 404);
|
|
298
|
+
}
|
|
299
|
+
if (Date.parse(attempt.expiresAt) <= Date.now() && attempt.status === "pending") {
|
|
300
|
+
await env.DB.prepare("UPDATE device_authorizations SET status = 'expired' WHERE attempt_id = ?")
|
|
301
|
+
.bind(body.attemptId).run();
|
|
302
|
+
return response({ status: "expired" }, 410);
|
|
303
|
+
}
|
|
304
|
+
if (attempt.status === "pending") return response({ status: "pending" }, 202);
|
|
305
|
+
if (!attempt.encryptedToken || !attempt.tokenIv) return response({ status: "completed", alreadyInitialized: true }, 200);
|
|
306
|
+
const result = await env.DB.prepare(`
|
|
307
|
+
UPDATE device_authorizations SET encrypted_token = NULL, token_iv = NULL
|
|
308
|
+
WHERE attempt_id = ? AND status = 'completed' AND encrypted_token = ?
|
|
309
|
+
`).bind(body.attemptId, attempt.encryptedToken).run() as { meta?: { changes?: number } };
|
|
310
|
+
if (result.meta?.changes !== 1) return response({ status: "completed", alreadyInitialized: true }, 200);
|
|
311
|
+
const token = await decryptForSecret(
|
|
312
|
+
attempt.encryptedToken,
|
|
313
|
+
attempt.tokenIv,
|
|
314
|
+
await sha256(body.pollSecret),
|
|
315
|
+
);
|
|
316
|
+
return response({ status: "completed", alreadyInitialized: false, token, email: attempt.aegisEmail }, 200);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function registerProject(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
320
|
+
if (!can(principal, "projects:write")) return response({ error: "Project registration is not allowed" }, 403);
|
|
321
|
+
const body = await request.json().catch(() => null) as {
|
|
322
|
+
id?: string;
|
|
323
|
+
name?: string;
|
|
324
|
+
appId?: string;
|
|
325
|
+
appName?: string;
|
|
326
|
+
workspaceId?: string;
|
|
327
|
+
workspaceName?: string;
|
|
328
|
+
tenantId?: string;
|
|
329
|
+
environment?: string;
|
|
330
|
+
storageProvider?: string;
|
|
331
|
+
} | null;
|
|
332
|
+
if (!body?.id || !body.name || !body.environment || !body.storageProvider) {
|
|
333
|
+
return response({ error: "id, name, environment, and storageProvider are required" }, 422);
|
|
334
|
+
}
|
|
335
|
+
if (!projectAllowed(principal, body.id)) return response({ error: "Project scope is not allowed" }, 403);
|
|
336
|
+
const workspaceId = body.workspaceId ?? body.tenantId ?? `workspace_${principal.aegisId}`;
|
|
337
|
+
const tenantId = body.tenantId ?? workspaceId;
|
|
338
|
+
const existingWorkspace = await env.DB.prepare(
|
|
339
|
+
"SELECT id FROM workspaces WHERE id = ?",
|
|
340
|
+
).bind(workspaceId).first<{ id: string }>();
|
|
341
|
+
if (existingWorkspace && !(await memberOfWorkspace(env, principal, workspaceId, "workspace.write"))) {
|
|
342
|
+
return response({ error: "Workspace scope is not allowed" }, 403);
|
|
343
|
+
}
|
|
344
|
+
// Re-registering a project is how the CLI refreshes it, but only for people
|
|
345
|
+
// who may manage it; otherwise an id could be used to move someone else's
|
|
346
|
+
// project into another workspace.
|
|
347
|
+
const existingProject = await env.DB.prepare("SELECT workspace_id AS workspaceId FROM projects WHERE id = ?")
|
|
348
|
+
.bind(body.id).first<{ workspaceId: string }>();
|
|
349
|
+
if (existingProject && !(await memberOfProject(env, principal, body.id, "project.manage"))) {
|
|
350
|
+
return response({ error: "A project with this id already exists" }, 409);
|
|
351
|
+
}
|
|
352
|
+
if (existingProject && existingProject.workspaceId !== workspaceId) {
|
|
353
|
+
return response({ error: "This project belongs to another workspace" }, 409);
|
|
354
|
+
}
|
|
355
|
+
const joinedAt = new Date().toISOString();
|
|
356
|
+
await env.DB.batch([
|
|
357
|
+
env.DB.prepare(`
|
|
358
|
+
INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING
|
|
359
|
+
`).bind(principal.aegisId),
|
|
360
|
+
env.DB.prepare(`
|
|
361
|
+
INSERT INTO workspaces (id, name, created_by)
|
|
362
|
+
VALUES (?, ?, ?)
|
|
363
|
+
ON CONFLICT(id) DO NOTHING
|
|
364
|
+
`).bind(workspaceId, body.workspaceName ?? workspaceId, principal.aegisId),
|
|
365
|
+
// Only the creator of a brand-new workspace becomes its owner; an existing
|
|
366
|
+
// membership (including a removed one) is left alone.
|
|
367
|
+
env.DB.prepare(`
|
|
368
|
+
INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status, created_at)
|
|
369
|
+
SELECT ?, ?, 'owner', 'active', ?
|
|
370
|
+
WHERE NOT EXISTS (SELECT 1 FROM workspace_memberships WHERE workspace_id = ?)
|
|
371
|
+
`).bind(workspaceId, principal.aegisId, joinedAt, workspaceId),
|
|
372
|
+
env.DB.prepare(`
|
|
373
|
+
INSERT INTO project_apps (id, project_id, name, type, is_default)
|
|
374
|
+
VALUES (?, ?, ?, 'web', 1)
|
|
375
|
+
ON CONFLICT(project_id, id) DO NOTHING
|
|
376
|
+
`).bind(body.appId?.trim() || "default", body.id, body.appName?.trim() || body.name),
|
|
377
|
+
env.DB.prepare(`
|
|
378
|
+
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
|
|
379
|
+
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
380
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
|
|
381
|
+
storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
|
|
382
|
+
`).bind(body.id, workspaceId, tenantId, body.name, body.environment, currentStorageProvider(body.storageProvider)),
|
|
383
|
+
env.DB.prepare(`
|
|
384
|
+
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
385
|
+
VALUES (?, ?, 'owner', 'active')
|
|
386
|
+
ON CONFLICT(project_id, aegis_id) DO NOTHING
|
|
387
|
+
`).bind(body.id, principal.aegisId),
|
|
388
|
+
]);
|
|
389
|
+
await auditProject(env, principal, body.id, { action: "project.registered", targetType: "project", targetId: body.id, data: { environment: body.environment } }, request);
|
|
390
|
+
return response({ registered: true, projectId: body.id, workspaceId, tenantId }, 201);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function listWorkspaces(env: Env, principal: Principal): Promise<Response> {
|
|
394
|
+
const rows = await env.DB.prepare(`
|
|
395
|
+
SELECT w.id, w.name, w.created_at AS createdAt, wm.role
|
|
396
|
+
FROM workspaces w
|
|
397
|
+
JOIN workspace_memberships wm ON wm.workspace_id = w.id
|
|
398
|
+
WHERE wm.aegis_id = ? AND wm.status = 'active'
|
|
399
|
+
ORDER BY w.created_at ASC
|
|
400
|
+
`).bind(principal.aegisId).all();
|
|
401
|
+
const workspaces = await Promise.all(rows.results.map(async (workspace) => {
|
|
402
|
+
const projects = await env.DB.prepare(`
|
|
403
|
+
SELECT p.id, p.name, p.environment, p.storage_provider AS storageProvider
|
|
404
|
+
FROM projects p
|
|
405
|
+
WHERE p.workspace_id = ? AND p.deleted_at IS NULL
|
|
406
|
+
ORDER BY p.created_at ASC
|
|
407
|
+
`).bind(workspace.id).all();
|
|
408
|
+
return { ...workspace, projects: projects.results };
|
|
409
|
+
}));
|
|
410
|
+
return response({ workspaces });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function createWorkspace(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
414
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) {
|
|
415
|
+
return response({ error: "Workspace creation is not allowed" }, 403);
|
|
416
|
+
}
|
|
417
|
+
const body = await request.json().catch(() => null) as { id?: string; name?: string } | null;
|
|
418
|
+
const workspaceId = body?.id?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
|
|
419
|
+
const name = body?.name?.trim();
|
|
420
|
+
if (!workspaceId || !name) return response({ error: "id and name are required" }, 422);
|
|
421
|
+
const freeLimit = await freeWorkspaceLimit(env, principal);
|
|
422
|
+
if (freeLimit) return freeLimit;
|
|
423
|
+
try {
|
|
424
|
+
await env.DB.batch([
|
|
425
|
+
env.DB.prepare("INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING").bind(principal.aegisId),
|
|
426
|
+
env.DB.prepare("INSERT INTO workspaces (id, name, created_by) VALUES (?, ?, ?)").bind(workspaceId, name, principal.aegisId),
|
|
427
|
+
env.DB.prepare(`
|
|
428
|
+
INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status)
|
|
429
|
+
VALUES (?, ?, 'owner', 'active')
|
|
430
|
+
`).bind(workspaceId, principal.aegisId),
|
|
431
|
+
]);
|
|
432
|
+
} catch {
|
|
433
|
+
return response({ error: "A workspace with this id already exists" }, 409);
|
|
434
|
+
}
|
|
435
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "workspace.created", targetType: "workspace", targetId: workspaceId }, request);
|
|
436
|
+
return response({ workspace: { id: workspaceId, name, projects: [] } }, 201);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function updateWorkspace(request: Request, env: Env, principal: Principal, workspaceId: string): Promise<Response> {
|
|
440
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Workspace editing is not allowed" }, 403);
|
|
441
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"))) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
442
|
+
const body = await request.json().catch(() => null) as { name?: string } | null;
|
|
443
|
+
const name = body?.name?.trim();
|
|
444
|
+
if (!name) return response({ error: "name is required" }, 422);
|
|
445
|
+
await env.DB.prepare("UPDATE workspaces SET name = ? WHERE id = ?").bind(name, workspaceId).run();
|
|
446
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "workspace.renamed", targetType: "workspace", targetId: workspaceId, data: { name } }, request);
|
|
447
|
+
return response({ workspace: { id: workspaceId, name } });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Bugmole storage was called "valkyrie" before the rename; 0.3.x CLIs still send that. */
|
|
451
|
+
export function currentStorageProvider(provider: string): string {
|
|
452
|
+
return provider.trim() === "valkyrie" ? "bugmole" : provider.trim();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** "bugmole" storage lives in Bugmole's bucket; anything else is the customer's own. */
|
|
456
|
+
function storageModeOf(provider: string): "managed" | "byo" {
|
|
457
|
+
return currentStorageProvider(provider) === "bugmole" ? "managed" : "byo";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function createManagedProject(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
461
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Project creation is not allowed" }, 403);
|
|
462
|
+
const body = await request.json().catch(() => null) as {
|
|
463
|
+
id?: string; name?: string; workspaceId?: string; environment?: string; storageProvider?: string;
|
|
464
|
+
} | null;
|
|
465
|
+
const projectId = body?.id?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
|
|
466
|
+
const name = body?.name?.trim();
|
|
467
|
+
const workspaceId = body?.workspaceId?.trim();
|
|
468
|
+
if (!projectId || !name || !workspaceId || !body?.environment?.trim() || !body.storageProvider?.trim()) {
|
|
469
|
+
return response({ error: "id, name, workspaceId, environment, and storageProvider are required" }, 422);
|
|
470
|
+
}
|
|
471
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.write"))) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
472
|
+
const projectLimit = await checkProjectLimit(env, workspaceId);
|
|
473
|
+
if (projectLimit) return projectLimit;
|
|
474
|
+
try {
|
|
475
|
+
await env.DB.batch([
|
|
476
|
+
env.DB.prepare(`
|
|
477
|
+
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode, created_at)
|
|
478
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
479
|
+
`).bind(projectId, workspaceId, workspaceId, name, body.environment.trim(), currentStorageProvider(body.storageProvider), storageModeOf(body.storageProvider)),
|
|
480
|
+
env.DB.prepare("INSERT INTO project_memberships (project_id, aegis_id, role, status) VALUES (?, ?, 'owner', 'active')")
|
|
481
|
+
.bind(projectId, principal.aegisId),
|
|
482
|
+
env.DB.prepare("INSERT INTO project_apps (id, project_id, name, type, is_default) VALUES ('default', ?, ?, 'web', 1)")
|
|
483
|
+
.bind(projectId, name),
|
|
484
|
+
]);
|
|
485
|
+
} catch {
|
|
486
|
+
return response({ error: "A project with this id already exists" }, 409);
|
|
487
|
+
}
|
|
488
|
+
await auditProject(env, principal, projectId, { action: "project.created", targetType: "project", targetId: projectId }, request);
|
|
489
|
+
return response({ project: { id: projectId, name, workspaceId, environment: body.environment.trim(), storageProvider: body.storageProvider.trim() } }, 201);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function updateProject(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
493
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Project editing is not allowed" }, 403);
|
|
494
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
495
|
+
const body = await request.json().catch(() => null) as { name?: string; environment?: string; storageProvider?: string } | null;
|
|
496
|
+
const name = body?.name?.trim();
|
|
497
|
+
if (!name || !body?.environment?.trim() || !body.storageProvider?.trim()) {
|
|
498
|
+
return response({ error: "name, environment, and storageProvider are required" }, 422);
|
|
499
|
+
}
|
|
500
|
+
await env.DB.prepare(`
|
|
501
|
+
UPDATE projects SET name = ?, environment = ?, storage_provider = ?, storage_mode = ? WHERE id = ?
|
|
502
|
+
`).bind(name, body.environment.trim(), currentStorageProvider(body.storageProvider), storageModeOf(body.storageProvider), projectId).run();
|
|
503
|
+
await auditProject(env, principal, projectId, { action: "project.updated", targetType: "project", targetId: projectId }, request);
|
|
504
|
+
return response({ project: { id: projectId, name, environment: body.environment.trim(), storageProvider: body.storageProvider.trim() } });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function createApiKey(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
508
|
+
if (!can(principal, "keys:write") && !can(principal, "*")) {
|
|
509
|
+
return response({ error: "API key creation is not allowed" }, 403);
|
|
510
|
+
}
|
|
511
|
+
const body = await request.json().catch(() => null) as {
|
|
512
|
+
name?: string;
|
|
513
|
+
scopes?: string[];
|
|
514
|
+
projectIds?: string[];
|
|
515
|
+
expiresAt?: string | null;
|
|
516
|
+
} | null;
|
|
517
|
+
if (!body?.name?.trim()) return response({ error: "name is required" }, 422);
|
|
518
|
+
const projectIds = body.projectIds ?? [];
|
|
519
|
+
for (const projectId of projectIds) {
|
|
520
|
+
if (!(await memberOfProject(env, principal, projectId))) {
|
|
521
|
+
return response({ error: "Project scope is not allowed" }, 403);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const id = `key_${randomToken(12)}`;
|
|
525
|
+
const token = `bm_live_${id}_${randomToken()}`;
|
|
526
|
+
await env.DB.prepare(`
|
|
527
|
+
INSERT INTO api_keys (id, aegis_id, name, token_hash, scopes_json, project_ids_json, expires_at)
|
|
528
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
529
|
+
`).bind(
|
|
530
|
+
id,
|
|
531
|
+
principal.aegisId,
|
|
532
|
+
body.name.trim(),
|
|
533
|
+
await sha256(token),
|
|
534
|
+
JSON.stringify(body.scopes ?? ["projects:read", "projects:write", "runs:write", "artifacts:write"]),
|
|
535
|
+
JSON.stringify(projectIds),
|
|
536
|
+
body.expiresAt ?? null,
|
|
537
|
+
).run();
|
|
538
|
+
for (const projectId of projectIds) await auditProject(env, principal, projectId, { action: "api_key.created", targetType: "api_key", targetId: id }, request);
|
|
539
|
+
return response({
|
|
540
|
+
id,
|
|
541
|
+
name: body.name.trim(),
|
|
542
|
+
scopes: body.scopes ?? ["projects:read", "projects:write", "runs:write", "artifacts:write"],
|
|
543
|
+
projectIds,
|
|
544
|
+
expiresAt: body.expiresAt ?? null,
|
|
545
|
+
token,
|
|
546
|
+
warning: "Store this token securely. It will not be shown again.",
|
|
547
|
+
}, 201);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function listApiKeys(env: Env, principal: Principal): Promise<Response> {
|
|
551
|
+
const rows = await env.DB.prepare(`
|
|
552
|
+
SELECT id, name, scopes_json AS scopes, project_ids_json AS projectIds,
|
|
553
|
+
expires_at AS expiresAt, created_at AS createdAt, revoked_at AS revokedAt
|
|
554
|
+
FROM api_keys
|
|
555
|
+
WHERE aegis_id = ? AND revoked_at IS NULL
|
|
556
|
+
ORDER BY created_at DESC
|
|
557
|
+
`).bind(principal.aegisId).all<{
|
|
558
|
+
id: string;
|
|
559
|
+
name: string;
|
|
560
|
+
scopes: string;
|
|
561
|
+
projectIds: string;
|
|
562
|
+
expiresAt: string | null;
|
|
563
|
+
createdAt: string;
|
|
564
|
+
revokedAt: string | null;
|
|
565
|
+
}>();
|
|
566
|
+
return response({
|
|
567
|
+
keys: rows.results.map((key) => ({
|
|
568
|
+
id: key.id,
|
|
569
|
+
name: key.name,
|
|
570
|
+
scopes: JSON.parse(key.scopes || "[]"),
|
|
571
|
+
projectIds: JSON.parse(key.projectIds || "[]"),
|
|
572
|
+
expiresAt: key.expiresAt,
|
|
573
|
+
createdAt: key.createdAt,
|
|
574
|
+
revokedAt: key.revokedAt,
|
|
575
|
+
})),
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function listProjects(env: Env, principal: Principal): Promise<Response> {
|
|
580
|
+
const rows = await env.DB.prepare(`
|
|
581
|
+
SELECT p.id, p.workspace_id AS workspaceId,
|
|
582
|
+
CASE
|
|
583
|
+
WHEN w.name LIKE 'tenant_%' OR w.name LIKE 'workspace_%'
|
|
584
|
+
THEN 'Bugmole workspace'
|
|
585
|
+
ELSE w.name
|
|
586
|
+
END AS workspaceName,
|
|
587
|
+
p.tenant_id AS tenantId, p.name, p.environment,
|
|
588
|
+
p.storage_provider AS storageProvider, p.storage_mode AS storageMode,
|
|
589
|
+
p.default_execution_mode AS defaultExecutionMode, p.locked_at AS lockedAt, p.created_at AS createdAt
|
|
590
|
+
FROM projects p
|
|
591
|
+
JOIN workspace_memberships wm ON wm.workspace_id = p.workspace_id
|
|
592
|
+
JOIN workspaces w ON w.id = p.workspace_id
|
|
593
|
+
WHERE wm.aegis_id = ? AND wm.status = 'active' AND p.deleted_at IS NULL
|
|
594
|
+
ORDER BY p.created_at DESC
|
|
595
|
+
`).bind(principal.aegisId).all();
|
|
596
|
+
const visible = rows.results.filter((project) => projectAllowed(principal, String(project.id)));
|
|
597
|
+
const projects = await Promise.all(visible.map(async (project) => {
|
|
598
|
+
const apps = await env.DB.prepare(`
|
|
599
|
+
SELECT id, name, type, location, is_default AS isDefault
|
|
600
|
+
FROM project_apps
|
|
601
|
+
WHERE project_id = ?
|
|
602
|
+
ORDER BY created_at ASC
|
|
603
|
+
`).bind(project.id).all<{
|
|
604
|
+
id: string;
|
|
605
|
+
name: string;
|
|
606
|
+
type: string;
|
|
607
|
+
location: string | null;
|
|
608
|
+
isDefault: number;
|
|
609
|
+
}>();
|
|
610
|
+
const environments = await env.DB.prepare(`
|
|
611
|
+
SELECT id, app_id AS appId, name, base_url AS baseUrl, api_url AS apiUrl,
|
|
612
|
+
status, is_default AS isDefault
|
|
613
|
+
FROM project_environments
|
|
614
|
+
WHERE project_id = ?
|
|
615
|
+
ORDER BY is_default DESC, created_at ASC
|
|
616
|
+
`).bind(project.id).all<{
|
|
617
|
+
id: string;
|
|
618
|
+
appId: string;
|
|
619
|
+
name: string;
|
|
620
|
+
baseUrl: string;
|
|
621
|
+
apiUrl: string | null;
|
|
622
|
+
status: string;
|
|
623
|
+
isDefault: number;
|
|
624
|
+
}>();
|
|
625
|
+
return {
|
|
626
|
+
...project,
|
|
627
|
+
apps: apps.results.map((app) => ({
|
|
628
|
+
...app,
|
|
629
|
+
location: app.location ?? undefined,
|
|
630
|
+
isDefault: Boolean(app.isDefault),
|
|
631
|
+
})),
|
|
632
|
+
environments: environments.results.map((environment) => ({
|
|
633
|
+
...environment,
|
|
634
|
+
apiUrl: environment.apiUrl ?? undefined,
|
|
635
|
+
isDefault: Boolean(environment.isDefault),
|
|
636
|
+
})),
|
|
637
|
+
};
|
|
638
|
+
}));
|
|
639
|
+
return response({ projects });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function createApp(
|
|
643
|
+
request: Request,
|
|
644
|
+
env: Env,
|
|
645
|
+
principal: Principal,
|
|
646
|
+
projectId: string,
|
|
647
|
+
): Promise<Response> {
|
|
648
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) {
|
|
649
|
+
return response({ error: "App creation is not allowed" }, 403);
|
|
650
|
+
}
|
|
651
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) {
|
|
652
|
+
return response({ error: "Project scope is not allowed" }, 403);
|
|
653
|
+
}
|
|
654
|
+
const body = await request.json().catch(() => null) as {
|
|
655
|
+
id?: string;
|
|
656
|
+
name?: string;
|
|
657
|
+
type?: string;
|
|
658
|
+
location?: string;
|
|
659
|
+
} | null;
|
|
660
|
+
const id = body?.id?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
|
|
661
|
+
if (!id || !body?.name?.trim()) return response({ error: "id and name are required" }, 422);
|
|
662
|
+
const count = await env.DB.prepare(
|
|
663
|
+
"SELECT COUNT(*) AS count FROM project_apps WHERE project_id = ?",
|
|
664
|
+
).bind(projectId).first<{ count: number }>();
|
|
665
|
+
try {
|
|
666
|
+
await env.DB.prepare(`
|
|
667
|
+
INSERT INTO project_apps (id, project_id, name, type, location, is_default)
|
|
668
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
669
|
+
`).bind(
|
|
670
|
+
id,
|
|
671
|
+
projectId,
|
|
672
|
+
body.name.trim(),
|
|
673
|
+
body.type?.trim() || "web",
|
|
674
|
+
body.location?.trim() || null,
|
|
675
|
+
(count?.count ?? 0) === 0 ? 1 : 0,
|
|
676
|
+
).run();
|
|
677
|
+
} catch {
|
|
678
|
+
return response({ error: "An app with this id already exists" }, 409);
|
|
679
|
+
}
|
|
680
|
+
await auditProject(env, principal, projectId, { action: "app.created", targetType: "app" }, request);
|
|
681
|
+
return response({
|
|
682
|
+
app: {
|
|
683
|
+
id,
|
|
684
|
+
name: body.name.trim(),
|
|
685
|
+
type: body.type?.trim() || "web",
|
|
686
|
+
location: body.location?.trim() || undefined,
|
|
687
|
+
isDefault: (count?.count ?? 0) === 0,
|
|
688
|
+
},
|
|
689
|
+
}, 201);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function createEnvironment(
|
|
693
|
+
request: Request,
|
|
694
|
+
env: Env,
|
|
695
|
+
principal: Principal,
|
|
696
|
+
projectId: string,
|
|
697
|
+
): Promise<Response> {
|
|
698
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) {
|
|
699
|
+
return response({ error: "Environment creation is not allowed" }, 403);
|
|
700
|
+
}
|
|
701
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) {
|
|
702
|
+
return response({ error: "Project scope is not allowed" }, 403);
|
|
703
|
+
}
|
|
704
|
+
const body = await request.json().catch(() => null) as {
|
|
705
|
+
id?: string;
|
|
706
|
+
appId?: string;
|
|
707
|
+
name?: string;
|
|
708
|
+
baseUrl?: string;
|
|
709
|
+
apiUrl?: string;
|
|
710
|
+
} | null;
|
|
711
|
+
const id = body?.id?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
|
|
712
|
+
if (!id || !body?.name?.trim() || !body.baseUrl?.trim()) {
|
|
713
|
+
return response({ error: "id, name, and baseUrl are required" }, 422);
|
|
714
|
+
}
|
|
715
|
+
const appId = body?.appId?.trim() || "default";
|
|
716
|
+
const app = await env.DB.prepare(
|
|
717
|
+
"SELECT id FROM project_apps WHERE project_id = ? AND id = ?",
|
|
718
|
+
).bind(projectId, appId).first<{ id: string }>();
|
|
719
|
+
if (!app) return response({ error: "App does not belong to this project" }, 422);
|
|
720
|
+
try {
|
|
721
|
+
await env.DB.prepare(`
|
|
722
|
+
INSERT INTO project_environments (id, project_id, app_id, name, base_url, api_url)
|
|
723
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
724
|
+
`).bind(id, projectId, appId, body.name.trim(), body.baseUrl.trim(), body.apiUrl?.trim() || null).run();
|
|
725
|
+
} catch {
|
|
726
|
+
return response({ error: "An environment with this id already exists" }, 409);
|
|
727
|
+
}
|
|
728
|
+
await auditProject(env, principal, projectId, { action: "environment.created", targetType: "environment" }, request);
|
|
729
|
+
return response({
|
|
730
|
+
environment: {
|
|
731
|
+
id,
|
|
732
|
+
appId,
|
|
733
|
+
name: body.name.trim(),
|
|
734
|
+
baseUrl: body.baseUrl.trim(),
|
|
735
|
+
apiUrl: body.apiUrl?.trim() || undefined,
|
|
736
|
+
status: "active",
|
|
737
|
+
isDefault: false,
|
|
738
|
+
},
|
|
739
|
+
}, 201);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function updateApp(request: Request, env: Env, principal: Principal, projectId: string, appId: string): Promise<Response> {
|
|
743
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "App editing is not allowed" }, 403);
|
|
744
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
745
|
+
const body = await request.json().catch(() => null) as { name?: string; type?: string; location?: string } | null ?? {};
|
|
746
|
+
const name = body?.name?.trim();
|
|
747
|
+
if (!name) return response({ error: "name is required" }, 422);
|
|
748
|
+
const result = await env.DB.prepare(`
|
|
749
|
+
UPDATE project_apps SET name = ?, type = ?, location = ? WHERE project_id = ? AND id = ?
|
|
750
|
+
`).bind(name, body.type?.trim() || "web", body.location?.trim() || null, projectId, appId).run() as { meta?: { changes?: number } };
|
|
751
|
+
if (result.meta?.changes !== 1) return response({ error: "App not found" }, 404);
|
|
752
|
+
await auditProject(env, principal, projectId, { action: "app.updated", targetType: "app", targetId: appId }, request);
|
|
753
|
+
return response({ app: { id: appId, name, type: body.type?.trim() || "web", location: body.location?.trim() || undefined } });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function updateEnvironment(
|
|
757
|
+
request: Request,
|
|
758
|
+
env: Env,
|
|
759
|
+
principal: Principal,
|
|
760
|
+
projectId: string,
|
|
761
|
+
environmentId: string,
|
|
762
|
+
): Promise<Response> {
|
|
763
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Environment editing is not allowed" }, 403);
|
|
764
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
765
|
+
const body = await request.json().catch(() => null) as {
|
|
766
|
+
appId?: string; name?: string; baseUrl?: string; apiUrl?: string; status?: string; isDefault?: boolean;
|
|
767
|
+
} | null ?? {};
|
|
768
|
+
const name = body?.name?.trim();
|
|
769
|
+
const baseUrl = body?.baseUrl?.trim();
|
|
770
|
+
if (!name || !baseUrl) return response({ error: "name and baseUrl are required" }, 422);
|
|
771
|
+
if (body.appId?.trim()) {
|
|
772
|
+
const app = await env.DB.prepare("SELECT id FROM project_apps WHERE project_id = ? AND id = ?")
|
|
773
|
+
.bind(projectId, body.appId.trim()).first<{ id: string }>();
|
|
774
|
+
if (!app) return response({ error: "App does not belong to this project" }, 422);
|
|
775
|
+
}
|
|
776
|
+
const result = await env.DB.prepare(`
|
|
777
|
+
UPDATE project_environments
|
|
778
|
+
SET app_id = COALESCE(?, app_id), name = ?, base_url = ?, api_url = ?, status = COALESCE(?, status),
|
|
779
|
+
is_default = CASE WHEN ? = 1 THEN 1 ELSE is_default END
|
|
780
|
+
WHERE project_id = ? AND id = ?
|
|
781
|
+
`).bind(
|
|
782
|
+
body.appId?.trim() || null,
|
|
783
|
+
name,
|
|
784
|
+
baseUrl,
|
|
785
|
+
body.apiUrl?.trim() || null,
|
|
786
|
+
body.status?.trim() || null,
|
|
787
|
+
body.isDefault === true ? 1 : 0,
|
|
788
|
+
projectId,
|
|
789
|
+
environmentId,
|
|
790
|
+
).run() as { meta?: { changes?: number } };
|
|
791
|
+
if (result.meta?.changes !== 1) return response({ error: "Environment not found" }, 404);
|
|
792
|
+
await auditProject(env, principal, projectId, { action: "environment.updated", targetType: "environment", targetId: environmentId }, request);
|
|
793
|
+
return response({ environment: { id: environmentId, appId: body.appId, name, baseUrl, apiUrl: body.apiUrl?.trim() || undefined, status: body.status || "active", isDefault: body.isDefault === true } });
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function revokeApiKey(request: Request, env: Env, principal: Principal, keyId: string): Promise<Response> {
|
|
797
|
+
if (!can(principal, "keys:write") && !can(principal, "*")) return response({ error: "Not allowed" }, 403);
|
|
798
|
+
const revokedKey = await env.DB.prepare("SELECT project_ids_json AS projectIds FROM api_keys WHERE id = ? AND aegis_id = ?")
|
|
799
|
+
.bind(keyId, principal.aegisId).first<{ projectIds: string }>();
|
|
800
|
+
const revokedProjectIds: string[] = revokedKey ? JSON.parse(revokedKey.projectIds || "[]") : [];
|
|
801
|
+
await env.DB.prepare(`
|
|
802
|
+
UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP
|
|
803
|
+
WHERE id = ? AND aegis_id = ? AND revoked_at IS NULL
|
|
804
|
+
`).bind(keyId, principal.aegisId).run();
|
|
805
|
+
for (const projectId of revokedProjectIds) await auditProject(env, principal, projectId, { action: "api_key.revoked", targetType: "api_key", targetId: keyId }, request);
|
|
806
|
+
return response({ revoked: true, id: keyId });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function requestJson(request: Request): Promise<Record<string, unknown> | null> {
|
|
810
|
+
return await request.json().catch(() => null) as Record<string, unknown> | null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function id(prefix: string): string {
|
|
814
|
+
return `${prefix}_${randomToken(12)}`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function listPlanDrafts(env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
818
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
819
|
+
const rows = await env.DB.prepare(`
|
|
820
|
+
SELECT id, project_id AS projectId, plan_id AS planId, journey_id AS journeyId,
|
|
821
|
+
object_key AS objectKey, status, created_by AS createdBy,
|
|
822
|
+
created_at AS createdAt, updated_at AS updatedAt
|
|
823
|
+
FROM plan_drafts WHERE project_id = ? ORDER BY updated_at DESC
|
|
824
|
+
`).bind(projectId).all();
|
|
825
|
+
return response({ plans: rows.results });
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function publishPlanDraft(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
829
|
+
if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Plan publication is not allowed" }, 403);
|
|
830
|
+
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
831
|
+
const body = await requestJson(request);
|
|
832
|
+
const planId = typeof body?.planId === "string" ? body.planId.trim() : "";
|
|
833
|
+
const journeyId = typeof body?.journeyId === "string" ? body.journeyId.trim() : "";
|
|
834
|
+
const objectKey = typeof body?.objectKey === "string" ? body.objectKey.trim() : "";
|
|
835
|
+
if (!planId || !journeyId || !objectKey) return response({ error: "planId, journeyId, and objectKey are required" }, 422);
|
|
836
|
+
const project = await env.DB.prepare("SELECT tenant_id AS tenantId FROM projects WHERE id = ?")
|
|
837
|
+
.bind(projectId).first<{ tenantId: string }>();
|
|
838
|
+
if (!project) return response({ error: "Project not found" }, 404);
|
|
839
|
+
const draftId = id("plan");
|
|
840
|
+
await env.DB.prepare(`
|
|
841
|
+
INSERT INTO plan_drafts (id, project_id, tenant_id, plan_id, journey_id, object_key, created_by)
|
|
842
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
843
|
+
ON CONFLICT(project_id, plan_id) DO UPDATE SET journey_id = excluded.journey_id,
|
|
844
|
+
object_key = excluded.object_key, status = 'draft', updated_at = CURRENT_TIMESTAMP
|
|
845
|
+
`).bind(draftId, projectId, project.tenantId, planId, journeyId, objectKey, principal.aegisId).run();
|
|
846
|
+
const plan = await env.DB.prepare(`
|
|
847
|
+
SELECT id, project_id AS projectId, plan_id AS planId, journey_id AS journeyId,
|
|
848
|
+
object_key AS objectKey, status, created_by AS createdBy,
|
|
849
|
+
created_at AS createdAt, updated_at AS updatedAt
|
|
850
|
+
FROM plan_drafts WHERE project_id = ? AND plan_id = ?
|
|
851
|
+
`).bind(projectId, planId).first();
|
|
852
|
+
return response({ plan }, 201);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Mirrors src/runtime/browser-matrix.ts. The Worker can't import Playwright,
|
|
856
|
+
// so device names are only shape-checked here; the runtime validates them
|
|
857
|
+
// against Playwright's device list and reports an unknown one as a run error.
|
|
858
|
+
const RUN_BROWSERS = new Set(["chromium", "firefox", "webkit", "msedge"]);
|
|
859
|
+
const MAX_RUN_TARGETS = 12;
|
|
860
|
+
const MAX_TARGET_RESULTS_BYTES = 64 * 1024;
|
|
861
|
+
const TERMINAL_RUN_STATUSES = new Set(["success", "partial", "error", "cancelled"]);
|
|
862
|
+
|
|
863
|
+
type BrowserRunTarget = { browser: string; device?: string };
|
|
864
|
+
type RunTargetRecord = BrowserRunTarget | ({ kind: "device" } & CloudDevice);
|
|
865
|
+
|
|
866
|
+
function validateRunTargets(input: unknown): { targets: RunTargetRecord[] } | { error: string } {
|
|
867
|
+
if (input === undefined || input === null) return { targets: [] };
|
|
868
|
+
if (!Array.isArray(input)) return { error: "targets must be an array of { browser, device? } or device-cloud targets" };
|
|
869
|
+
const seen = new Set<string>();
|
|
870
|
+
const targets: RunTargetRecord[] = [];
|
|
871
|
+
for (const raw of input as Array<Record<string, unknown>>) {
|
|
872
|
+
if (raw?.kind === "device") {
|
|
873
|
+
let cloud: CloudDevice;
|
|
874
|
+
try {
|
|
875
|
+
cloud = parseCloudDevice(raw);
|
|
876
|
+
} catch (error) {
|
|
877
|
+
return { error: error instanceof Error ? error.message : "Invalid device target" };
|
|
878
|
+
}
|
|
879
|
+
const key = cloudTargetId(cloud);
|
|
880
|
+
if (seen.has(key)) continue;
|
|
881
|
+
seen.add(key);
|
|
882
|
+
targets.push({ kind: "device", ...cloud });
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
const browser = raw?.browser;
|
|
886
|
+
if (typeof browser !== "string" || !RUN_BROWSERS.has(browser)) {
|
|
887
|
+
return { error: `Unsupported browser "${String(browser)}". Use one of: ${[...RUN_BROWSERS].join(", ")}.` };
|
|
888
|
+
}
|
|
889
|
+
const device = raw.device;
|
|
890
|
+
if (device !== undefined && device !== null && device !== "") {
|
|
891
|
+
if (typeof device !== "string" || !/^[A-Za-z0-9 ()+.,-]{1,48}$/.test(device)) {
|
|
892
|
+
return { error: `Invalid device "${String(device)}".` };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const key = `${browser}|${device || ""}`;
|
|
896
|
+
if (seen.has(key)) continue;
|
|
897
|
+
seen.add(key);
|
|
898
|
+
targets.push(typeof device === "string" && device ? { browser, device } : { browser });
|
|
899
|
+
}
|
|
900
|
+
if (targets.length > MAX_RUN_TARGETS) return { error: `A run can target at most ${MAX_RUN_TARGETS} browser/device combinations.` };
|
|
901
|
+
return { targets };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function parseJsonColumn(value: unknown): unknown {
|
|
905
|
+
if (typeof value !== "string" || !value) return null;
|
|
906
|
+
try {
|
|
907
|
+
return JSON.parse(value);
|
|
908
|
+
} catch {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function hydrateRun<T extends Record<string, unknown>>(row: T) {
|
|
914
|
+
const { targetsJson, targetResultsJson, fixFromJson, ...rest } = row as Record<string, unknown>;
|
|
915
|
+
const started = typeof rest.startedAt === "string" ? Date.parse(`${rest.startedAt.replace(" ", "T")}Z`) : NaN;
|
|
916
|
+
const completed = typeof rest.completedAt === "string" ? Date.parse(`${rest.completedAt.replace(" ", "T")}Z`) : NaN;
|
|
917
|
+
return {
|
|
918
|
+
...rest,
|
|
919
|
+
targets: parseJsonColumn(targetsJson),
|
|
920
|
+
targetResults: parseJsonColumn(targetResultsJson),
|
|
921
|
+
fixFrom: parseJsonColumn(fixFromJson),
|
|
922
|
+
durationMs: Number.isFinite(started) && Number.isFinite(completed) ? Math.max(0, completed - started) : null,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function listRuns(env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
927
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
928
|
+
const cutoff = await historyCutoffFor(env, await workspaceOfProject(env, projectId));
|
|
929
|
+
const rows = await env.DB.prepare(`
|
|
930
|
+
SELECT id, project_id AS projectId, plan_id AS planId, journey_id AS journeyId,
|
|
931
|
+
environment_id AS environmentId, status, worker_id AS workerId,
|
|
932
|
+
journey_revision AS journeyRevision,
|
|
933
|
+
current_step AS currentStep, steps_executed AS stepsExecuted,
|
|
934
|
+
steps_passed AS stepsPassed, steps_failed AS stepsFailed,
|
|
935
|
+
driver, platform, recovery_attempts AS recoveryAttempts,
|
|
936
|
+
failure_code AS failureCode, artifact_summary_json AS artifactSummaryJson,
|
|
937
|
+
error_code AS errorCode, error_message AS errorMessage,
|
|
938
|
+
manifest_key AS manifestKey, requested_at AS requestedAt,
|
|
939
|
+
started_at AS startedAt, completed_at AS completedAt,
|
|
940
|
+
targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
|
|
941
|
+
target_results_json AS targetResultsJson, fix_from_json AS fixFromJson,
|
|
942
|
+
execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes,
|
|
943
|
+
trigger_source AS triggerSource, repo_full_name AS repoFullName, commit_sha AS commitSha, pr_number AS prNumber,
|
|
944
|
+
gitlab_binding_id AS gitlabBindingId
|
|
945
|
+
FROM runs
|
|
946
|
+
WHERE project_id = ? AND (? IS NULL OR COALESCE(completed_at, requested_at) >= ? OR status NOT IN ('success', 'partial', 'error', 'cancelled'))
|
|
947
|
+
ORDER BY requested_at DESC LIMIT 100
|
|
948
|
+
`).bind(projectId, cutoff, cutoff).all<Record<string, unknown>>();
|
|
949
|
+
return response({ runs: rows.results.map(hydrateRun) });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function createRun(request: Request, env: Env, principal: Principal, projectId: string, ctx: ExecutionContext, internal: InternalRunFields = {}): Promise<Response> {
|
|
953
|
+
if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Run creation is not allowed" }, 403);
|
|
954
|
+
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
955
|
+
const body = await requestJson(request);
|
|
956
|
+
const planId = typeof body?.planId === "string" ? body.planId.trim() : "";
|
|
957
|
+
const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
|
|
958
|
+
if (!planId || !idempotencyKey) return response({ error: "planId and idempotencyKey are required" }, 422);
|
|
959
|
+
const draft = await env.DB.prepare(`
|
|
960
|
+
SELECT journey_id AS journeyId FROM plan_drafts WHERE project_id = ? AND plan_id = ?
|
|
961
|
+
`).bind(projectId, planId).first<{ journeyId: string }>();
|
|
962
|
+
if (!draft) return response({ error: "Plan draft not found" }, 404);
|
|
963
|
+
const existing = await env.DB.prepare(`
|
|
964
|
+
SELECT id, status FROM runs WHERE project_id = ? AND idempotency_key = ?
|
|
965
|
+
`).bind(projectId, idempotencyKey).first();
|
|
966
|
+
if (existing) return response({ run: existing }, 200);
|
|
967
|
+
const matrix = validateRunTargets(body?.targets);
|
|
968
|
+
if ("error" in matrix) return response({ error: matrix.error }, 422);
|
|
969
|
+
const deviceTargets = matrix.targets.filter((target): target is { kind: "device" } & CloudDevice => "kind" in target);
|
|
970
|
+
const executionMode = executionModeOf(body?.executionMode);
|
|
971
|
+
if (!executionMode) return response({ error: "executionMode must be self or cloud" }, 422);
|
|
972
|
+
const runWorkspaceId = await workspaceOfProject(env, projectId);
|
|
973
|
+
if (executionMode === "cloud") {
|
|
974
|
+
if (!runWorkspaceId) return response({ error: "This project isn't in a workspace" }, 422);
|
|
975
|
+
const storageProblem = await cloudStorageError(env, projectId);
|
|
976
|
+
if (storageProblem) return storageProblem;
|
|
977
|
+
const accountProblem = await cloudAccountError(env, principal, runWorkspaceId);
|
|
978
|
+
if (accountProblem) return accountProblem;
|
|
979
|
+
const blocked = await checkCloudRun(env, runWorkspaceId);
|
|
980
|
+
if (blocked) return blocked;
|
|
981
|
+
}
|
|
982
|
+
if (deviceTargets.length) {
|
|
983
|
+
const deviceProblem = await deviceTargetsError(env, projectId, runWorkspaceId, deviceTargets, executionMode);
|
|
984
|
+
if (deviceProblem) return deviceProblem;
|
|
985
|
+
}
|
|
986
|
+
const environmentId = typeof body?.environmentId === "string" && body.environmentId.trim() ? body.environmentId.trim() : null;
|
|
987
|
+
let environmentBaseUrl: string | null = null;
|
|
988
|
+
if (environmentId) {
|
|
989
|
+
const environment = await env.DB.prepare(`
|
|
990
|
+
SELECT base_url AS baseUrl FROM project_environments WHERE project_id = ? AND id = ?
|
|
991
|
+
`).bind(projectId, environmentId).first<{ baseUrl: string | null }>();
|
|
992
|
+
if (!environment) return response({ error: "Environment not found in this project" }, 422);
|
|
993
|
+
environmentBaseUrl = internal.environmentBaseUrl ?? (environment.baseUrl?.trim() || null);
|
|
994
|
+
if (!environmentBaseUrl) {
|
|
995
|
+
return response({ error: "Environment has no base URL — set one in Manage → Environments", code: "environment_base_url_missing" }, 422);
|
|
996
|
+
}
|
|
997
|
+
// Only when the environment's own URL is what's being used: a person
|
|
998
|
+
// chose it. internal.environmentBaseUrl (GitHub PR preview runs) comes
|
|
999
|
+
// from a repo binding an admin already configured, so it isn't gated
|
|
1000
|
+
// again here.
|
|
1001
|
+
if (executionMode === "cloud" && runWorkspaceId && !internal.environmentBaseUrl) {
|
|
1002
|
+
const domainProblem = await requireVerifiedDomainForCloud(env, runWorkspaceId, environmentBaseUrl);
|
|
1003
|
+
if (domainProblem) return domainProblem;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
let fixFrom: { runId: string; targetId: string; mode?: "ai" } | null = null;
|
|
1007
|
+
if (body?.fixFrom !== undefined && body?.fixFrom !== null) {
|
|
1008
|
+
const source = body.fixFrom as Record<string, unknown>;
|
|
1009
|
+
const sourceRunId = typeof source.runId === "string" ? source.runId : "";
|
|
1010
|
+
const targetId = typeof source.targetId === "string" ? source.targetId : "";
|
|
1011
|
+
if (!/^[a-z0-9-]{1,60}$/.test(targetId)) return response({ error: "fixFrom.targetId is invalid" }, 422);
|
|
1012
|
+
const sourceRun = await env.DB.prepare("SELECT plan_id AS planId FROM runs WHERE id = ? AND project_id = ?")
|
|
1013
|
+
.bind(sourceRunId, projectId).first<{ planId: string }>();
|
|
1014
|
+
if (!sourceRun) return response({ error: "The run to take the fix from was not found in this project" }, 422);
|
|
1015
|
+
if (sourceRun.planId !== planId) return response({ error: "A fix can only be applied to the plan it was suggested for" }, 422);
|
|
1016
|
+
if (source.mode !== undefined && source.mode !== "suggested" && source.mode !== "ai") {
|
|
1017
|
+
return response({ error: "fixFrom.mode must be suggested or ai" }, 422);
|
|
1018
|
+
}
|
|
1019
|
+
fixFrom = { runId: sourceRunId, targetId, ...(source.mode === "ai" ? { mode: "ai" as const } : {}) };
|
|
1020
|
+
}
|
|
1021
|
+
const project = await env.DB.prepare("SELECT tenant_id AS tenantId FROM projects WHERE id = ?")
|
|
1022
|
+
.bind(projectId).first<{ tenantId: string }>();
|
|
1023
|
+
if (!environmentId && internal.environmentBaseUrl) environmentBaseUrl = internal.environmentBaseUrl;
|
|
1024
|
+
const runId = id("run");
|
|
1025
|
+
const targets = matrix.targets.length > 0 ? matrix.targets : null;
|
|
1026
|
+
await env.DB.prepare(`
|
|
1027
|
+
INSERT INTO runs (id, project_id, tenant_id, plan_id, journey_id, journey_revision, environment_id, requested_by, idempotency_key, driver, platform, targets_json, environment_base_url, fix_from_json, execution_mode, workspace_id,
|
|
1028
|
+
trigger_source, repo_full_name, commit_sha, pr_number, github_installation_id, gitlab_binding_id)
|
|
1029
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1030
|
+
`).bind(
|
|
1031
|
+
runId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, planId, draft.journeyId,
|
|
1032
|
+
Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
|
|
1033
|
+
environmentId, principal.aegisId, idempotencyKey,
|
|
1034
|
+
typeof body?.driver === "string" ? body.driver : "maestro",
|
|
1035
|
+
typeof body?.platform === "string" ? body.platform : null,
|
|
1036
|
+
targets ? JSON.stringify(targets) : null,
|
|
1037
|
+
environmentBaseUrl,
|
|
1038
|
+
fixFrom ? JSON.stringify(fixFrom) : null,
|
|
1039
|
+
executionMode,
|
|
1040
|
+
runWorkspaceId,
|
|
1041
|
+
internal.triggerSource ?? null,
|
|
1042
|
+
internal.repoFullName ?? null,
|
|
1043
|
+
internal.commitSha ?? null,
|
|
1044
|
+
internal.prNumber ?? null,
|
|
1045
|
+
internal.githubInstallationId ?? null,
|
|
1046
|
+
internal.gitlabBindingId ?? null,
|
|
1047
|
+
).run();
|
|
1048
|
+
await appendRunEvent(env, runId, "queued", { status: "queued", targets, environmentBaseUrl, executionMode });
|
|
1049
|
+
await broadcastProjectEvent(env, projectId, { type: "job_queued", runId, projectId, planId, executionMode });
|
|
1050
|
+
for (const hook of runQueuedHooks) ctx.waitUntil(hook(env, { runId, projectId, workspaceId: runWorkspaceId, executionMode }));
|
|
1051
|
+
await auditProject(env, principal, projectId, { action: "run.created", targetType: "run", targetId: runId, data: { planId } }, request);
|
|
1052
|
+
return response({
|
|
1053
|
+
run: {
|
|
1054
|
+
id: runId, projectId, planId, journeyId: draft.journeyId, status: "queued", executionMode,
|
|
1055
|
+
environmentId, environmentBaseUrl, targets, targetResults: null, durationMs: null, fixFrom,
|
|
1056
|
+
},
|
|
1057
|
+
}, 201);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async function getRun(env: Env, principal: Principal, runId: string): Promise<Response> {
|
|
1061
|
+
const run = await env.DB.prepare(`
|
|
1062
|
+
SELECT id, project_id AS projectId, plan_id AS planId, journey_id AS journeyId,
|
|
1063
|
+
environment_id AS environmentId, status, worker_id AS workerId,
|
|
1064
|
+
current_step AS currentStep, steps_executed AS stepsExecuted,
|
|
1065
|
+
steps_passed AS stepsPassed, steps_failed AS stepsFailed,
|
|
1066
|
+
driver, platform, recovery_attempts AS recoveryAttempts,
|
|
1067
|
+
failure_code AS failureCode, artifact_summary_json AS artifactSummaryJson,
|
|
1068
|
+
error_code AS errorCode, error_message AS errorMessage,
|
|
1069
|
+
manifest_key AS manifestKey, requested_at AS requestedAt,
|
|
1070
|
+
started_at AS startedAt, completed_at AS completedAt,
|
|
1071
|
+
targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
|
|
1072
|
+
target_results_json AS targetResultsJson, fix_from_json AS fixFromJson,
|
|
1073
|
+
execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes
|
|
1074
|
+
FROM runs WHERE id = ?
|
|
1075
|
+
`).bind(runId).first<{ projectId: string } & Record<string, unknown>>();
|
|
1076
|
+
if (!run || !(await memberOfProject(env, principal, run.projectId))) return response({ error: "Run not found" }, 404);
|
|
1077
|
+
const events = await env.DB.prepare(`
|
|
1078
|
+
SELECT sequence, type, payload_json AS payload, created_at AS createdAt
|
|
1079
|
+
FROM run_events WHERE run_id = ? ORDER BY sequence ASC
|
|
1080
|
+
`).bind(runId).all<{ sequence: number; type: string; payload: string; createdAt: string }>();
|
|
1081
|
+
return response({ run: hydrateRun(run), events: events.results.map((event) => ({ ...event, payload: JSON.parse(event.payload) })) });
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function claimRun(request: Request, env: Env, principal: Principal, runId: string, ctx: ExecutionContext): Promise<Response> {
|
|
1085
|
+
if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Run claim is not allowed" }, 403);
|
|
1086
|
+
const body = await requestJson(request);
|
|
1087
|
+
const workerId = typeof body?.workerId === "string" ? body.workerId.trim() : "";
|
|
1088
|
+
if (!workerId) return response({ error: "workerId is required" }, 422);
|
|
1089
|
+
const run = await env.DB.prepare(`
|
|
1090
|
+
SELECT project_id AS projectId, targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
|
|
1091
|
+
fix_from_json AS fixFromJson, execution_mode AS executionMode, workspace_id AS workspaceId, status
|
|
1092
|
+
FROM runs WHERE id = ?
|
|
1093
|
+
`).bind(runId).first<{ projectId: string; targetsJson: string | null; environmentBaseUrl: string | null; fixFromJson: string | null; executionMode: string; workspaceId: string | null; status: string }>();
|
|
1094
|
+
if (!run || !(await memberOfProject(env, principal, run.projectId, "project.write"))) return response({ error: "Run not found" }, 404);
|
|
1095
|
+
// Bugmole Cloud runs are billed; only Bugmole's own runners may take them.
|
|
1096
|
+
if (run.executionMode === "cloud") return response({ error: "This run is queued for Bugmole Cloud" }, 409);
|
|
1097
|
+
const lease = new Date(Date.now() + 60_000).toISOString();
|
|
1098
|
+
const claimed = await env.DB.prepare(`
|
|
1099
|
+
UPDATE runs SET status = 'running', worker_id = ?, lease_expires_at = ?,
|
|
1100
|
+
heartbeat_at = CURRENT_TIMESTAMP, started_at = COALESCE(started_at, CURRENT_TIMESTAMP)
|
|
1101
|
+
WHERE id = ? AND (status = 'queued' OR (status = 'running' AND lease_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))
|
|
1102
|
+
`).bind(workerId, lease, runId).run() as { meta?: { changes?: number } };
|
|
1103
|
+
if (claimed.meta?.changes !== 1) return response({ error: "Run is already claimed or complete" }, 409);
|
|
1104
|
+
await appendRunEvent(env, runId, "running", { status: "running", workerId });
|
|
1105
|
+
await broadcastProjectEvent(env, run.projectId, { type: "run_event", runId, eventType: "running", status: "running" });
|
|
1106
|
+
if (run.status !== "running") {
|
|
1107
|
+
const event = {
|
|
1108
|
+
runId, projectId: run.projectId, workspaceId: run.workspaceId, executionMode: run.executionMode,
|
|
1109
|
+
status: "running", previousStatus: run.status, terminal: false,
|
|
1110
|
+
};
|
|
1111
|
+
for (const hook of runChangedHooks) ctx.waitUntil(hook(env, event));
|
|
1112
|
+
}
|
|
1113
|
+
return response({
|
|
1114
|
+
claimed: true,
|
|
1115
|
+
leaseExpiresAt: lease,
|
|
1116
|
+
environmentBaseUrl: run.environmentBaseUrl,
|
|
1117
|
+
targets: parseJsonColumn(run.targetsJson),
|
|
1118
|
+
fixFrom: parseJsonColumn(run.fixFromJson),
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async function updateRun(request: Request, env: Env, principal: Principal, runId: string, ctx: ExecutionContext): Promise<Response> {
|
|
1123
|
+
if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Run update is not allowed" }, 403);
|
|
1124
|
+
const body = await requestJson(request);
|
|
1125
|
+
const run = await env.DB.prepare(`
|
|
1126
|
+
SELECT project_id AS projectId, worker_id AS workerId, status, execution_mode AS executionMode, workspace_id AS workspaceId
|
|
1127
|
+
FROM runs WHERE id = ?
|
|
1128
|
+
`).bind(runId).first<{ projectId: string; workerId: string | null; status: string; executionMode: string; workspaceId: string | null }>();
|
|
1129
|
+
if (!run || !(await memberOfProject(env, principal, run.projectId, "project.write"))) return response({ error: "Run not found" }, 404);
|
|
1130
|
+
if (run.workerId && body?.workerId !== run.workerId) return response({ error: "Worker lease does not match" }, 409);
|
|
1131
|
+
const status = typeof body?.status === "string" ? body.status : "running";
|
|
1132
|
+
// A late progress tick or heartbeat (a browser lane still reporting after
|
|
1133
|
+
// the run was finalized) must not turn a finished run back into a running one.
|
|
1134
|
+
if (TERMINAL_RUN_STATUSES.has(run.status) && !TERMINAL_RUN_STATUSES.has(status)) {
|
|
1135
|
+
return response({ error: "Run already finished" }, 409);
|
|
1136
|
+
}
|
|
1137
|
+
const manifestKey = body?.manifestKey;
|
|
1138
|
+
let targetResultsJson: string | null = null;
|
|
1139
|
+
if (Array.isArray(body?.targetResults)) {
|
|
1140
|
+
const serialized = JSON.stringify(body.targetResults);
|
|
1141
|
+
if (serialized.length <= MAX_TARGET_RESULTS_BYTES) targetResultsJson = serialized;
|
|
1142
|
+
}
|
|
1143
|
+
await env.DB.prepare(`
|
|
1144
|
+
UPDATE runs SET status = ?, heartbeat_at = CURRENT_TIMESTAMP,
|
|
1145
|
+
lease_expires_at = ?, current_step = ?, steps_executed = ?, steps_passed = ?,
|
|
1146
|
+
steps_failed = ?, error_code = ?, error_message = ?,
|
|
1147
|
+
driver = COALESCE(?, driver), platform = COALESCE(?, platform),
|
|
1148
|
+
recovery_attempts = COALESCE(?, recovery_attempts),
|
|
1149
|
+
failure_code = COALESCE(?, failure_code), artifact_summary_json = COALESCE(?, artifact_summary_json),
|
|
1150
|
+
manifest_key = COALESCE(?, manifest_key),
|
|
1151
|
+
target_results_json = COALESCE(?, target_results_json),
|
|
1152
|
+
completed_at = CASE WHEN ? IN ('success', 'partial', 'error', 'cancelled') THEN CURRENT_TIMESTAMP ELSE completed_at END
|
|
1153
|
+
WHERE id = ?
|
|
1154
|
+
`).bind(
|
|
1155
|
+
status, new Date(Date.now() + 60_000).toISOString(),
|
|
1156
|
+
typeof body?.currentStep === "string" ? body.currentStep : null,
|
|
1157
|
+
Number(body?.stepsExecuted ?? 0), Number(body?.stepsPassed ?? 0), Number(body?.stepsFailed ?? 0),
|
|
1158
|
+
typeof body?.errorCode === "string" ? body.errorCode : null,
|
|
1159
|
+
typeof body?.errorMessage === "string" ? body.errorMessage : null,
|
|
1160
|
+
typeof body?.driver === "string" ? body.driver : null,
|
|
1161
|
+
typeof body?.platform === "string" ? body.platform : null,
|
|
1162
|
+
Number.isFinite(Number(body?.recoveryAttempts)) ? Number(body?.recoveryAttempts) : null,
|
|
1163
|
+
typeof body?.failureCode === "string" ? body.failureCode : null,
|
|
1164
|
+
typeof body?.artifactSummaryJson === "string" ? body.artifactSummaryJson : null,
|
|
1165
|
+
isRunManifestKey(run.projectId, runId, manifestKey) ? manifestKey : null,
|
|
1166
|
+
targetResultsJson,
|
|
1167
|
+
status, runId,
|
|
1168
|
+
).run();
|
|
1169
|
+
await appendRunEvent(env, runId, typeof body?.eventType === "string" ? body.eventType : status, body);
|
|
1170
|
+
await broadcastProjectEvent(env, run.projectId, {
|
|
1171
|
+
type: "run_event",
|
|
1172
|
+
runId,
|
|
1173
|
+
eventType: typeof body?.eventType === "string" ? body.eventType : status,
|
|
1174
|
+
...body,
|
|
1175
|
+
});
|
|
1176
|
+
if (status !== run.status) {
|
|
1177
|
+
const event = {
|
|
1178
|
+
runId, projectId: run.projectId, workspaceId: run.workspaceId, executionMode: run.executionMode,
|
|
1179
|
+
status, previousStatus: run.status, terminal: TERMINAL_RUN_STATUSES.has(status),
|
|
1180
|
+
};
|
|
1181
|
+
for (const hook of runChangedHooks) ctx.waitUntil(hook(env, event));
|
|
1182
|
+
}
|
|
1183
|
+
if (!TERMINAL_RUN_STATUSES.has(status)) {
|
|
1184
|
+
for (const check of heartbeatChecks) {
|
|
1185
|
+
const stop = await check(env, { kind: "run", id: runId, projectId: run.projectId, workspaceId: run.workspaceId, executionMode: run.executionMode });
|
|
1186
|
+
if (stop) return response({ updated: true, stop: true, reason: stop.reason, message: stop.message });
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return response({ updated: true });
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const taskSelect = `
|
|
1193
|
+
SELECT id, project_id AS projectId, title, description, task_type AS taskType,
|
|
1194
|
+
priority, status, requested_by AS requestedBy, worker_id AS workerId,
|
|
1195
|
+
lease_expires_at AS leaseExpiresAt, heartbeat_at AS heartbeatAt,
|
|
1196
|
+
progress_message AS progressMessage, continuation_json AS continuation,
|
|
1197
|
+
baseline_match_score AS baselineMatchScore, parity_match_score AS parityMatchScore,
|
|
1198
|
+
parity_audit_id AS parityAuditId, completion_summary AS completionSummary,
|
|
1199
|
+
changed_files_json AS changedFiles, error_message AS errorMessage,
|
|
1200
|
+
journey_id AS journeyId, journey_revision AS journeyRevision, step_id AS stepId,
|
|
1201
|
+
idempotency_key AS idempotencyKey, requested_at AS requestedAt,
|
|
1202
|
+
started_at AS startedAt, completed_at AS completedAt, device_session_id AS deviceSessionId,
|
|
1203
|
+
attempts, next_attempt_at AS nextAttemptAt,
|
|
1204
|
+
execution_mode AS executionMode, billed_minutes AS billedMinutes
|
|
1205
|
+
FROM agent_tasks
|
|
1206
|
+
`;
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
async function listAgentTasks(env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
1210
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1211
|
+
const rows = await env.DB.prepare(`${taskSelect} WHERE project_id = ? ORDER BY priority DESC, requested_at ASC LIMIT 100`)
|
|
1212
|
+
.bind(projectId).all();
|
|
1213
|
+
return response({ tasks: rows.results });
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function extractDiscoveryUrl(description: string): string | null {
|
|
1217
|
+
const match = /https?:\/\/[^\s"'<>]+/.exec(description);
|
|
1218
|
+
if (!match) return null;
|
|
1219
|
+
try {
|
|
1220
|
+
return /^https?:$/.test(new URL(match[0]).protocol) ? match[0] : null;
|
|
1221
|
+
} catch {
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function validDiscoveryUrl(description: string): boolean {
|
|
1227
|
+
return extractDiscoveryUrl(description) !== null;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* A project's default environment can sit with no base_url at all — created
|
|
1232
|
+
* empty by the CLI's device-pairing flow (migration 0003 / index.ts's
|
|
1233
|
+
* claim-device inserts), or not created at all by createManagedProject — and
|
|
1234
|
+
* nothing has ever filled it in since. A discovery run against a project
|
|
1235
|
+
* with no configured URL yet is the one moment we know what that URL should
|
|
1236
|
+
* be, so use it: fill an empty default environment, or create one if the
|
|
1237
|
+
* project doesn't have one yet. Either way, only into the gap — an
|
|
1238
|
+
* environment a human already pointed somewhere (including at something
|
|
1239
|
+
* else) is never touched.
|
|
1240
|
+
*/
|
|
1241
|
+
async function fillDiscoveredEnvironmentUrl(env: Env, projectId: string, description: string | null): Promise<void> {
|
|
1242
|
+
const discovered = description && extractDiscoveryUrl(description);
|
|
1243
|
+
if (!discovered) return;
|
|
1244
|
+
const origin = new URL(discovered).origin;
|
|
1245
|
+
const existing = await env.DB.prepare(
|
|
1246
|
+
"SELECT id, base_url AS baseUrl FROM project_environments WHERE project_id = ? AND is_default = 1",
|
|
1247
|
+
).bind(projectId).first<{ id: string; baseUrl: string }>();
|
|
1248
|
+
if (existing) {
|
|
1249
|
+
if (existing.baseUrl) return;
|
|
1250
|
+
await env.DB.prepare("UPDATE project_environments SET base_url = ? WHERE project_id = ? AND id = ?")
|
|
1251
|
+
.bind(origin, projectId, existing.id).run();
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const app = await env.DB.prepare("SELECT id FROM project_apps WHERE project_id = ? AND is_default = 1")
|
|
1255
|
+
.bind(projectId).first<{ id: string }>();
|
|
1256
|
+
if (!app) return;
|
|
1257
|
+
await env.DB.prepare(`
|
|
1258
|
+
INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
|
|
1259
|
+
VALUES ('default', ?, ?, 'Production', ?, 'active', 1)
|
|
1260
|
+
`).bind(projectId, app.id, origin).run();
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
async function createAgentTask(request: Request, env: Env, principal: Principal, projectId: string, ctx: ExecutionContext): Promise<Response> {
|
|
1264
|
+
if (!can(principal, "tasks:write") && !can(principal, "projects:write") && !can(principal, "*")) {
|
|
1265
|
+
return response({ error: "Task creation is not allowed" }, 403);
|
|
1266
|
+
}
|
|
1267
|
+
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1268
|
+
const body = await requestJson(request) as {
|
|
1269
|
+
title?: string; description?: string; taskType?: string; idempotencyKey?: string; priority?: number;
|
|
1270
|
+
journeyId?: string; journeyRevision?: number; deviceSessionId?: string; stepId?: string;
|
|
1271
|
+
} | null;
|
|
1272
|
+
const title = typeof body?.title === "string" ? body.title.trim() : "";
|
|
1273
|
+
const description = typeof body?.description === "string" ? body.description.trim() : "";
|
|
1274
|
+
const taskType = body?.taskType && ["code", "ui", "investigation", "discovery"].includes(body.taskType) ? body.taskType : "code";
|
|
1275
|
+
const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
|
|
1276
|
+
if (!title || !description || !idempotencyKey) return response({ error: "title, description, and idempotencyKey are required" }, 422);
|
|
1277
|
+
// A worker opens this URL in a browser, so only a real http(s) address is accepted.
|
|
1278
|
+
if (taskType === "discovery" && !validDiscoveryUrl(description)) {
|
|
1279
|
+
return response({ error: "Discovery needs an http(s) URL to explore" }, 422);
|
|
1280
|
+
}
|
|
1281
|
+
const executionMode = executionModeOf((body as { executionMode?: unknown } | null)?.executionMode);
|
|
1282
|
+
if (!executionMode) return response({ error: "executionMode must be self or cloud" }, 422);
|
|
1283
|
+
const taskWorkspaceId = await workspaceOfProject(env, projectId);
|
|
1284
|
+
if (executionMode === "cloud") {
|
|
1285
|
+
// Coding-agent tasks need the customer's own agent; only discovery runs in the cloud.
|
|
1286
|
+
if (taskType !== "discovery") return response({ error: "Only discovery can run on Bugmole Cloud" }, 422);
|
|
1287
|
+
if (!taskWorkspaceId) return response({ error: "This project isn't in a workspace" }, 422);
|
|
1288
|
+
const storageProblem = await cloudStorageError(env, projectId);
|
|
1289
|
+
if (storageProblem) return storageProblem;
|
|
1290
|
+
const accountProblem = await cloudAccountError(env, principal, taskWorkspaceId);
|
|
1291
|
+
if (accountProblem) return accountProblem;
|
|
1292
|
+
const blocked = await checkCloudRun(env, taskWorkspaceId);
|
|
1293
|
+
if (blocked) return blocked;
|
|
1294
|
+
const discoveryUrl = extractDiscoveryUrl(description);
|
|
1295
|
+
if (discoveryUrl) {
|
|
1296
|
+
const domainProblem = await requireVerifiedDomainForCloud(env, taskWorkspaceId, discoveryUrl);
|
|
1297
|
+
if (domainProblem) return domainProblem;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const queued = (taskId: string) => {
|
|
1301
|
+
for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId, workspaceId: taskWorkspaceId, executionMode }));
|
|
1302
|
+
};
|
|
1303
|
+
const existing = await env.DB.prepare(`${taskSelect} WHERE project_id = ? AND idempotency_key = ?`)
|
|
1304
|
+
.bind(projectId, idempotencyKey).first<{ id: string; status: string }>();
|
|
1305
|
+
if (existing && !["completed", "error"].includes(existing.status)) return response({ task: existing }, 200);
|
|
1306
|
+
if (existing) {
|
|
1307
|
+
// The UNIQUE(project_id, idempotency_key) constraint means a finished
|
|
1308
|
+
// (completed/error, including a manually cancelled) task can't simply
|
|
1309
|
+
// be superseded by a new INSERT under the same key — without this, a
|
|
1310
|
+
// cancelled task would keep being returned as "the" task forever,
|
|
1311
|
+
// making retry impossible. Recycle the row into a fresh attempt instead.
|
|
1312
|
+
await env.DB.prepare(`
|
|
1313
|
+
UPDATE agent_tasks SET title = ?, description = ?, task_type = ?, priority = ?, status = 'queued',
|
|
1314
|
+
worker_id = NULL, lease_expires_at = NULL, heartbeat_at = NULL, progress_message = NULL,
|
|
1315
|
+
continuation_json = NULL, completion_summary = NULL, changed_files_json = NULL, error_message = NULL,
|
|
1316
|
+
journey_id = ?, journey_revision = ?, device_session_id = ?, step_id = ?,
|
|
1317
|
+
attempts = 0, next_attempt_at = NULL,
|
|
1318
|
+
execution_mode = ?, workspace_id = ?, claimed_at = NULL, billed_at = NULL, billed_minutes = NULL,
|
|
1319
|
+
blocked_reason = NULL, dispatch_attempts = 0, next_dispatch_at = NULL,
|
|
1320
|
+
requested_at = CURRENT_TIMESTAMP, started_at = NULL, completed_at = NULL
|
|
1321
|
+
WHERE id = ?
|
|
1322
|
+
`).bind(
|
|
1323
|
+
title, description, taskType,
|
|
1324
|
+
Number.isFinite(Number(body?.priority)) ? Number(body?.priority) : 0,
|
|
1325
|
+
body?.journeyId ?? null, Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
|
|
1326
|
+
typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
|
|
1327
|
+
typeof body?.stepId === "string" ? body.stepId : null,
|
|
1328
|
+
executionMode, taskWorkspaceId,
|
|
1329
|
+
existing.id,
|
|
1330
|
+
).run();
|
|
1331
|
+
await broadcastProjectEvent(env, projectId, { type: "task_event", taskId: existing.id, projectId, status: "queued" });
|
|
1332
|
+
queued(existing.id);
|
|
1333
|
+
return response({ task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(existing.id).first() }, 200);
|
|
1334
|
+
}
|
|
1335
|
+
const project = await env.DB.prepare("SELECT tenant_id AS tenantId FROM projects WHERE id = ?")
|
|
1336
|
+
.bind(projectId).first<{ tenantId: string }>();
|
|
1337
|
+
const taskId = id("task");
|
|
1338
|
+
await env.DB.prepare(`
|
|
1339
|
+
INSERT INTO agent_tasks
|
|
1340
|
+
(id, project_id, tenant_id, title, description, task_type, priority, requested_by, idempotency_key, journey_id, journey_revision, device_session_id, step_id, execution_mode, workspace_id)
|
|
1341
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1342
|
+
`).bind(
|
|
1343
|
+
taskId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, title, description, taskType,
|
|
1344
|
+
Number.isFinite(Number(body?.priority)) ? Number(body?.priority) : 0, principal.aegisId, idempotencyKey,
|
|
1345
|
+
body?.journeyId ?? null, Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
|
|
1346
|
+
typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
|
|
1347
|
+
typeof body?.stepId === "string" ? body.stepId : null,
|
|
1348
|
+
executionMode, taskWorkspaceId,
|
|
1349
|
+
).run();
|
|
1350
|
+
await broadcastProjectEvent(env, projectId, { type: "task_event", taskId, projectId, status: "queued" });
|
|
1351
|
+
queued(taskId);
|
|
1352
|
+
const task = await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first();
|
|
1353
|
+
return response({ task }, 201);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
async function claimAgentTask(request: Request, env: Env, principal: Principal, taskId: string): Promise<Response> {
|
|
1357
|
+
if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task claim is not allowed" }, 403);
|
|
1358
|
+
const body = await requestJson(request);
|
|
1359
|
+
const workerId = typeof body?.workerId === "string" ? body.workerId.trim() : "";
|
|
1360
|
+
if (!workerId) return response({ error: "workerId is required" }, 422);
|
|
1361
|
+
const task = await env.DB.prepare("SELECT project_id AS projectId, execution_mode AS executionMode FROM agent_tasks WHERE id = ?")
|
|
1362
|
+
.bind(taskId).first<{ projectId: string; executionMode: string }>();
|
|
1363
|
+
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1364
|
+
if (task.executionMode === "cloud") return response({ error: "This task is queued for Bugmole Cloud" }, 409);
|
|
1365
|
+
const lease = new Date(Date.now() + 60_000).toISOString();
|
|
1366
|
+
const deviceSessionId = typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null;
|
|
1367
|
+
// Claiming is what counts as an attempt: it's the only point both the
|
|
1368
|
+
// worker and an agent-driven claim pass through, so the cap can't be
|
|
1369
|
+
// sidestepped by whichever path picks the task up.
|
|
1370
|
+
const claimed = await env.DB.prepare(`
|
|
1371
|
+
UPDATE agent_tasks SET status = 'running', worker_id = ?, lease_expires_at = ?,
|
|
1372
|
+
heartbeat_at = CURRENT_TIMESTAMP, started_at = COALESCE(started_at, CURRENT_TIMESTAMP),
|
|
1373
|
+
device_session_id = COALESCE(device_session_id, ?),
|
|
1374
|
+
attempts = attempts + 1
|
|
1375
|
+
WHERE id = ? AND attempts < ${MAX_TASK_ATTEMPTS}
|
|
1376
|
+
AND (next_attempt_at IS NULL OR next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
1377
|
+
AND (status = 'queued' OR status = 'blocked' OR (status = 'running' AND lease_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))
|
|
1378
|
+
`).bind(workerId, lease, deviceSessionId, taskId).run() as { meta?: { changes?: number } };
|
|
1379
|
+
if (claimed.meta?.changes !== 1) {
|
|
1380
|
+
return response({ error: "Task is already claimed, complete, or has exhausted its attempts" }, 409);
|
|
1381
|
+
}
|
|
1382
|
+
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "running", workerId });
|
|
1383
|
+
return response({ claimed: true, leaseExpiresAt: lease, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
async function updateAgentTask(request: Request, env: Env, principal: Principal, taskId: string, ctx: ExecutionContext): Promise<Response> {
|
|
1387
|
+
if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task update is not allowed" }, 403);
|
|
1388
|
+
const body = await requestJson(request);
|
|
1389
|
+
const task = await env.DB.prepare(`
|
|
1390
|
+
SELECT project_id AS projectId, worker_id AS workerId, task_type AS taskType, attempts, status,
|
|
1391
|
+
execution_mode AS executionMode, workspace_id AS workspaceId, description
|
|
1392
|
+
FROM agent_tasks WHERE id = ?
|
|
1393
|
+
`).bind(taskId).first<{ projectId: string; workerId: string | null; taskType: string; attempts: number; status: string; executionMode: string; workspaceId: string | null; description: string | null }>();
|
|
1394
|
+
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1395
|
+
if (task.workerId && body?.workerId !== task.workerId) return response({ error: "Worker lease does not match" }, 409);
|
|
1396
|
+
const status = typeof body?.status === "string" ? body.status : "running";
|
|
1397
|
+
if (!["running", "blocked", "completed", "error"].includes(status)) return response({ error: "Invalid task status" }, 422);
|
|
1398
|
+
if (status === "completed" && task.taskType === "ui" && (!body?.parityAuditId || typeof body?.parityMatchScore !== "number")) {
|
|
1399
|
+
return response({ error: "UI tasks require parityAuditId and parityMatchScore before completion" }, 422);
|
|
1400
|
+
}
|
|
1401
|
+
// Falling back to blocked schedules the next attempt rather than making
|
|
1402
|
+
// the task immediately eligible again on the worker's next 5s poll.
|
|
1403
|
+
const nextAttemptAt = status === "blocked" ? backoffUntil(task.attempts ?? 0) : null;
|
|
1404
|
+
await env.DB.prepare(`
|
|
1405
|
+
UPDATE agent_tasks SET status = ?, heartbeat_at = CURRENT_TIMESTAMP, lease_expires_at = ?,
|
|
1406
|
+
progress_message = ?, continuation_json = ?, parity_match_score = ?, parity_audit_id = ?,
|
|
1407
|
+
completion_summary = ?, changed_files_json = ?, error_message = ?,
|
|
1408
|
+
next_attempt_at = ?,
|
|
1409
|
+
completed_at = CASE WHEN ? IN ('completed', 'error') THEN CURRENT_TIMESTAMP ELSE completed_at END
|
|
1410
|
+
WHERE id = ?
|
|
1411
|
+
`).bind(
|
|
1412
|
+
status, new Date(Date.now() + 60_000).toISOString(),
|
|
1413
|
+
typeof body?.progressMessage === "string" ? body.progressMessage : null,
|
|
1414
|
+
body?.continuation ? JSON.stringify(body.continuation) : null,
|
|
1415
|
+
typeof body?.parityMatchScore === "number" ? body.parityMatchScore : null,
|
|
1416
|
+
typeof body?.parityAuditId === "string" ? body.parityAuditId : null,
|
|
1417
|
+
typeof body?.completionSummary === "string" ? body.completionSummary : null,
|
|
1418
|
+
Array.isArray(body?.changedFiles) ? JSON.stringify(body.changedFiles) : null,
|
|
1419
|
+
typeof body?.errorMessage === "string" ? body.errorMessage : null,
|
|
1420
|
+
nextAttemptAt, status, taskId,
|
|
1421
|
+
).run();
|
|
1422
|
+
if (status === "completed" && task.taskType === "discovery") {
|
|
1423
|
+
await fillDiscoveredEnvironmentUrl(env, task.projectId, task.description);
|
|
1424
|
+
}
|
|
1425
|
+
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status, ...body });
|
|
1426
|
+
const terminal = status === "completed" || status === "error";
|
|
1427
|
+
if (status !== task.status) {
|
|
1428
|
+
const event = {
|
|
1429
|
+
taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode,
|
|
1430
|
+
status, previousStatus: task.status, terminal,
|
|
1431
|
+
};
|
|
1432
|
+
for (const hook of taskChangedHooks) ctx.waitUntil(hook(env, event));
|
|
1433
|
+
}
|
|
1434
|
+
let stop: { reason: string; message: string } | null = null;
|
|
1435
|
+
if (!terminal) {
|
|
1436
|
+
for (const check of heartbeatChecks) {
|
|
1437
|
+
stop = await check(env, { kind: "task", id: taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode });
|
|
1438
|
+
if (stop) break;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return response({
|
|
1442
|
+
updated: true,
|
|
1443
|
+
task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first(),
|
|
1444
|
+
...(stop ? { stop: true, reason: stop.reason, message: stop.message } : {}),
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// The counterpart to cancelling: a human answering what a parked task asked
|
|
1449
|
+
// for and putting it back in the queue. Same trust boundary as cancel (no
|
|
1450
|
+
// worker-lease check — the worker that gave up is exactly who we are
|
|
1451
|
+
// overriding), and the attempt cap is deliberately not consulted, since
|
|
1452
|
+
// being over it is the normal reason to be here.
|
|
1453
|
+
async function resumeAgentTask(request: Request, env: Env, principal: Principal, taskId: string): Promise<Response> {
|
|
1454
|
+
if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task resume is not allowed" }, 403);
|
|
1455
|
+
const task = await env.DB.prepare("SELECT project_id AS projectId, status, lease_expires_at AS leaseExpiresAt FROM agent_tasks WHERE id = ?")
|
|
1456
|
+
.bind(taskId).first<{ projectId: string; status: string; leaseExpiresAt: string | null }>();
|
|
1457
|
+
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1458
|
+
if (!isResumableTask(task)) {
|
|
1459
|
+
return response({ error: task.status === "completed" ? "Task is already finished" : "Task is still held by a live worker" }, 409);
|
|
1460
|
+
}
|
|
1461
|
+
const body = await requestJson(request);
|
|
1462
|
+
const note = typeof body?.note === "string" && body.note.trim() ? body.note.trim() : "Resumed by user";
|
|
1463
|
+
const fields = resumedTaskFields();
|
|
1464
|
+
await env.DB.prepare(`
|
|
1465
|
+
UPDATE agent_tasks SET status = ?, attempts = ?, next_attempt_at = NULL, lease_expires_at = NULL,
|
|
1466
|
+
worker_id = NULL, error_message = NULL, completed_at = NULL,
|
|
1467
|
+
progress_message = ?, heartbeat_at = CURRENT_TIMESTAMP
|
|
1468
|
+
WHERE id = ?
|
|
1469
|
+
`).bind(fields.status, fields.attempts, note, taskId).run();
|
|
1470
|
+
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: fields.status, progressMessage: note });
|
|
1471
|
+
return response({ resumed: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// A human closing a task from the dashboard is a different trust boundary
|
|
1475
|
+
// than a worker self-reporting its own status — updateAgentTask's worker-
|
|
1476
|
+
// lease check (`body.workerId` must match `worker_id`) deliberately doesn't
|
|
1477
|
+
// apply here, since the whole point is overriding a task that's stuck
|
|
1478
|
+
// (claimed by a worker that died, or never should have been claimed).
|
|
1479
|
+
async function cancelAgentTask(request: Request, env: Env, principal: Principal, taskId: string): Promise<Response> {
|
|
1480
|
+
if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task cancellation is not allowed" }, 403);
|
|
1481
|
+
const task = await env.DB.prepare("SELECT project_id AS projectId, status FROM agent_tasks WHERE id = ?")
|
|
1482
|
+
.bind(taskId).first<{ projectId: string; status: string }>();
|
|
1483
|
+
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1484
|
+
if (["completed", "error"].includes(task.status)) return response({ error: "Task is already finished" }, 409);
|
|
1485
|
+
const body = await requestJson(request);
|
|
1486
|
+
const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : "Cancelled by user";
|
|
1487
|
+
await env.DB.prepare(`
|
|
1488
|
+
UPDATE agent_tasks SET status = 'error', error_message = ?, completion_summary = 'Cancelled',
|
|
1489
|
+
heartbeat_at = CURRENT_TIMESTAMP, completed_at = CURRENT_TIMESTAMP
|
|
1490
|
+
WHERE id = ?
|
|
1491
|
+
`).bind(reason, taskId).run();
|
|
1492
|
+
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "error", errorMessage: reason });
|
|
1493
|
+
return response({ cancelled: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// device_sessions is append-oriented historical infrastructure data, not a
|
|
1497
|
+
// cache of current Realm state — rows are retained even after the underlying
|
|
1498
|
+
// Realm resource is destroyed upstream. It carries no project reference:
|
|
1499
|
+
// project attribution is derived transitively through agent_tasks below,
|
|
1500
|
+
// since a session can legitimately be used by tasks from more than one
|
|
1501
|
+
// project (hence `projects: [{id, taskCount}]`, never a singular projectId).
|
|
1502
|
+
//
|
|
1503
|
+
// Tenant isolation happens once, at the apps/qa edge (dashboardIdentity()'s
|
|
1504
|
+
// tenantIds checked against REGISTRY_REALM_OWNER_TENANT_ID before this route
|
|
1505
|
+
// is ever called) — these routes don't re-check tenant membership themselves,
|
|
1506
|
+
// they trust the authenticated service-to-service caller like every other
|
|
1507
|
+
// apps/qa -> registry-worker proxy already does, and just tag/filter rows by
|
|
1508
|
+
// the fixed owner-tenant configuration value.
|
|
1509
|
+
type DeviceSessionRow = {
|
|
1510
|
+
id: string;
|
|
1511
|
+
deviceId: string;
|
|
1512
|
+
provider: string;
|
|
1513
|
+
deviceType: string;
|
|
1514
|
+
nativeSessionId: string;
|
|
1515
|
+
state: string;
|
|
1516
|
+
startedAt: string;
|
|
1517
|
+
stoppedAt: string | null;
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
async function listDeviceSessions(env: Env, principal: Principal): Promise<Response> {
|
|
1521
|
+
if (!can(principal, "*") && !can(principal, "tasks:read")) return response({ error: "Not allowed" }, 403);
|
|
1522
|
+
const ownerTenantId = env.REGISTRY_REALM_OWNER_TENANT_ID;
|
|
1523
|
+
if (!ownerTenantId) return response({ sessions: [] });
|
|
1524
|
+
const sessions = await env.DB.prepare(`
|
|
1525
|
+
SELECT id, device_id AS deviceId, provider, device_type AS deviceType, native_session_id AS nativeSessionId,
|
|
1526
|
+
state, started_at AS startedAt, stopped_at AS stoppedAt
|
|
1527
|
+
FROM device_sessions WHERE tenant_id = ? ORDER BY started_at DESC LIMIT 200
|
|
1528
|
+
`).bind(ownerTenantId).all<DeviceSessionRow>();
|
|
1529
|
+
const rows = sessions.results;
|
|
1530
|
+
const sessionIds = rows.map((row) => row.id);
|
|
1531
|
+
const tasksBySession = new Map<string, Array<{ id: string; title: string; projectId: string }>>();
|
|
1532
|
+
if (sessionIds.length > 0) {
|
|
1533
|
+
const placeholders = sessionIds.map(() => "?").join(",");
|
|
1534
|
+
const taskRows = await env.DB.prepare(`
|
|
1535
|
+
SELECT id, title, project_id AS projectId, device_session_id AS deviceSessionId
|
|
1536
|
+
FROM agent_tasks WHERE device_session_id IN (${placeholders})
|
|
1537
|
+
`).bind(...sessionIds).all<{ id: string; title: string; projectId: string; deviceSessionId: string }>();
|
|
1538
|
+
for (const task of taskRows.results) {
|
|
1539
|
+
const list = tasksBySession.get(task.deviceSessionId) ?? [];
|
|
1540
|
+
list.push({ id: task.id, title: task.title, projectId: task.projectId });
|
|
1541
|
+
tasksBySession.set(task.deviceSessionId, list);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
const result = rows.map((row) => {
|
|
1545
|
+
const tasks = tasksBySession.get(row.id) ?? [];
|
|
1546
|
+
const projectCounts = new Map<string, number>();
|
|
1547
|
+
for (const task of tasks) projectCounts.set(task.projectId, (projectCounts.get(task.projectId) ?? 0) + 1);
|
|
1548
|
+
return {
|
|
1549
|
+
id: row.id, deviceId: row.deviceId, provider: row.provider, deviceType: row.deviceType,
|
|
1550
|
+
nativeSessionId: row.nativeSessionId, state: row.state, startedAt: row.startedAt, stoppedAt: row.stoppedAt,
|
|
1551
|
+
tasks: tasks.map(({ id: taskId, title }) => ({ id: taskId, title })),
|
|
1552
|
+
projects: [...projectCounts.entries()].map(([projectId, taskCount]) => ({ id: projectId, taskCount })),
|
|
1553
|
+
};
|
|
1554
|
+
});
|
|
1555
|
+
return response({ sessions: result });
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Trusted internal route: reachable only via apps/qa's server-side proxy
|
|
1559
|
+
// (AEGIS_CLIENT_SECRET-authenticated), never directly by a dashboard user.
|
|
1560
|
+
// Idempotent at the whole-request level by construction — upsert on the
|
|
1561
|
+
// (provider, native_session_id) unique key plus set-stopped_at-only-from-NULL
|
|
1562
|
+
// means calling it once or a hundred times with the same observed state (or
|
|
1563
|
+
// concurrently, from multiple tabs) produces identical DB state. Absence of a
|
|
1564
|
+
// session from the observed list is never treated as evidence it stopped —
|
|
1565
|
+
// only sessions actually present in this request's payload are touched.
|
|
1566
|
+
async function reconcileDeviceSessions(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
1567
|
+
if (!can(principal, "*")) return response({ error: "Not allowed" }, 403);
|
|
1568
|
+
const ownerTenantId = env.REGISTRY_REALM_OWNER_TENANT_ID;
|
|
1569
|
+
if (!ownerTenantId) return response({ error: "REGISTRY_REALM_OWNER_TENANT_ID is not configured" }, 500);
|
|
1570
|
+
const body = await requestJson(request) as {
|
|
1571
|
+
devices?: Array<{
|
|
1572
|
+
id?: string; provider?: string; deviceType?: string;
|
|
1573
|
+
session?: { id?: string; state?: string; startedAt?: string };
|
|
1574
|
+
}>;
|
|
1575
|
+
} | null;
|
|
1576
|
+
const devices = Array.isArray(body?.devices) ? body.devices : [];
|
|
1577
|
+
let reconciled = 0;
|
|
1578
|
+
for (const device of devices) {
|
|
1579
|
+
if (!device.session?.id || !device.id || !device.provider || !device.deviceType || !device.session.state || !device.session.startedAt) continue;
|
|
1580
|
+
await env.DB.prepare(`
|
|
1581
|
+
INSERT INTO device_sessions (id, device_id, provider, device_type, native_session_id, tenant_id, state, started_at)
|
|
1582
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1583
|
+
ON CONFLICT(provider, native_session_id) DO UPDATE SET
|
|
1584
|
+
state = CASE WHEN device_sessions.state IN ('stopped', 'failed') THEN device_sessions.state ELSE excluded.state END,
|
|
1585
|
+
stopped_at = CASE WHEN device_sessions.stopped_at IS NULL AND excluded.state IN ('stopped', 'failed') THEN CURRENT_TIMESTAMP ELSE device_sessions.stopped_at END,
|
|
1586
|
+
updated_at = CURRENT_TIMESTAMP
|
|
1587
|
+
`).bind(
|
|
1588
|
+
id("dsess"), device.id, device.provider, device.deviceType, device.session.id,
|
|
1589
|
+
ownerTenantId, device.session.state, device.session.startedAt,
|
|
1590
|
+
).run();
|
|
1591
|
+
reconciled += 1;
|
|
1592
|
+
}
|
|
1593
|
+
return response({ reconciled });
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
async function listJourneyRevisions(env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
1597
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1598
|
+
const rows = await env.DB.prepare(`
|
|
1599
|
+
SELECT id, project_id AS projectId, journey_id AS journeyId, revision, object_key AS objectKey,
|
|
1600
|
+
app_id AS appId, environment_id AS environmentId, status,
|
|
1601
|
+
source_of_truth AS sourceOfTruth, stability, linked_to_graph AS linkedToGraph,
|
|
1602
|
+
verification_run_id AS verificationRunId, created_by AS createdBy,
|
|
1603
|
+
created_at AS createdAt
|
|
1604
|
+
FROM journey_revisions WHERE project_id = ? ORDER BY journey_id, revision DESC
|
|
1605
|
+
`).bind(projectId).all();
|
|
1606
|
+
return response({ revisions: rows.results });
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
async function saveJourneyRevision(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
1610
|
+
if (!can(principal, "projects:write") && !can(principal, "tasks:write") && !can(principal, "*")) {
|
|
1611
|
+
return response({ error: "Journey editing is not allowed" }, 403);
|
|
1612
|
+
}
|
|
1613
|
+
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1614
|
+
const body = await requestJson(request) as {
|
|
1615
|
+
journeyId?: string; objectKey?: string; expectedRevision?: number; appId?: string; environmentId?: string; status?: string;
|
|
1616
|
+
sourceOfTruth?: string; stability?: string; linkedToGraph?: boolean; verificationRunId?: string;
|
|
1617
|
+
} | null;
|
|
1618
|
+
if (!body) return response({ error: "Request body is required" }, 422);
|
|
1619
|
+
const journeyId = body?.journeyId?.trim() ?? "";
|
|
1620
|
+
const objectKey = body?.objectKey?.trim() ?? "";
|
|
1621
|
+
if (!journeyId || !objectKey) return response({ error: "journeyId and objectKey are required" }, 422);
|
|
1622
|
+
const latest = await env.DB.prepare(`
|
|
1623
|
+
SELECT revision FROM journey_revisions WHERE project_id = ? AND journey_id = ?
|
|
1624
|
+
ORDER BY revision DESC LIMIT 1
|
|
1625
|
+
`).bind(projectId, journeyId).first<{ revision: number }>();
|
|
1626
|
+
const currentRevision = latest?.revision ?? 0;
|
|
1627
|
+
if (body?.expectedRevision !== undefined && body.expectedRevision !== currentRevision) {
|
|
1628
|
+
return response({ error: "Journey revision conflict", currentRevision }, 409);
|
|
1629
|
+
}
|
|
1630
|
+
const project = await env.DB.prepare("SELECT tenant_id AS tenantId FROM projects WHERE id = ?")
|
|
1631
|
+
.bind(projectId).first<{ tenantId: string }>();
|
|
1632
|
+
const revision = currentRevision + 1;
|
|
1633
|
+
const idValue = id("journey");
|
|
1634
|
+
await env.DB.prepare(`
|
|
1635
|
+
INSERT INTO journey_revisions
|
|
1636
|
+
(id, project_id, tenant_id, journey_id, revision, object_key, app_id, environment_id, status,
|
|
1637
|
+
source_of_truth, stability, linked_to_graph, verification_run_id, created_by)
|
|
1638
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1639
|
+
`).bind(
|
|
1640
|
+
idValue, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, journeyId, revision,
|
|
1641
|
+
objectKey, body.appId ?? null, body.environmentId ?? null, body.status === "published" ? "published" : "draft",
|
|
1642
|
+
body.sourceOfTruth === "canonical_flow" ? "canonical_flow" : "canonical_flow",
|
|
1643
|
+
body.stability === "stable" ? "stable" : body.stability === "stabilizing" ? "stabilizing" : "discovered",
|
|
1644
|
+
body.linkedToGraph === true ? 1 : 0,
|
|
1645
|
+
body.verificationRunId ?? null,
|
|
1646
|
+
principal.aegisId,
|
|
1647
|
+
).run();
|
|
1648
|
+
await broadcastProjectEvent(env, projectId, { type: "journey_event", journeyId, projectId, revision, status: body.status ?? "draft" });
|
|
1649
|
+
return response({
|
|
1650
|
+
revision: {
|
|
1651
|
+
id: idValue, projectId, journeyId, revision, objectKey, appId: body.appId ?? null,
|
|
1652
|
+
environmentId: body.environmentId ?? null, status: body.status === "published" ? "published" : "draft",
|
|
1653
|
+
sourceOfTruth: "canonical_flow",
|
|
1654
|
+
stability: body.stability ?? "discovered",
|
|
1655
|
+
linkedToGraph: body.linkedToGraph === true,
|
|
1656
|
+
verificationRunId: body.verificationRunId ?? null,
|
|
1657
|
+
},
|
|
1658
|
+
}, 201);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
async function resetProject(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
1662
|
+
if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Project reset is not allowed" }, 403);
|
|
1663
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1664
|
+
const body = await requestJson(request);
|
|
1665
|
+
if (body?.confirmation !== `RESET ${projectId}`) return response({ error: "Explicit reset confirmation is required" }, 422);
|
|
1666
|
+
await env.DB.batch([
|
|
1667
|
+
env.DB.prepare("DELETE FROM plan_drafts WHERE project_id = ?").bind(projectId),
|
|
1668
|
+
env.DB.prepare("DELETE FROM runs WHERE project_id = ?").bind(projectId),
|
|
1669
|
+
env.DB.prepare("DELETE FROM agent_tasks WHERE project_id = ?").bind(projectId),
|
|
1670
|
+
env.DB.prepare("DELETE FROM journey_revisions WHERE project_id = ?").bind(projectId),
|
|
1671
|
+
]);
|
|
1672
|
+
await auditProject(env, principal, projectId, { action: "project.reset", targetType: "project", targetId: projectId }, request);
|
|
1673
|
+
return response({ reset: true, projectId });
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
async function upsertGithubInstallation(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
1677
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Not allowed" }, 403);
|
|
1678
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1679
|
+
const body = await requestJson(request);
|
|
1680
|
+
const installationId = Number(body?.installationId);
|
|
1681
|
+
const accountLogin = typeof body?.accountLogin === "string" ? body.accountLogin.trim() : "";
|
|
1682
|
+
const accountType = typeof body?.accountType === "string" ? body.accountType.trim() : "";
|
|
1683
|
+
const repositorySelection = typeof body?.repositorySelection === "string" ? body.repositorySelection.trim() : "";
|
|
1684
|
+
if (!Number.isFinite(installationId) || !accountLogin || !accountType || !repositorySelection) {
|
|
1685
|
+
return response({ error: "installationId, accountLogin, accountType, and repositorySelection are required" }, 422);
|
|
1686
|
+
}
|
|
1687
|
+
const repositories = Array.isArray(body?.repositories) ? body.repositories : [];
|
|
1688
|
+
const installationRowId = id("ghinst");
|
|
1689
|
+
await env.DB.prepare(`
|
|
1690
|
+
INSERT INTO github_installations
|
|
1691
|
+
(id, project_id, installation_id, account_login, account_type, repository_selection, repositories_json, connected_by)
|
|
1692
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1693
|
+
ON CONFLICT(project_id, installation_id) DO UPDATE SET
|
|
1694
|
+
account_login = excluded.account_login, account_type = excluded.account_type,
|
|
1695
|
+
repository_selection = excluded.repository_selection, repositories_json = excluded.repositories_json,
|
|
1696
|
+
revoked_at = NULL
|
|
1697
|
+
`).bind(
|
|
1698
|
+
installationRowId, projectId, installationId, accountLogin, accountType,
|
|
1699
|
+
repositorySelection, JSON.stringify(repositories), principal.aegisId,
|
|
1700
|
+
).run();
|
|
1701
|
+
const installation = await env.DB.prepare(`
|
|
1702
|
+
SELECT id, installation_id AS installationId, account_login AS accountLogin, account_type AS accountType,
|
|
1703
|
+
repository_selection AS repositorySelection, repositories_json AS repositoriesJson,
|
|
1704
|
+
connected_by AS connectedBy, connected_at AS connectedAt, revoked_at AS revokedAt
|
|
1705
|
+
FROM github_installations WHERE project_id = ? AND installation_id = ?
|
|
1706
|
+
`).bind(projectId, installationId).first();
|
|
1707
|
+
await auditProject(env, principal, projectId, { action: "integration.github_connected", targetType: "github_installation", targetId: String(installationId) }, request);
|
|
1708
|
+
return response({ installation }, 201);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
async function listGithubInstallations(env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
1712
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1713
|
+
const rows = await env.DB.prepare(`
|
|
1714
|
+
SELECT id, installation_id AS installationId, account_login AS accountLogin, account_type AS accountType,
|
|
1715
|
+
repository_selection AS repositorySelection, repositories_json AS repositoriesJson,
|
|
1716
|
+
connected_by AS connectedBy, connected_at AS connectedAt, revoked_at AS revokedAt
|
|
1717
|
+
FROM github_installations WHERE project_id = ? AND revoked_at IS NULL ORDER BY connected_at DESC
|
|
1718
|
+
`).bind(projectId).all();
|
|
1719
|
+
return response({ installations: rows.results });
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
async function getGithubInstallationsByInstallationId(env: Env, principal: Principal, installationId: string): Promise<Response> {
|
|
1723
|
+
if (!can(principal, "*")) return response({ error: "Not allowed" }, 403);
|
|
1724
|
+
const rows = await env.DB.prepare(`
|
|
1725
|
+
SELECT project_id AS projectId FROM github_installations
|
|
1726
|
+
WHERE installation_id = ? AND revoked_at IS NULL
|
|
1727
|
+
`).bind(Number(installationId)).all<{ projectId: string }>();
|
|
1728
|
+
return response({ installations: rows.results });
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
async function markGithubInstallationRevoked(env: Env, principal: Principal, installationId: string): Promise<Response> {
|
|
1732
|
+
if (!can(principal, "*")) return response({ error: "Not allowed" }, 403);
|
|
1733
|
+
await env.DB.prepare(`
|
|
1734
|
+
UPDATE github_installations SET revoked_at = CURRENT_TIMESTAMP
|
|
1735
|
+
WHERE installation_id = ? AND revoked_at IS NULL
|
|
1736
|
+
`).bind(Number(installationId)).run();
|
|
1737
|
+
return response({ revoked: true, installationId: Number(installationId) });
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
async function updateGithubInstallationRepositories(request: Request, env: Env, principal: Principal, installationId: string): Promise<Response> {
|
|
1741
|
+
if (!can(principal, "*")) return response({ error: "Not allowed" }, 403);
|
|
1742
|
+
const body = await requestJson(request);
|
|
1743
|
+
const repositories = Array.isArray(body?.repositories) ? body.repositories : [];
|
|
1744
|
+
await env.DB.prepare(`
|
|
1745
|
+
UPDATE github_installations SET repositories_json = ? WHERE installation_id = ? AND revoked_at IS NULL
|
|
1746
|
+
`).bind(JSON.stringify(repositories), Number(installationId)).run();
|
|
1747
|
+
return response({ updated: true, installationId: Number(installationId) });
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
async function createIssueTrackerLink(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
1751
|
+
if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Not allowed" }, 403);
|
|
1752
|
+
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1753
|
+
const body = await requestJson(request);
|
|
1754
|
+
const owner = typeof body?.owner === "string" ? body.owner.trim() : "";
|
|
1755
|
+
const repo = typeof body?.repo === "string" ? body.repo.trim() : "";
|
|
1756
|
+
const issueNumber = Number(body?.issueNumber);
|
|
1757
|
+
const url = typeof body?.url === "string" ? body.url.trim() : "";
|
|
1758
|
+
const title = typeof body?.title === "string" ? body.title.trim() : "";
|
|
1759
|
+
const state = typeof body?.state === "string" ? body.state.trim() : "unknown";
|
|
1760
|
+
const findingKind = typeof body?.findingKind === "string" ? body.findingKind.trim() : "";
|
|
1761
|
+
const findingKey = typeof body?.findingKey === "string" ? body.findingKey.trim() : "";
|
|
1762
|
+
if (!owner || !repo || !Number.isFinite(issueNumber) || !url || !title || !findingKind || !findingKey) {
|
|
1763
|
+
return response({ error: "owner, repo, issueNumber, url, title, findingKind, and findingKey are required" }, 422);
|
|
1764
|
+
}
|
|
1765
|
+
const existing = await env.DB.prepare(`
|
|
1766
|
+
SELECT id, project_id AS projectId, provider, installation_id AS installationId, owner, repo,
|
|
1767
|
+
issue_number AS issueNumber, issue_node_id AS issueNodeId, url, title, state,
|
|
1768
|
+
finding_kind AS findingKind, finding_key AS findingKey, created_by AS createdBy,
|
|
1769
|
+
created_at AS createdAt, updated_at AS updatedAt, last_sync_source AS lastSyncSource
|
|
1770
|
+
FROM issue_tracker_links WHERE project_id = ? AND provider = 'github' AND finding_kind = ? AND finding_key = ?
|
|
1771
|
+
`).bind(projectId, findingKind, findingKey).first();
|
|
1772
|
+
if (existing) return response({ error: "This finding is already linked", link: existing }, 409);
|
|
1773
|
+
const linkId = id("ghlink");
|
|
1774
|
+
await env.DB.prepare(`
|
|
1775
|
+
INSERT INTO issue_tracker_links
|
|
1776
|
+
(id, project_id, provider, installation_id, owner, repo, issue_number, issue_node_id, url, title, state,
|
|
1777
|
+
finding_kind, finding_key, created_by, last_sync_source)
|
|
1778
|
+
VALUES (?, ?, 'github', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'create')
|
|
1779
|
+
`).bind(
|
|
1780
|
+
linkId, projectId, Number.isFinite(Number(body?.installationId)) ? Number(body?.installationId) : null,
|
|
1781
|
+
owner, repo, issueNumber, typeof body?.nodeId === "string" ? body.nodeId : null, url, title, state,
|
|
1782
|
+
findingKind, findingKey, principal.aegisId,
|
|
1783
|
+
).run();
|
|
1784
|
+
const link = await env.DB.prepare(`
|
|
1785
|
+
SELECT id, project_id AS projectId, provider, installation_id AS installationId, owner, repo,
|
|
1786
|
+
issue_number AS issueNumber, issue_node_id AS issueNodeId, url, title, state,
|
|
1787
|
+
finding_kind AS findingKind, finding_key AS findingKey, created_by AS createdBy,
|
|
1788
|
+
created_at AS createdAt, updated_at AS updatedAt, last_sync_source AS lastSyncSource
|
|
1789
|
+
FROM issue_tracker_links WHERE id = ?
|
|
1790
|
+
`).bind(linkId).first();
|
|
1791
|
+
return response({ link }, 201);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
async function listIssueTrackerLinks(env: Env, principal: Principal, projectId: string, findingKey?: string): Promise<Response> {
|
|
1795
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1796
|
+
const rows = findingKey
|
|
1797
|
+
? await env.DB.prepare(`
|
|
1798
|
+
SELECT id, project_id AS projectId, provider, installation_id AS installationId, owner, repo,
|
|
1799
|
+
issue_number AS issueNumber, issue_node_id AS issueNodeId, url, title, state,
|
|
1800
|
+
finding_kind AS findingKind, finding_key AS findingKey, created_by AS createdBy,
|
|
1801
|
+
created_at AS createdAt, updated_at AS updatedAt, last_sync_source AS lastSyncSource
|
|
1802
|
+
FROM issue_tracker_links WHERE project_id = ? AND finding_key = ? ORDER BY updated_at DESC
|
|
1803
|
+
`).bind(projectId, findingKey).all()
|
|
1804
|
+
: await env.DB.prepare(`
|
|
1805
|
+
SELECT id, project_id AS projectId, provider, installation_id AS installationId, owner, repo,
|
|
1806
|
+
issue_number AS issueNumber, issue_node_id AS issueNodeId, url, title, state,
|
|
1807
|
+
finding_kind AS findingKind, finding_key AS findingKey, created_by AS createdBy,
|
|
1808
|
+
created_at AS createdAt, updated_at AS updatedAt, last_sync_source AS lastSyncSource
|
|
1809
|
+
FROM issue_tracker_links WHERE project_id = ? ORDER BY updated_at DESC
|
|
1810
|
+
`).bind(projectId).all();
|
|
1811
|
+
return response({ links: rows.results });
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
async function deleteIssueTrackerLink(env: Env, principal: Principal, projectId: string, linkId: string): Promise<Response> {
|
|
1815
|
+
if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Not allowed" }, 403);
|
|
1816
|
+
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1817
|
+
const result = await env.DB.prepare(`
|
|
1818
|
+
DELETE FROM issue_tracker_links WHERE id = ? AND project_id = ?
|
|
1819
|
+
`).bind(linkId, projectId).run() as { meta?: { changes?: number } };
|
|
1820
|
+
if (result.meta?.changes !== 1) return response({ error: "Link not found" }, 404);
|
|
1821
|
+
return response({ deleted: true, id: linkId });
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Service-only: called by the GitHub webhook handler, which knows only
|
|
1826
|
+
* owner/repo/issue_number (never our finding_key or project id). Updates
|
|
1827
|
+
* every matching link across every project in one idempotent SET — webhook
|
|
1828
|
+
* delivery isn't ordered and can be redelivered, so this must stay a plain
|
|
1829
|
+
* last-write-wins update rather than any increment/toggle logic.
|
|
1830
|
+
*/
|
|
1831
|
+
async function updateIssueTrackerLinksByExternalIssue(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
1832
|
+
if (!can(principal, "*")) return response({ error: "Not allowed" }, 403);
|
|
1833
|
+
const body = await requestJson(request);
|
|
1834
|
+
const owner = typeof body?.owner === "string" ? body.owner.trim() : "";
|
|
1835
|
+
const repo = typeof body?.repo === "string" ? body.repo.trim() : "";
|
|
1836
|
+
const issueNumber = Number(body?.issueNumber);
|
|
1837
|
+
const state = typeof body?.state === "string" ? body.state.trim() : "";
|
|
1838
|
+
const title = typeof body?.title === "string" ? body.title.trim() : "";
|
|
1839
|
+
if (!owner || !repo || !Number.isFinite(issueNumber) || !state) {
|
|
1840
|
+
return response({ error: "owner, repo, issueNumber, and state are required" }, 422);
|
|
1841
|
+
}
|
|
1842
|
+
const result = await env.DB.prepare(`
|
|
1843
|
+
UPDATE issue_tracker_links
|
|
1844
|
+
SET state = ?, title = COALESCE(NULLIF(?, ''), title), updated_at = CURRENT_TIMESTAMP, last_sync_source = 'webhook'
|
|
1845
|
+
WHERE provider = 'github' AND owner = ? AND repo = ? AND issue_number = ?
|
|
1846
|
+
`).bind(state, title, owner, repo, issueNumber).run() as { meta?: { changes?: number } };
|
|
1847
|
+
return response({ updated: result.meta?.changes ?? 0 });
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
async function rememberEmail(env: Env, aegisId: string, email: string, verified: boolean): Promise<void> {
|
|
1851
|
+
await env.DB.prepare(`
|
|
1852
|
+
INSERT INTO aegis_users (id, email, email_verified) VALUES (?, ?, ?)
|
|
1853
|
+
ON CONFLICT(id) DO UPDATE SET email = excluded.email, email_verified = excluded.email_verified
|
|
1854
|
+
WHERE aegis_users.email IS NOT excluded.email OR aegis_users.email_verified IS NOT excluded.email_verified
|
|
1855
|
+
`).bind(aegisId, email, verified ? 1 : 0).run();
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
runService.create = createRun;
|
|
1859
|
+
|
|
1860
|
+
export class RunsCoordinator {
|
|
1861
|
+
private readonly sockets = new Set<WebSocket>();
|
|
1862
|
+
|
|
1863
|
+
constructor(private readonly state: DurableObjectState) {}
|
|
1864
|
+
|
|
1865
|
+
async fetch(request: Request): Promise<Response> {
|
|
1866
|
+
if (request.method === "POST" && new URL(request.url).pathname === "/broadcast") {
|
|
1867
|
+
const event = await request.text();
|
|
1868
|
+
for (const socket of this.sockets) {
|
|
1869
|
+
try {
|
|
1870
|
+
socket.send(event);
|
|
1871
|
+
} catch {
|
|
1872
|
+
this.sockets.delete(socket);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
return new Response("ok");
|
|
1876
|
+
}
|
|
1877
|
+
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
|
1878
|
+
return new Response("WebSocket upgrade required", { status: 426 });
|
|
1879
|
+
}
|
|
1880
|
+
const pair = new WebSocketPair();
|
|
1881
|
+
const client = pair[0];
|
|
1882
|
+
const server = pair[1];
|
|
1883
|
+
server.accept();
|
|
1884
|
+
this.sockets.add(server);
|
|
1885
|
+
server.addEventListener("close", () => this.sockets.delete(server));
|
|
1886
|
+
server.addEventListener("error", () => this.sockets.delete(server));
|
|
1887
|
+
server.addEventListener("message", (event) => {
|
|
1888
|
+
if (typeof event.data === "string") {
|
|
1889
|
+
try {
|
|
1890
|
+
server.send(JSON.stringify({ type: "ack", message: JSON.parse(event.data) }));
|
|
1891
|
+
} catch {
|
|
1892
|
+
server.send(JSON.stringify({ type: "error", error: "Invalid message" }));
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
});
|
|
1896
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
const registryWorker = {
|
|
1901
|
+
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
1902
|
+
await runScheduledJobs(controller, env, ctx);
|
|
1903
|
+
},
|
|
1904
|
+
|
|
1905
|
+
async fetch(request: Request, env: Env, ctx?: ExecutionContext): Promise<Response> {
|
|
1906
|
+
const url = new URL(request.url);
|
|
1907
|
+
const context = ctx ?? detachedContext();
|
|
1908
|
+
if (request.method === "GET" && url.pathname === "/health") return response({ ok: true, service: "valkyrie-registry" });
|
|
1909
|
+
const publicResponse = await handlePublicRoutes(request, env, url, context);
|
|
1910
|
+
if (publicResponse) return publicResponse;
|
|
1911
|
+
if (request.method === "POST" && url.pathname === "/api/device-authorizations") {
|
|
1912
|
+
return createDeviceAuthorization(request, env);
|
|
1913
|
+
}
|
|
1914
|
+
if (request.method === "POST" && url.pathname === "/api/device-authorizations/poll") {
|
|
1915
|
+
return pollDeviceAuthorization(request, env);
|
|
1916
|
+
}
|
|
1917
|
+
const websocketMatch = url.pathname.match(/^\/api\/ws\/projects\/([^/]+)$/);
|
|
1918
|
+
const principal = websocketMatch
|
|
1919
|
+
? ((await apiPrincipal(request, env)) ?? (await subscriptionPrincipal(request, env)))
|
|
1920
|
+
: await apiPrincipal(request, env);
|
|
1921
|
+
if (!principal) return response({ error: "Authentication required" }, 401);
|
|
1922
|
+
if (principal.runner && !runnerMayCall(principal.runner, request.method, url)) {
|
|
1923
|
+
return response({ error: "Runner tokens can't call this route" }, 403);
|
|
1924
|
+
}
|
|
1925
|
+
if (principal.org) {
|
|
1926
|
+
// People the IdP removed stay out, even with a dashboard session left over.
|
|
1927
|
+
if (await isDeprovisioned(env, principal.aegisId)) {
|
|
1928
|
+
return response({ error: "Your access was removed by your organization", code: "account_deprovisioned" }, 401);
|
|
1929
|
+
}
|
|
1930
|
+
// Groups arrive with the sign-in; later changes come from SCIM events.
|
|
1931
|
+
if (principal.org.groups) {
|
|
1932
|
+
await applyOrgMembership(env, { aegisId: principal.aegisId, email: principal.email }, { id: principal.org.id, groups: principal.org.groups });
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
if (principal.email) context.waitUntil(rememberEmail(env, principal.aegisId, principal.email, principal.emailVerified === true));
|
|
1936
|
+
if (websocketMatch && request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
|
|
1937
|
+
if (!(await memberOfProject(env, principal, decodeURIComponent(websocketMatch[1])))) {
|
|
1938
|
+
return response({ error: "Project scope is not allowed" }, 403);
|
|
1939
|
+
}
|
|
1940
|
+
if (!env.RUNS_COORDINATOR) return response({ error: "WebSocket coordinator is unavailable" }, 503);
|
|
1941
|
+
const stub = env.RUNS_COORDINATOR.get(
|
|
1942
|
+
env.RUNS_COORDINATOR.idFromName(decodeURIComponent(websocketMatch[1])),
|
|
1943
|
+
);
|
|
1944
|
+
return stub.fetch(request);
|
|
1945
|
+
}
|
|
1946
|
+
if (request.method === "POST" && url.pathname === "/api/device-authorizations/claim") {
|
|
1947
|
+
return claimDeviceAuthorization(request, env, principal);
|
|
1948
|
+
}
|
|
1949
|
+
if (request.method === "GET" && url.pathname === "/api/projects") {
|
|
1950
|
+
return can(principal, "projects:read") ? listProjects(env, principal) : response({ error: "Not allowed" }, 403);
|
|
1951
|
+
}
|
|
1952
|
+
if (request.method === "GET" && url.pathname === "/api/workspaces") {
|
|
1953
|
+
return can(principal, "projects:read") ? listWorkspaces(env, principal) : response({ error: "Not allowed" }, 403);
|
|
1954
|
+
}
|
|
1955
|
+
if (request.method === "POST" && url.pathname === "/api/workspaces") return createWorkspace(request, env, principal);
|
|
1956
|
+
const workspaceMatch = url.pathname.match(/^\/api\/workspaces\/([^/]+)$/);
|
|
1957
|
+
if (workspaceMatch && request.method === "PATCH") {
|
|
1958
|
+
return updateWorkspace(request, env, principal, decodeURIComponent(workspaceMatch[1]));
|
|
1959
|
+
}
|
|
1960
|
+
if (request.method === "POST" && url.pathname === "/api/projects/register") return createManagedProject(request, env, principal);
|
|
1961
|
+
const projectEditMatch = url.pathname.match(/^\/api\/projects\/([^/]+)$/);
|
|
1962
|
+
if (projectEditMatch && request.method === "PATCH") {
|
|
1963
|
+
return updateProject(request, env, principal, decodeURIComponent(projectEditMatch[1]));
|
|
1964
|
+
}
|
|
1965
|
+
const appEditMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/apps\/([^/]+)$/);
|
|
1966
|
+
if (appEditMatch && request.method === "PATCH") {
|
|
1967
|
+
return updateApp(request, env, principal, decodeURIComponent(appEditMatch[1]), decodeURIComponent(appEditMatch[2]));
|
|
1968
|
+
}
|
|
1969
|
+
const appMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/apps$/);
|
|
1970
|
+
if (request.method === "POST" && appMatch) {
|
|
1971
|
+
return createApp(request, env, principal, decodeURIComponent(appMatch[1]));
|
|
1972
|
+
}
|
|
1973
|
+
const environmentMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/environments$/);
|
|
1974
|
+
if (request.method === "POST" && environmentMatch) {
|
|
1975
|
+
return createEnvironment(request, env, principal, decodeURIComponent(environmentMatch[1]));
|
|
1976
|
+
}
|
|
1977
|
+
const environmentEditMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/environments\/([^/]+)$/);
|
|
1978
|
+
if (environmentEditMatch && request.method === "PATCH") {
|
|
1979
|
+
return updateEnvironment(
|
|
1980
|
+
request,
|
|
1981
|
+
env,
|
|
1982
|
+
principal,
|
|
1983
|
+
decodeURIComponent(environmentEditMatch[1]),
|
|
1984
|
+
decodeURIComponent(environmentEditMatch[2]),
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
if (request.method === "GET" && url.pathname === "/api/api-keys") {
|
|
1988
|
+
return can(principal, "keys:read") || can(principal, "keys:write") || can(principal, "*")
|
|
1989
|
+
? listApiKeys(env, principal)
|
|
1990
|
+
: response({ error: "Not allowed" }, 403);
|
|
1991
|
+
}
|
|
1992
|
+
if (request.method === "POST" && url.pathname === "/api/api-keys") return createApiKey(request, env, principal);
|
|
1993
|
+
const revokeMatch = url.pathname.match(/^\/api\/api-keys\/([^/]+)$/);
|
|
1994
|
+
if (request.method === "DELETE" && revokeMatch) return revokeApiKey(request, env, principal, revokeMatch[1]);
|
|
1995
|
+
const plansMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/plans$/);
|
|
1996
|
+
if (plansMatch && request.method === "GET") {
|
|
1997
|
+
return listPlanDrafts(env, principal, decodeURIComponent(plansMatch[1]));
|
|
1998
|
+
}
|
|
1999
|
+
if (plansMatch && request.method === "POST") {
|
|
2000
|
+
return publishPlanDraft(request, env, principal, decodeURIComponent(plansMatch[1]));
|
|
2001
|
+
}
|
|
2002
|
+
const runsMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/runs$/);
|
|
2003
|
+
if (runsMatch && request.method === "GET") return listRuns(env, principal, decodeURIComponent(runsMatch[1]));
|
|
2004
|
+
if (runsMatch && request.method === "POST") return createRun(request, env, principal, decodeURIComponent(runsMatch[1]), context);
|
|
2005
|
+
const artifactsMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/artifacts(\/object)?$/);
|
|
2006
|
+
if (artifactsMatch && request.method === "GET") {
|
|
2007
|
+
const projectId = decodeURIComponent(artifactsMatch[1]);
|
|
2008
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
2009
|
+
return artifactsMatch[2]
|
|
2010
|
+
? getProjectArtifact(env.ARTIFACTS, projectId, url)
|
|
2011
|
+
: listProjectArtifacts(env.ARTIFACTS, projectId, url);
|
|
2012
|
+
}
|
|
2013
|
+
const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
|
|
2014
|
+
if (runMatch && request.method === "GET") return getRun(env, principal, decodeURIComponent(runMatch[1]));
|
|
2015
|
+
if (runMatch && request.method === "POST") return updateRun(request, env, principal, decodeURIComponent(runMatch[1]), context);
|
|
2016
|
+
const claimMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/claim$/);
|
|
2017
|
+
if (claimMatch && request.method === "POST") return claimRun(request, env, principal, decodeURIComponent(claimMatch[1]), context);
|
|
2018
|
+
const tasksMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/tasks$/);
|
|
2019
|
+
if (tasksMatch && request.method === "GET") return listAgentTasks(env, principal, decodeURIComponent(tasksMatch[1]));
|
|
2020
|
+
if (tasksMatch && request.method === "POST") return createAgentTask(request, env, principal, decodeURIComponent(tasksMatch[1]), context);
|
|
2021
|
+
const taskClaimMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/claim$/);
|
|
2022
|
+
if (taskClaimMatch && request.method === "POST") return claimAgentTask(request, env, principal, decodeURIComponent(taskClaimMatch[1]));
|
|
2023
|
+
const taskCancelMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
|
|
2024
|
+
if (taskCancelMatch && request.method === "POST") return cancelAgentTask(request, env, principal, decodeURIComponent(taskCancelMatch[1]));
|
|
2025
|
+
const taskResumeMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/resume$/);
|
|
2026
|
+
if (taskResumeMatch && request.method === "POST") return resumeAgentTask(request, env, principal, decodeURIComponent(taskResumeMatch[1]));
|
|
2027
|
+
const taskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
2028
|
+
if (taskMatch && request.method === "POST") return updateAgentTask(request, env, principal, decodeURIComponent(taskMatch[1]), context);
|
|
2029
|
+
if (request.method === "GET" && url.pathname === "/api/device-sessions") return listDeviceSessions(env, principal);
|
|
2030
|
+
if (request.method === "POST" && url.pathname === "/api/device-sessions/reconcile") return reconcileDeviceSessions(request, env, principal);
|
|
2031
|
+
const journeysMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/journeys$/);
|
|
2032
|
+
if (journeysMatch && request.method === "GET") return listJourneyRevisions(env, principal, decodeURIComponent(journeysMatch[1]));
|
|
2033
|
+
if (journeysMatch && request.method === "POST") return saveJourneyRevision(request, env, principal, decodeURIComponent(journeysMatch[1]));
|
|
2034
|
+
const resetMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/reset$/);
|
|
2035
|
+
if (resetMatch && request.method === "POST") return resetProject(request, env, principal, decodeURIComponent(resetMatch[1]));
|
|
2036
|
+
const githubInstallationsMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/github-installations$/);
|
|
2037
|
+
if (githubInstallationsMatch && request.method === "GET") {
|
|
2038
|
+
return listGithubInstallations(env, principal, decodeURIComponent(githubInstallationsMatch[1]));
|
|
2039
|
+
}
|
|
2040
|
+
if (githubInstallationsMatch && request.method === "POST") {
|
|
2041
|
+
return upsertGithubInstallation(request, env, principal, decodeURIComponent(githubInstallationsMatch[1]));
|
|
2042
|
+
}
|
|
2043
|
+
const githubInstallationByIdMatch = url.pathname.match(/^\/api\/github-installations\/by-installation-id\/([^/]+)$/);
|
|
2044
|
+
if (githubInstallationByIdMatch && request.method === "GET") {
|
|
2045
|
+
return getGithubInstallationsByInstallationId(env, principal, decodeURIComponent(githubInstallationByIdMatch[1]));
|
|
2046
|
+
}
|
|
2047
|
+
const githubInstallationRevokeMatch = url.pathname.match(/^\/api\/github-installations\/by-installation-id\/([^/]+)\/revoke$/);
|
|
2048
|
+
if (githubInstallationRevokeMatch && request.method === "POST") {
|
|
2049
|
+
return markGithubInstallationRevoked(env, principal, decodeURIComponent(githubInstallationRevokeMatch[1]));
|
|
2050
|
+
}
|
|
2051
|
+
const githubInstallationReposMatch = url.pathname.match(/^\/api\/github-installations\/by-installation-id\/([^/]+)\/repositories$/);
|
|
2052
|
+
if (githubInstallationReposMatch && request.method === "POST") {
|
|
2053
|
+
return updateGithubInstallationRepositories(request, env, principal, decodeURIComponent(githubInstallationReposMatch[1]));
|
|
2054
|
+
}
|
|
2055
|
+
const githubLinksMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/github-links$/);
|
|
2056
|
+
if (githubLinksMatch && request.method === "GET") {
|
|
2057
|
+
return listIssueTrackerLinks(env, principal, decodeURIComponent(githubLinksMatch[1]), url.searchParams.get("finding") ?? undefined);
|
|
2058
|
+
}
|
|
2059
|
+
if (githubLinksMatch && request.method === "POST") {
|
|
2060
|
+
return createIssueTrackerLink(request, env, principal, decodeURIComponent(githubLinksMatch[1]));
|
|
2061
|
+
}
|
|
2062
|
+
const githubLinkDeleteMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/github-links\/([^/]+)$/);
|
|
2063
|
+
if (githubLinkDeleteMatch && request.method === "DELETE") {
|
|
2064
|
+
return deleteIssueTrackerLink(env, principal, decodeURIComponent(githubLinkDeleteMatch[1]), decodeURIComponent(githubLinkDeleteMatch[2]));
|
|
2065
|
+
}
|
|
2066
|
+
if (request.method === "PATCH" && url.pathname === "/api/github-links/by-issue") {
|
|
2067
|
+
return updateIssueTrackerLinksByExternalIssue(request, env, principal);
|
|
2068
|
+
}
|
|
2069
|
+
const extended = await handleExtendedRoutes(request, env, principal, url, context);
|
|
2070
|
+
if (extended) return extended;
|
|
2071
|
+
return response({ error: "Not found" }, 404);
|
|
2072
|
+
},
|
|
2073
|
+
};
|
|
2074
|
+
|
|
2075
|
+
registryDispatch.fetch = (request, env, ctx) => registryWorker.fetch(request, env, ctx);
|
|
2076
|
+
|
|
2077
|
+
export default registryWorker;
|