@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,803 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local, loopback-only stand-in for the real Bugmole registry (the
|
|
3
|
+
* deployed Cloudflare Worker at BUGMOLE_REGISTRY_URL). It implements the
|
|
4
|
+
* same REST contract (`src/registry-worker/index.ts`'s plans/runs/tasks/
|
|
5
|
+
* journeys endpoints, as consumed by `ControlPlaneClient` and the
|
|
6
|
+
* dashboard's `apps/qa/src/app/api/**` proxy routes) but persists to a
|
|
7
|
+
* local JSON file per collection instead of the real D1 database, and
|
|
8
|
+
* accepts any caller without checking `x-aegis-id`/Bearer credentials.
|
|
9
|
+
*
|
|
10
|
+
* This exists so a project that has no real registered access (no
|
|
11
|
+
* BUGMOLE_API_KEY scoped to it, no real Aegis membership) can still
|
|
12
|
+
* exercise the full write path locally — queue a task, have `bugmole serve`'s
|
|
13
|
+
* worker claim and run it, publish a plan, start a run — without the real
|
|
14
|
+
* registry rejecting every write with "Project scope is not allowed" and
|
|
15
|
+
* without repeatedly clicking through the real Aegis login. It is never
|
|
16
|
+
* reachable from a browser directly (the dashboard's own Next.js server
|
|
17
|
+
* proxies to it, same as it would the real registry); binding to
|
|
18
|
+
* 127.0.0.1 only is the trust boundary, matching other local-only dev
|
|
19
|
+
* servers in this codebase (e.g. the local artifact bridge).
|
|
20
|
+
*
|
|
21
|
+
* Deliberately out of scope: workspaces, apps, environments, api-keys,
|
|
22
|
+
* device authorizations, and `GET /api/projects` (project listing stays
|
|
23
|
+
* "no real membership" so the dashboard's local-dev-bypass project
|
|
24
|
+
* synthesis — see `apps/qa/src/app/dashboard-route.tsx` — keeps doing
|
|
25
|
+
* exactly what it already does for reads).
|
|
26
|
+
*/
|
|
27
|
+
import http from "node:http";
|
|
28
|
+
import { randomBytes } from "node:crypto";
|
|
29
|
+
import { createObjectStore, type ObjectStoreConfig } from "../storage/create-object-store.js";
|
|
30
|
+
import type { ObjectStore } from "../storage/object-store.js";
|
|
31
|
+
import { normalizeTargets } from "./browser-matrix.js";
|
|
32
|
+
import { MAX_TASK_ATTEMPTS, backoffUntil, isResumableTask, resumedTaskFields } from "../registry/task-scheduling.js";
|
|
33
|
+
|
|
34
|
+
export type LocalRegistryStub = {
|
|
35
|
+
origin: string;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type LocalRegistryStubOptions = {
|
|
40
|
+
directory: string;
|
|
41
|
+
port?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type PlanDraft = {
|
|
45
|
+
id: string;
|
|
46
|
+
projectId: string;
|
|
47
|
+
planId: string;
|
|
48
|
+
journeyId: string;
|
|
49
|
+
objectKey: string;
|
|
50
|
+
status: "draft";
|
|
51
|
+
createdBy: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
updatedAt: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type Run = {
|
|
57
|
+
id: string;
|
|
58
|
+
projectId: string;
|
|
59
|
+
planId: string;
|
|
60
|
+
journeyId?: string;
|
|
61
|
+
journeyRevision?: number | null;
|
|
62
|
+
environmentId?: string | null;
|
|
63
|
+
environmentBaseUrl?: string | null;
|
|
64
|
+
targets?: Array<{ browser: string; device?: string }> | null;
|
|
65
|
+
targetResults?: unknown[] | null;
|
|
66
|
+
durationMs?: number | null;
|
|
67
|
+
fixFrom?: { runId: string; targetId: string } | null;
|
|
68
|
+
status: string;
|
|
69
|
+
workerId?: string | null;
|
|
70
|
+
currentStep?: string | null;
|
|
71
|
+
stepsExecuted: number;
|
|
72
|
+
stepsPassed: number;
|
|
73
|
+
stepsFailed: number;
|
|
74
|
+
driver?: string | null;
|
|
75
|
+
platform?: string | null;
|
|
76
|
+
recoveryAttempts?: number | null;
|
|
77
|
+
failureCode?: string | null;
|
|
78
|
+
artifactSummaryJson?: string | null;
|
|
79
|
+
errorCode?: string | null;
|
|
80
|
+
errorMessage?: string | null;
|
|
81
|
+
manifestKey?: string | null;
|
|
82
|
+
idempotencyKey: string;
|
|
83
|
+
requestedAt: string;
|
|
84
|
+
startedAt?: string | null;
|
|
85
|
+
completedAt?: string | null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type AgentTask = {
|
|
89
|
+
id: string;
|
|
90
|
+
projectId: string;
|
|
91
|
+
title: string;
|
|
92
|
+
description: string;
|
|
93
|
+
taskType: "code" | "ui" | "investigation" | "discovery";
|
|
94
|
+
priority: number;
|
|
95
|
+
status: string;
|
|
96
|
+
requestedBy: string;
|
|
97
|
+
workerId?: string | null;
|
|
98
|
+
progressMessage?: string | null;
|
|
99
|
+
continuation?: unknown;
|
|
100
|
+
parityMatchScore?: number | null;
|
|
101
|
+
parityAuditId?: string | null;
|
|
102
|
+
completionSummary?: string | null;
|
|
103
|
+
changedFiles?: unknown;
|
|
104
|
+
errorMessage?: string | null;
|
|
105
|
+
journeyId?: string | null;
|
|
106
|
+
journeyRevision?: number | null;
|
|
107
|
+
stepId?: string | null;
|
|
108
|
+
attempts?: number;
|
|
109
|
+
nextAttemptAt?: string | null;
|
|
110
|
+
leaseExpiresAt?: string | null;
|
|
111
|
+
idempotencyKey: string;
|
|
112
|
+
requestedAt: string;
|
|
113
|
+
startedAt?: string | null;
|
|
114
|
+
completedAt?: string | null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
type JourneyRevision = {
|
|
118
|
+
id: string;
|
|
119
|
+
projectId: string;
|
|
120
|
+
journeyId: string;
|
|
121
|
+
revision: number;
|
|
122
|
+
objectKey: string;
|
|
123
|
+
appId?: string | null;
|
|
124
|
+
environmentId?: string | null;
|
|
125
|
+
status: "draft" | "published";
|
|
126
|
+
sourceOfTruth: "canonical_flow";
|
|
127
|
+
stability: "discovered" | "stabilizing" | "stable";
|
|
128
|
+
linkedToGraph: boolean;
|
|
129
|
+
verificationRunId?: string | null;
|
|
130
|
+
createdBy: string;
|
|
131
|
+
createdAt: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type Persona = {
|
|
135
|
+
id: string;
|
|
136
|
+
projectId: string;
|
|
137
|
+
name: string;
|
|
138
|
+
description: string;
|
|
139
|
+
isDefault: boolean;
|
|
140
|
+
createdBy: string;
|
|
141
|
+
createdAt: string;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Same default text as src/registry-worker/personas/routes.ts's
|
|
146
|
+
* DEFAULT_PERSONA — duplicated, not imported: this stub and the registry
|
|
147
|
+
* worker are separately deployable units, so it can't reach across into
|
|
148
|
+
* registry-worker's own module graph.
|
|
149
|
+
*/
|
|
150
|
+
const DEFAULT_PERSONA = {
|
|
151
|
+
name: "Curious",
|
|
152
|
+
description:
|
|
153
|
+
"Explore like a curious first-time visitor. Click into anything unfamiliar, wander into secondary sections " +
|
|
154
|
+
"instead of only the shortest path to a goal, and open things out of order the way someone browsing for the " +
|
|
155
|
+
"first time would. Prefer journeys that reveal more of the app over the single fastest way through it.",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function genId(prefix: string): string {
|
|
159
|
+
return `${prefix}_${randomBytes(9).toString("base64url")}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function writeJson(response: http.ServerResponse, status: number, body: unknown): void {
|
|
163
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
164
|
+
response.end(JSON.stringify(body));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readJsonBody(request: http.IncomingMessage): Promise<Record<string, unknown> | null> {
|
|
168
|
+
const chunks: Buffer[] = [];
|
|
169
|
+
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
170
|
+
if (chunks.length === 0) return null;
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function loadCollection<T>(store: ObjectStore, projectId: string, name: string): Promise<T[]> {
|
|
179
|
+
const key = `projects/${encodeURIComponent(projectId)}/local-registry/${name}.json`;
|
|
180
|
+
if (!(await store.has(key))) return [];
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(await store.getText(key)) as T[];
|
|
183
|
+
} catch {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function saveCollection<T>(store: ObjectStore, projectId: string, name: string, items: T[]): Promise<void> {
|
|
189
|
+
const key = `projects/${encodeURIComponent(projectId)}/local-registry/${name}.json`;
|
|
190
|
+
await store.put(key, JSON.stringify(items, null, 2), "application/json");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function startLocalRegistryStub(options: LocalRegistryStubOptions): Promise<LocalRegistryStub> {
|
|
194
|
+
const store = createObjectStore({
|
|
195
|
+
provider: "local",
|
|
196
|
+
localDirectory: options.directory,
|
|
197
|
+
} satisfies ObjectStoreConfig);
|
|
198
|
+
|
|
199
|
+
const server = http.createServer(async (request, response) => {
|
|
200
|
+
try {
|
|
201
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
202
|
+
const method = request.method ?? "GET";
|
|
203
|
+
|
|
204
|
+
if (method === "GET" && url.pathname === "/health") {
|
|
205
|
+
writeJson(response, 200, { ok: true, service: "bugmole-local-registry-stub" });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (method === "GET" && url.pathname === "/api/projects") {
|
|
209
|
+
writeJson(response, 200, { projects: [] });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const plansMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/plans$/);
|
|
214
|
+
if (plansMatch) {
|
|
215
|
+
const projectId = decodeURIComponent(plansMatch[1]);
|
|
216
|
+
if (method === "GET") {
|
|
217
|
+
const plans = await loadCollection<PlanDraft>(store, projectId, "plans");
|
|
218
|
+
writeJson(response, 200, { plans: [...plans].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (method === "POST") {
|
|
222
|
+
const body = await readJsonBody(request);
|
|
223
|
+
const planId = typeof body?.planId === "string" ? body.planId.trim() : "";
|
|
224
|
+
const journeyId = typeof body?.journeyId === "string" ? body.journeyId.trim() : "";
|
|
225
|
+
const objectKey = typeof body?.objectKey === "string" ? body.objectKey.trim() : "";
|
|
226
|
+
if (!planId || !journeyId || !objectKey) {
|
|
227
|
+
writeJson(response, 422, { error: "planId, journeyId, and objectKey are required" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const plans = await loadCollection<PlanDraft>(store, projectId, "plans");
|
|
231
|
+
const now = new Date().toISOString();
|
|
232
|
+
const existingIndex = plans.findIndex((plan) => plan.planId === planId);
|
|
233
|
+
const plan: PlanDraft = {
|
|
234
|
+
id: existingIndex >= 0 ? plans[existingIndex].id : genId("plan"),
|
|
235
|
+
projectId,
|
|
236
|
+
planId,
|
|
237
|
+
journeyId,
|
|
238
|
+
objectKey,
|
|
239
|
+
status: "draft",
|
|
240
|
+
createdBy: existingIndex >= 0 ? plans[existingIndex].createdBy : "local-dev-bypass",
|
|
241
|
+
createdAt: existingIndex >= 0 ? plans[existingIndex].createdAt : now,
|
|
242
|
+
updatedAt: now,
|
|
243
|
+
};
|
|
244
|
+
if (existingIndex >= 0) plans[existingIndex] = plan; else plans.push(plan);
|
|
245
|
+
await saveCollection(store, projectId, "plans", plans);
|
|
246
|
+
writeJson(response, 201, { plan });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const personasMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/personas$/);
|
|
252
|
+
if (personasMatch) {
|
|
253
|
+
const projectId = decodeURIComponent(personasMatch[1]);
|
|
254
|
+
if (method === "GET") {
|
|
255
|
+
let personas = await loadCollection<Persona>(store, projectId, "personas");
|
|
256
|
+
if (personas.length === 0) {
|
|
257
|
+
personas = [{
|
|
258
|
+
id: genId("persona"), projectId, name: DEFAULT_PERSONA.name, description: DEFAULT_PERSONA.description,
|
|
259
|
+
isDefault: true, createdBy: "local-dev-bypass", createdAt: new Date().toISOString(),
|
|
260
|
+
}];
|
|
261
|
+
await saveCollection(store, projectId, "personas", personas);
|
|
262
|
+
}
|
|
263
|
+
writeJson(response, 200, { personas });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (method === "POST") {
|
|
267
|
+
const body = await readJsonBody(request);
|
|
268
|
+
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
|
269
|
+
const description = typeof body?.description === "string" ? body.description.trim() : "";
|
|
270
|
+
if (!name || name.length > 60 || !description || description.length > 1000) {
|
|
271
|
+
writeJson(response, 422, { error: "name and description are required (name up to 60 characters, description up to 1000)" });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const personas = await loadCollection<Persona>(store, projectId, "personas");
|
|
275
|
+
if (personas.some((persona) => persona.name === name)) {
|
|
276
|
+
writeJson(response, 409, { error: "A persona with that name already exists" });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const persona: Persona = { id: genId("persona"), projectId, name, description, isDefault: false, createdBy: "local-dev-bypass", createdAt: new Date().toISOString() };
|
|
280
|
+
personas.push(persona);
|
|
281
|
+
await saveCollection(store, projectId, "personas", personas);
|
|
282
|
+
writeJson(response, 201, { persona });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const personaMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/personas\/([^/]+)$/);
|
|
288
|
+
if (personaMatch) {
|
|
289
|
+
const projectId = decodeURIComponent(personaMatch[1]);
|
|
290
|
+
const personaId = decodeURIComponent(personaMatch[2]);
|
|
291
|
+
const personas = await loadCollection<Persona>(store, projectId, "personas");
|
|
292
|
+
const index = personas.findIndex((persona) => persona.id === personaId);
|
|
293
|
+
if (index < 0) {
|
|
294
|
+
writeJson(response, 404, { error: "Not found" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (method === "DELETE") {
|
|
298
|
+
if (personas.length <= 1) {
|
|
299
|
+
writeJson(response, 409, { error: "A project needs at least one persona; add another before removing this one" });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
personas.splice(index, 1);
|
|
303
|
+
await saveCollection(store, projectId, "personas", personas);
|
|
304
|
+
writeJson(response, 200, { deleted: true });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (method === "PATCH") {
|
|
308
|
+
const body = await readJsonBody(request);
|
|
309
|
+
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
|
310
|
+
const description = typeof body?.description === "string" ? body.description.trim() : "";
|
|
311
|
+
if (!name || name.length > 60 || !description || description.length > 1000) {
|
|
312
|
+
writeJson(response, 422, { error: "name and description are required (name up to 60 characters, description up to 1000)" });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (personas.some((persona) => persona.name === name && persona.id !== personaId)) {
|
|
316
|
+
writeJson(response, 409, { error: "A persona with that name already exists" });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
personas[index] = { ...personas[index], name, description };
|
|
320
|
+
await saveCollection(store, projectId, "personas", personas);
|
|
321
|
+
writeJson(response, 200, { persona: personas[index] });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const runsMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/runs$/);
|
|
327
|
+
if (runsMatch) {
|
|
328
|
+
const projectId = decodeURIComponent(runsMatch[1]);
|
|
329
|
+
if (method === "GET") {
|
|
330
|
+
const runs = await loadCollection<Run>(store, projectId, "runs");
|
|
331
|
+
writeJson(response, 200, { runs: [...runs].sort((a, b) => b.requestedAt.localeCompare(a.requestedAt)).slice(0, 100) });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (method === "POST") {
|
|
335
|
+
const body = await readJsonBody(request);
|
|
336
|
+
const planId = typeof body?.planId === "string" ? body.planId.trim() : "";
|
|
337
|
+
const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
|
|
338
|
+
if (!planId || !idempotencyKey) {
|
|
339
|
+
writeJson(response, 422, { error: "planId and idempotencyKey are required" });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const plans = await loadCollection<PlanDraft>(store, projectId, "plans");
|
|
343
|
+
const draft = plans.find((plan) => plan.planId === planId);
|
|
344
|
+
if (!draft) {
|
|
345
|
+
writeJson(response, 404, { error: "Plan draft not found" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const runs = await loadCollection<Run>(store, projectId, "runs");
|
|
349
|
+
const existing = runs.find((run) => run.idempotencyKey === idempotencyKey);
|
|
350
|
+
if (existing) {
|
|
351
|
+
writeJson(response, 200, { run: existing });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
let targets: Array<{ browser: string; device?: string }> = [];
|
|
355
|
+
try {
|
|
356
|
+
targets = normalizeTargets(body?.targets).map(({ browser, device }) => (device ? { browser, device } : { browser }));
|
|
357
|
+
} catch (error) {
|
|
358
|
+
writeJson(response, 422, { error: error instanceof Error ? error.message : String(error) });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const now = new Date().toISOString();
|
|
362
|
+
const run: Run = {
|
|
363
|
+
id: genId("run"),
|
|
364
|
+
projectId,
|
|
365
|
+
planId,
|
|
366
|
+
journeyId: draft.journeyId,
|
|
367
|
+
journeyRevision: Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
|
|
368
|
+
environmentId: typeof body?.environmentId === "string" ? body.environmentId : null,
|
|
369
|
+
// The stub has no environments table, so a caller passes the URL directly.
|
|
370
|
+
environmentBaseUrl: typeof body?.environmentBaseUrl === "string" && body.environmentBaseUrl ? body.environmentBaseUrl : null,
|
|
371
|
+
targets: targets.length > 0 ? targets : null,
|
|
372
|
+
targetResults: null,
|
|
373
|
+
durationMs: null,
|
|
374
|
+
fixFrom: body?.fixFrom && typeof (body.fixFrom as { runId?: unknown }).runId === "string"
|
|
375
|
+
? { runId: String((body.fixFrom as { runId: string }).runId), targetId: String((body.fixFrom as { targetId?: string }).targetId ?? "") }
|
|
376
|
+
: null,
|
|
377
|
+
status: "queued",
|
|
378
|
+
stepsExecuted: 0,
|
|
379
|
+
stepsPassed: 0,
|
|
380
|
+
stepsFailed: 0,
|
|
381
|
+
driver: typeof body?.driver === "string" ? body.driver : "maestro",
|
|
382
|
+
platform: typeof body?.platform === "string" ? body.platform : null,
|
|
383
|
+
idempotencyKey,
|
|
384
|
+
requestedAt: now,
|
|
385
|
+
};
|
|
386
|
+
runs.push(run);
|
|
387
|
+
await saveCollection(store, projectId, "runs", runs);
|
|
388
|
+
writeJson(response, 201, { run });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const runClaimMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/claim$/);
|
|
394
|
+
if (runClaimMatch && method === "POST") {
|
|
395
|
+
const runId = decodeURIComponent(runClaimMatch[1]);
|
|
396
|
+
const body = await readJsonBody(request);
|
|
397
|
+
const workerId = typeof body?.workerId === "string" ? body.workerId.trim() : "";
|
|
398
|
+
if (!workerId) {
|
|
399
|
+
writeJson(response, 422, { error: "workerId is required" });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const found = await findRunAnyProject(store, runId);
|
|
403
|
+
if (!found) {
|
|
404
|
+
writeJson(response, 404, { error: "Run not found" });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const { projectId, runs, index } = found;
|
|
408
|
+
if (runs[index].status !== "queued") {
|
|
409
|
+
writeJson(response, 409, { error: "Run is already claimed or complete" });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const leaseExpiresAt = new Date(Date.now() + 60_000).toISOString();
|
|
413
|
+
runs[index] = { ...runs[index], status: "running", workerId, startedAt: runs[index].startedAt ?? new Date().toISOString() };
|
|
414
|
+
await saveCollection(store, projectId, "runs", runs);
|
|
415
|
+
writeJson(response, 200, {
|
|
416
|
+
claimed: true,
|
|
417
|
+
leaseExpiresAt,
|
|
418
|
+
environmentBaseUrl: runs[index].environmentBaseUrl ?? null,
|
|
419
|
+
targets: runs[index].targets ?? null,
|
|
420
|
+
fixFrom: runs[index].fixFrom ?? null,
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const runUpdateMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
|
|
426
|
+
if (runUpdateMatch) {
|
|
427
|
+
const runId = decodeURIComponent(runUpdateMatch[1]);
|
|
428
|
+
const found = await findRunAnyProject(store, runId);
|
|
429
|
+
if (!found) {
|
|
430
|
+
writeJson(response, 404, { error: "Run not found" });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const { projectId, runs, index } = found;
|
|
434
|
+
if (method === "GET") {
|
|
435
|
+
writeJson(response, 200, { run: runs[index], events: [] });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (method === "POST") {
|
|
439
|
+
const body = await readJsonBody(request);
|
|
440
|
+
const status = typeof body?.status === "string" ? body.status : "running";
|
|
441
|
+
const terminal = ["success", "partial", "error", "cancelled"].includes(status);
|
|
442
|
+
// Mirrors the registry: a late update can't reopen a finished run.
|
|
443
|
+
if (!terminal && ["success", "partial", "error", "cancelled"].includes(runs[index].status)) {
|
|
444
|
+
writeJson(response, 409, { error: "Run already finished" });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
runs[index] = {
|
|
448
|
+
...runs[index],
|
|
449
|
+
status,
|
|
450
|
+
currentStep: typeof body?.currentStep === "string" ? body.currentStep : runs[index].currentStep,
|
|
451
|
+
stepsExecuted: Number(body?.stepsExecuted ?? runs[index].stepsExecuted),
|
|
452
|
+
stepsPassed: Number(body?.stepsPassed ?? runs[index].stepsPassed),
|
|
453
|
+
stepsFailed: Number(body?.stepsFailed ?? runs[index].stepsFailed),
|
|
454
|
+
errorCode: typeof body?.errorCode === "string" ? body.errorCode : runs[index].errorCode,
|
|
455
|
+
errorMessage: typeof body?.errorMessage === "string" ? body.errorMessage : runs[index].errorMessage,
|
|
456
|
+
driver: typeof body?.driver === "string" ? body.driver : runs[index].driver,
|
|
457
|
+
platform: typeof body?.platform === "string" ? body.platform : runs[index].platform,
|
|
458
|
+
recoveryAttempts: Number.isFinite(Number(body?.recoveryAttempts)) ? Number(body?.recoveryAttempts) : runs[index].recoveryAttempts,
|
|
459
|
+
failureCode: typeof body?.failureCode === "string" ? body.failureCode : runs[index].failureCode,
|
|
460
|
+
artifactSummaryJson: typeof body?.artifactSummaryJson === "string" ? body.artifactSummaryJson : runs[index].artifactSummaryJson,
|
|
461
|
+
targetResults: Array.isArray(body?.targetResults) ? body.targetResults : runs[index].targetResults,
|
|
462
|
+
completedAt: terminal ? new Date().toISOString() : runs[index].completedAt,
|
|
463
|
+
};
|
|
464
|
+
const { startedAt, completedAt } = runs[index];
|
|
465
|
+
if (startedAt && completedAt) runs[index].durationMs = Math.max(0, Date.parse(completedAt) - Date.parse(startedAt));
|
|
466
|
+
await saveCollection(store, projectId, "runs", runs);
|
|
467
|
+
writeJson(response, 200, { updated: true });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const tasksMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/tasks$/);
|
|
473
|
+
if (tasksMatch) {
|
|
474
|
+
const projectId = decodeURIComponent(tasksMatch[1]);
|
|
475
|
+
if (method === "GET") {
|
|
476
|
+
const tasks = await loadCollection<AgentTask>(store, projectId, "tasks");
|
|
477
|
+
const sorted = [...tasks].sort((a, b) => b.priority - a.priority || a.requestedAt.localeCompare(b.requestedAt));
|
|
478
|
+
writeJson(response, 200, { tasks: sorted.slice(0, 100) });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (method === "POST") {
|
|
482
|
+
const body = await readJsonBody(request);
|
|
483
|
+
const title = typeof body?.title === "string" ? body.title.trim() : "";
|
|
484
|
+
const description = typeof body?.description === "string" ? body.description.trim() : "";
|
|
485
|
+
const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
|
|
486
|
+
const taskType = body?.taskType && ["code", "ui", "investigation", "discovery"].includes(body.taskType as string)
|
|
487
|
+
? body.taskType as AgentTask["taskType"] : "code";
|
|
488
|
+
if (!title || !description || !idempotencyKey) {
|
|
489
|
+
writeJson(response, 422, { error: "title, description, and idempotencyKey are required" });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const tasks = await loadCollection<AgentTask>(store, projectId, "tasks");
|
|
493
|
+
const existingIndex = tasks.findIndex((task) => task.idempotencyKey === idempotencyKey);
|
|
494
|
+
const existing = existingIndex >= 0 ? tasks[existingIndex] : undefined;
|
|
495
|
+
if (existing && !["completed", "error"].includes(existing.status)) {
|
|
496
|
+
writeJson(response, 200, { task: existing });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (existing) {
|
|
500
|
+
// See registry-worker's createAgentTask: a finished task can't
|
|
501
|
+
// just be superseded by a new row under the same idempotency
|
|
502
|
+
// key, or a cancelled task would be returned forever, making
|
|
503
|
+
// retry impossible. Recycle the row into a fresh attempt.
|
|
504
|
+
tasks[existingIndex] = {
|
|
505
|
+
...existing,
|
|
506
|
+
title,
|
|
507
|
+
description,
|
|
508
|
+
taskType,
|
|
509
|
+
priority: Number.isFinite(Number(body?.priority)) ? Number(body?.priority) : 0,
|
|
510
|
+
status: "queued",
|
|
511
|
+
workerId: null,
|
|
512
|
+
progressMessage: null,
|
|
513
|
+
continuation: undefined,
|
|
514
|
+
completionSummary: null,
|
|
515
|
+
changedFiles: undefined,
|
|
516
|
+
errorMessage: null,
|
|
517
|
+
journeyId: typeof body?.journeyId === "string" ? body.journeyId : null,
|
|
518
|
+
journeyRevision: Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
|
|
519
|
+
stepId: typeof body?.stepId === "string" ? body.stepId : null,
|
|
520
|
+
attempts: 0,
|
|
521
|
+
nextAttemptAt: null,
|
|
522
|
+
requestedAt: new Date().toISOString(),
|
|
523
|
+
startedAt: null,
|
|
524
|
+
completedAt: null,
|
|
525
|
+
};
|
|
526
|
+
await saveCollection(store, projectId, "tasks", tasks);
|
|
527
|
+
writeJson(response, 200, { task: tasks[existingIndex] });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const task: AgentTask = {
|
|
531
|
+
id: genId("task"),
|
|
532
|
+
projectId,
|
|
533
|
+
title,
|
|
534
|
+
description,
|
|
535
|
+
taskType,
|
|
536
|
+
priority: Number.isFinite(Number(body?.priority)) ? Number(body?.priority) : 0,
|
|
537
|
+
status: "queued",
|
|
538
|
+
requestedBy: "local-dev-bypass",
|
|
539
|
+
idempotencyKey,
|
|
540
|
+
journeyId: typeof body?.journeyId === "string" ? body.journeyId : null,
|
|
541
|
+
journeyRevision: Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
|
|
542
|
+
stepId: typeof body?.stepId === "string" ? body.stepId : null,
|
|
543
|
+
attempts: 0,
|
|
544
|
+
nextAttemptAt: null,
|
|
545
|
+
requestedAt: new Date().toISOString(),
|
|
546
|
+
};
|
|
547
|
+
tasks.push(task);
|
|
548
|
+
await saveCollection(store, projectId, "tasks", tasks);
|
|
549
|
+
writeJson(response, 201, { task });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const taskClaimMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/claim$/);
|
|
555
|
+
if (taskClaimMatch && method === "POST") {
|
|
556
|
+
const taskId = decodeURIComponent(taskClaimMatch[1]);
|
|
557
|
+
const body = await readJsonBody(request);
|
|
558
|
+
const workerId = typeof body?.workerId === "string" ? body.workerId.trim() : "";
|
|
559
|
+
if (!workerId) {
|
|
560
|
+
writeJson(response, 422, { error: "workerId is required" });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const found = await findTaskAnyProject(store, taskId);
|
|
564
|
+
if (!found) {
|
|
565
|
+
writeJson(response, 404, { error: "Task not found" });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const { projectId, tasks, index } = found;
|
|
569
|
+
// Mirrors registry-worker: a running task whose lease has lapsed is
|
|
570
|
+
// reclaimable, otherwise a worker that died mid-task would wedge
|
|
571
|
+
// the queue permanently. Without this the client-side selector and
|
|
572
|
+
// the claim handler disagreed — the selector offered a stale task
|
|
573
|
+
// and the claim rejected it, aborting every poll.
|
|
574
|
+
const lease = tasks[index].leaseExpiresAt;
|
|
575
|
+
const reclaimable = tasks[index].status === "running"
|
|
576
|
+
&& Boolean(lease)
|
|
577
|
+
&& Date.parse(lease!) <= Date.now();
|
|
578
|
+
if (!["queued", "blocked"].includes(tasks[index].status) && !reclaimable) {
|
|
579
|
+
writeJson(response, 409, { error: "Task is already claimed or complete" });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// Mirrors registry-worker's claim guard: claiming counts as an
|
|
583
|
+
// attempt, and an exhausted or backed-off task is not claimable.
|
|
584
|
+
const attempts = tasks[index].attempts ?? 0;
|
|
585
|
+
const nextAttemptAt = tasks[index].nextAttemptAt;
|
|
586
|
+
if (attempts >= MAX_TASK_ATTEMPTS || (nextAttemptAt && Date.parse(nextAttemptAt) > Date.now())) {
|
|
587
|
+
writeJson(response, 409, { error: "Task is already claimed, complete, or has exhausted its attempts" });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const leaseExpiresAt = new Date(Date.now() + 60_000).toISOString();
|
|
591
|
+
tasks[index] = {
|
|
592
|
+
...tasks[index],
|
|
593
|
+
status: "running",
|
|
594
|
+
workerId,
|
|
595
|
+
attempts: attempts + 1,
|
|
596
|
+
leaseExpiresAt,
|
|
597
|
+
startedAt: tasks[index].startedAt ?? new Date().toISOString(),
|
|
598
|
+
};
|
|
599
|
+
await saveCollection(store, projectId, "tasks", tasks);
|
|
600
|
+
writeJson(response, 200, { claimed: true, leaseExpiresAt, task: tasks[index] });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const taskCancelMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
|
|
605
|
+
if (taskCancelMatch && method === "POST") {
|
|
606
|
+
const taskId = decodeURIComponent(taskCancelMatch[1]);
|
|
607
|
+
const found = await findTaskAnyProject(store, taskId);
|
|
608
|
+
if (!found) {
|
|
609
|
+
writeJson(response, 404, { error: "Task not found" });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const { projectId, tasks, index } = found;
|
|
613
|
+
if (["completed", "error"].includes(tasks[index].status)) {
|
|
614
|
+
writeJson(response, 409, { error: "Task is already finished" });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const body = await readJsonBody(request);
|
|
618
|
+
const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : "Cancelled by user";
|
|
619
|
+
tasks[index] = {
|
|
620
|
+
...tasks[index],
|
|
621
|
+
status: "error",
|
|
622
|
+
errorMessage: reason,
|
|
623
|
+
completionSummary: "Cancelled",
|
|
624
|
+
completedAt: new Date().toISOString(),
|
|
625
|
+
};
|
|
626
|
+
await saveCollection(store, projectId, "tasks", tasks);
|
|
627
|
+
writeJson(response, 200, { cancelled: true, task: tasks[index] });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const taskResumeMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/resume$/);
|
|
632
|
+
if (taskResumeMatch && method === "POST") {
|
|
633
|
+
const taskId = decodeURIComponent(taskResumeMatch[1]);
|
|
634
|
+
const found = await findTaskAnyProject(store, taskId);
|
|
635
|
+
if (!found) {
|
|
636
|
+
writeJson(response, 404, { error: "Task not found" });
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const { projectId, tasks, index } = found;
|
|
640
|
+
if (!isResumableTask(tasks[index])) {
|
|
641
|
+
writeJson(response, 409, {
|
|
642
|
+
error: tasks[index].status === "completed"
|
|
643
|
+
? "Task is already finished"
|
|
644
|
+
: "Task is still held by a live worker",
|
|
645
|
+
});
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const body = await readJsonBody(request);
|
|
649
|
+
const note = typeof body?.note === "string" && body.note.trim() ? body.note.trim() : "Resumed by user";
|
|
650
|
+
tasks[index] = {
|
|
651
|
+
...tasks[index],
|
|
652
|
+
...resumedTaskFields(),
|
|
653
|
+
progressMessage: note,
|
|
654
|
+
errorMessage: null,
|
|
655
|
+
completedAt: null,
|
|
656
|
+
};
|
|
657
|
+
await saveCollection(store, projectId, "tasks", tasks);
|
|
658
|
+
writeJson(response, 200, { resumed: true, task: tasks[index] });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const taskUpdateMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
663
|
+
if (taskUpdateMatch && method === "POST") {
|
|
664
|
+
const taskId = decodeURIComponent(taskUpdateMatch[1]);
|
|
665
|
+
const found = await findTaskAnyProject(store, taskId);
|
|
666
|
+
if (!found) {
|
|
667
|
+
writeJson(response, 404, { error: "Task not found" });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const { projectId, tasks, index } = found;
|
|
671
|
+
const body = await readJsonBody(request);
|
|
672
|
+
const status = typeof body?.status === "string" ? body.status : "running";
|
|
673
|
+
if (!["running", "blocked", "completed", "error"].includes(status)) {
|
|
674
|
+
writeJson(response, 422, { error: "Invalid task status" });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (status === "completed" && tasks[index].taskType === "ui" && (!body?.parityAuditId || typeof body?.parityMatchScore !== "number")) {
|
|
678
|
+
writeJson(response, 422, { error: "UI tasks require parityAuditId and parityMatchScore before completion" });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const terminal = ["completed", "error"].includes(status);
|
|
682
|
+
tasks[index] = {
|
|
683
|
+
...tasks[index],
|
|
684
|
+
status,
|
|
685
|
+
progressMessage: typeof body?.progressMessage === "string" ? body.progressMessage : tasks[index].progressMessage,
|
|
686
|
+
continuation: body?.continuation ?? tasks[index].continuation,
|
|
687
|
+
parityMatchScore: typeof body?.parityMatchScore === "number" ? body.parityMatchScore : tasks[index].parityMatchScore,
|
|
688
|
+
parityAuditId: typeof body?.parityAuditId === "string" ? body.parityAuditId : tasks[index].parityAuditId,
|
|
689
|
+
completionSummary: typeof body?.completionSummary === "string" ? body.completionSummary : tasks[index].completionSummary,
|
|
690
|
+
changedFiles: Array.isArray(body?.changedFiles) ? body.changedFiles : tasks[index].changedFiles,
|
|
691
|
+
errorMessage: typeof body?.errorMessage === "string" ? body.errorMessage : tasks[index].errorMessage,
|
|
692
|
+
// See registry-worker's updateAgentTask: falling back to blocked
|
|
693
|
+
// schedules the retry instead of making it instantly claimable.
|
|
694
|
+
nextAttemptAt: status === "blocked"
|
|
695
|
+
? backoffUntil(tasks[index].attempts ?? 0)
|
|
696
|
+
: tasks[index].nextAttemptAt ?? null,
|
|
697
|
+
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
698
|
+
completedAt: terminal ? new Date().toISOString() : tasks[index].completedAt,
|
|
699
|
+
};
|
|
700
|
+
await saveCollection(store, projectId, "tasks", tasks);
|
|
701
|
+
writeJson(response, 200, { updated: true, task: tasks[index] });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const journeysMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/journeys$/);
|
|
706
|
+
if (journeysMatch) {
|
|
707
|
+
const projectId = decodeURIComponent(journeysMatch[1]);
|
|
708
|
+
if (method === "GET") {
|
|
709
|
+
const revisions = await loadCollection<JourneyRevision>(store, projectId, "journeys");
|
|
710
|
+
writeJson(response, 200, { revisions });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (method === "POST") {
|
|
714
|
+
const body = await readJsonBody(request);
|
|
715
|
+
const journeyId = typeof body?.journeyId === "string" ? body.journeyId.trim() : "";
|
|
716
|
+
const objectKey = typeof body?.objectKey === "string" ? body.objectKey.trim() : "";
|
|
717
|
+
if (!journeyId || !objectKey) {
|
|
718
|
+
writeJson(response, 422, { error: "journeyId and objectKey are required" });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const revisions = await loadCollection<JourneyRevision>(store, projectId, "journeys");
|
|
722
|
+
const currentRevision = revisions
|
|
723
|
+
.filter((rev) => rev.journeyId === journeyId)
|
|
724
|
+
.reduce((max, rev) => Math.max(max, rev.revision), 0);
|
|
725
|
+
if (body?.expectedRevision !== undefined && Number(body.expectedRevision) !== currentRevision) {
|
|
726
|
+
writeJson(response, 409, { error: "Journey revision conflict", currentRevision });
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const revision: JourneyRevision = {
|
|
730
|
+
id: genId("journey"),
|
|
731
|
+
projectId,
|
|
732
|
+
journeyId,
|
|
733
|
+
revision: currentRevision + 1,
|
|
734
|
+
objectKey,
|
|
735
|
+
appId: typeof body?.appId === "string" ? body.appId : null,
|
|
736
|
+
environmentId: typeof body?.environmentId === "string" ? body.environmentId : null,
|
|
737
|
+
status: body?.status === "published" ? "published" : "draft",
|
|
738
|
+
sourceOfTruth: "canonical_flow",
|
|
739
|
+
stability: body?.stability === "stable" ? "stable" : body?.stability === "stabilizing" ? "stabilizing" : "discovered",
|
|
740
|
+
linkedToGraph: body?.linkedToGraph === true,
|
|
741
|
+
verificationRunId: typeof body?.verificationRunId === "string" ? body.verificationRunId : null,
|
|
742
|
+
createdBy: "local-dev-bypass",
|
|
743
|
+
createdAt: new Date().toISOString(),
|
|
744
|
+
};
|
|
745
|
+
revisions.push(revision);
|
|
746
|
+
await saveCollection(store, projectId, "journeys", revisions);
|
|
747
|
+
writeJson(response, 201, { revision });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
writeJson(response, 404, { error: "Not found" });
|
|
753
|
+
} catch (error) {
|
|
754
|
+
writeJson(response, 500, { error: error instanceof Error ? error.message : "Local registry stub error" });
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
await new Promise<void>((resolve) => server.listen(options.port ?? 0, "127.0.0.1", resolve));
|
|
759
|
+
const address = server.address();
|
|
760
|
+
const port = typeof address === "object" && address ? address.port : options.port ?? 0;
|
|
761
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
origin,
|
|
765
|
+
async close() {
|
|
766
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
767
|
+
},
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
async function findRunAnyProject(objectStore: ObjectStore, runId: string) {
|
|
771
|
+
const prefix = "projects/";
|
|
772
|
+
const objects = await objectStore.list(prefix);
|
|
773
|
+
const projectIds = [...new Set(
|
|
774
|
+
objects
|
|
775
|
+
.map((object) => object.key.match(/^projects\/([^/]+)\/local-registry\/runs\.json$/)?.[1])
|
|
776
|
+
.filter((value): value is string => Boolean(value)),
|
|
777
|
+
)];
|
|
778
|
+
for (const encodedProjectId of projectIds) {
|
|
779
|
+
const projectId = decodeURIComponent(encodedProjectId);
|
|
780
|
+
const runs = await loadCollection<Run>(objectStore, projectId, "runs");
|
|
781
|
+
const index = runs.findIndex((run) => run.id === runId);
|
|
782
|
+
if (index >= 0) return { projectId, runs, index };
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function findTaskAnyProject(objectStore: ObjectStore, taskId: string) {
|
|
788
|
+
const prefix = "projects/";
|
|
789
|
+
const objects = await objectStore.list(prefix);
|
|
790
|
+
const projectIds = [...new Set(
|
|
791
|
+
objects
|
|
792
|
+
.map((object) => object.key.match(/^projects\/([^/]+)\/local-registry\/tasks\.json$/)?.[1])
|
|
793
|
+
.filter((value): value is string => Boolean(value)),
|
|
794
|
+
)];
|
|
795
|
+
for (const encodedProjectId of projectIds) {
|
|
796
|
+
const projectId = decodeURIComponent(encodedProjectId);
|
|
797
|
+
const tasks = await loadCollection<AgentTask>(objectStore, projectId, "tasks");
|
|
798
|
+
const index = tasks.findIndex((task) => task.id === taskId);
|
|
799
|
+
if (index >= 0) return { projectId, tasks, index };
|
|
800
|
+
}
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
}
|