@bugmole/cli 0.4.0 → 0.6.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.
Files changed (173) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +7 -4
  3. package/scripts/bugmole-admin.mjs +194 -0
  4. package/scripts/bugmole-admin.test.mjs +62 -0
  5. package/scripts/bugmole.test.ts +94 -2
  6. package/scripts/bugmole.ts +290 -43
  7. package/scripts/ensure-playwright.cjs +87 -0
  8. package/scripts/ensure-playwright.test.ts +36 -0
  9. package/scripts/sync-byok.d.mts +2 -0
  10. package/scripts/sync-byok.mjs +18 -0
  11. package/scripts/sync-launcher-version.cjs +24 -0
  12. package/scripts/sync-launcher-version.test.ts +28 -0
  13. package/scripts/sync-plan-catalog.d.mts +3 -0
  14. package/scripts/sync-plan-catalog.mjs +16 -6
  15. package/spec/domain_rules.yaml +139 -0
  16. package/spec/roles.yaml +20 -0
  17. package/spec/test-case-results.schema.json +20 -4
  18. package/spec/test-cases.schema.json +131 -16
  19. package/src/billing/plan-catalog.test.ts +82 -2
  20. package/src/billing/plan-catalog.ts +212 -9
  21. package/src/integrations/jira.ts +264 -0
  22. package/src/mcp/roles-and-review.test.ts +111 -0
  23. package/src/mcp/server.ts +358 -22
  24. package/src/mcp/write-test-cases.test.ts +68 -0
  25. package/src/registry/control-plane-client.ts +121 -6
  26. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  27. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  28. package/src/registry/migrations/0036_task_approval.sql +11 -0
  29. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  30. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  31. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  32. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  33. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  34. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  35. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  36. package/src/registry/migrations/0044_project_identity.sql +31 -0
  37. package/src/registry/project-identity.test.ts +99 -0
  38. package/src/registry/project-identity.ts +206 -0
  39. package/src/registry/roles.test.ts +66 -0
  40. package/src/registry/roles.ts +201 -0
  41. package/src/registry/task-scheduling.test.ts +94 -0
  42. package/src/registry/task-scheduling.ts +199 -2
  43. package/src/registry/test-case-revisions.test.ts +57 -0
  44. package/src/registry/test-case-revisions.ts +132 -0
  45. package/src/registry-worker/ai/routes.ts +3 -3
  46. package/src/registry-worker/artifacts.ts +23 -4
  47. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  48. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  49. package/src/registry-worker/billing/enforcement.ts +67 -12
  50. package/src/registry-worker/billing/paypal/api.ts +14 -0
  51. package/src/registry-worker/billing/paypal/client.ts +9 -0
  52. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  53. package/src/registry-worker/billing/paypal.test.ts +108 -1
  54. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  55. package/src/registry-worker/billing/provider.ts +8 -0
  56. package/src/registry-worker/billing/routes.ts +2 -1
  57. package/src/registry-worker/billing/subscriptions.ts +114 -4
  58. package/src/registry-worker/core.ts +24 -0
  59. package/src/registry-worker/devices/policy.ts +4 -5
  60. package/src/registry-worker/devices/routes.ts +8 -8
  61. package/src/registry-worker/domains/domains.test.ts +64 -1
  62. package/src/registry-worker/domains/routes.ts +32 -15
  63. package/src/registry-worker/domains.ts +40 -2
  64. package/src/registry-worker/feature-access.ts +165 -0
  65. package/src/registry-worker/feature-flags/admin.ts +300 -0
  66. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  67. package/src/registry-worker/feature-flags/routes.ts +102 -0
  68. package/src/registry-worker/features.ts +11 -0
  69. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  70. package/src/registry-worker/feedback/routes.ts +2 -2
  71. package/src/registry-worker/feedback/website.ts +77 -0
  72. package/src/registry-worker/flags.ts +80 -18
  73. package/src/registry-worker/github/checks.ts +4 -4
  74. package/src/registry-worker/hooks.ts +9 -0
  75. package/src/registry-worker/identity/oidc.test.ts +103 -0
  76. package/src/registry-worker/identity/oidc.ts +176 -0
  77. package/src/registry-worker/index.ts +494 -173
  78. package/src/registry-worker/jira/connection.ts +111 -0
  79. package/src/registry-worker/jira/jira.test.ts +404 -0
  80. package/src/registry-worker/jira/routes.ts +432 -0
  81. package/src/registry-worker/jira/workflow.ts +577 -0
  82. package/src/registry-worker/jobs/retention.ts +21 -5
  83. package/src/registry-worker/mcp/tools.ts +2 -1
  84. package/src/registry-worker/notifications/alerts.ts +4 -4
  85. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  86. package/src/registry-worker/notifications/routes.ts +9 -10
  87. package/src/registry-worker/notifications/teams.ts +6 -2
  88. package/src/registry-worker/org/routes.test.ts +24 -1
  89. package/src/registry-worker/org/routes.ts +9 -8
  90. package/src/registry-worker/projects/identity.ts +194 -0
  91. package/src/registry-worker/projects/inactivity.ts +162 -0
  92. package/src/registry-worker/projects/projects.test.ts +283 -0
  93. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  94. package/src/registry-worker/proposals/routes.ts +183 -0
  95. package/src/registry-worker/roles/roles.test.ts +84 -0
  96. package/src/registry-worker/roles/routes.ts +141 -0
  97. package/src/registry-worker/runner/dispatch.ts +1 -0
  98. package/src/registry-worker/runner/routes.ts +22 -5
  99. package/src/registry-worker/runner/runner.test.ts +59 -0
  100. package/src/registry-worker/runner/tokens.ts +12 -1
  101. package/src/registry-worker/secrets/crypto.ts +135 -0
  102. package/src/registry-worker/secrets/routes.ts +296 -0
  103. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  104. package/src/registry-worker/signup/attribution.test.ts +147 -0
  105. package/src/registry-worker/signup/attribution.ts +194 -0
  106. package/src/registry-worker/signup/policy.ts +2 -2
  107. package/src/registry-worker/signup/routes.ts +12 -3
  108. package/src/registry-worker/sso/membership.ts +4 -2
  109. package/src/registry-worker/sso/routes.ts +24 -8
  110. package/src/registry-worker/sso/sso.test.ts +3 -2
  111. package/src/registry-worker/task-approval.test.ts +67 -0
  112. package/src/registry-worker/task-resume.test.ts +196 -0
  113. package/src/registry-worker/task-review.test.ts +121 -0
  114. package/src/runtime/ai-exploration.test.ts +34 -1
  115. package/src/runtime/ai-exploration.ts +97 -3
  116. package/src/runtime/appium-driver.ts +7 -1
  117. package/src/runtime/apply-proposals.test.ts +74 -0
  118. package/src/runtime/apply-proposals.ts +41 -0
  119. package/src/runtime/cloud-secrets.test.ts +201 -0
  120. package/src/runtime/cloud-secrets.ts +210 -0
  121. package/src/runtime/config-validate.ts +12 -9
  122. package/src/runtime/cursor-driver-run.ts +14 -3
  123. package/src/runtime/discovery-task.test.ts +26 -1
  124. package/src/runtime/discovery-task.ts +87 -7
  125. package/src/runtime/executor.ts +18 -10
  126. package/src/runtime/explorer.test.ts +32 -0
  127. package/src/runtime/explorer.ts +48 -0
  128. package/src/runtime/flow-language.ts +43 -8
  129. package/src/runtime/init-wizard.ts +112 -6
  130. package/src/runtime/journey-editor.ts +78 -2
  131. package/src/runtime/journey-graph.test.ts +60 -0
  132. package/src/runtime/local-registry-stub.test.ts +64 -0
  133. package/src/runtime/local-registry-stub.ts +255 -7
  134. package/src/runtime/local-vault.ts +65 -0
  135. package/src/runtime/pipeline.test.ts +23 -0
  136. package/src/runtime/pipeline.ts +61 -20
  137. package/src/runtime/planner.test.ts +24 -1
  138. package/src/runtime/planner.ts +88 -23
  139. package/src/runtime/playwright-driver.test.ts +164 -0
  140. package/src/runtime/playwright-driver.ts +286 -15
  141. package/src/runtime/project-identity.test.ts +161 -0
  142. package/src/runtime/project-identity.ts +232 -0
  143. package/src/runtime/project-roles.test.ts +107 -0
  144. package/src/runtime/project-roles.ts +158 -0
  145. package/src/runtime/propose-cli.ts +66 -0
  146. package/src/runtime/record-run-verdicts.test.ts +58 -0
  147. package/src/runtime/record-run-verdicts.ts +38 -4
  148. package/src/runtime/reporter.ts +1 -1
  149. package/src/runtime/reset.ts +2 -0
  150. package/src/runtime/roles-cli.test.ts +47 -0
  151. package/src/runtime/roles-cli.ts +60 -0
  152. package/src/runtime/run-job.test.ts +21 -0
  153. package/src/runtime/run-job.ts +8 -0
  154. package/src/runtime/run-once.ts +68 -5
  155. package/src/runtime/scenario-matrix.test.ts +41 -0
  156. package/src/runtime/scenario-matrix.ts +98 -0
  157. package/src/runtime/secret-driver.ts +77 -0
  158. package/src/runtime/secret-redaction.ts +69 -0
  159. package/src/runtime/secret-sources.test.ts +442 -0
  160. package/src/runtime/secret-sources.ts +176 -0
  161. package/src/runtime/secrets-cli.ts +146 -0
  162. package/src/runtime/serve-worker.ts +64 -6
  163. package/src/runtime/site-discovery.test.ts +181 -4
  164. package/src/runtime/site-discovery.ts +248 -29
  165. package/src/runtime/vault-federation.test.ts +37 -0
  166. package/src/runtime/vault-federation.ts +128 -0
  167. package/src/runtime/web-suite.test.ts +65 -0
  168. package/src/runtime/web-suite.ts +8 -2
  169. package/src/storage/create-object-store.ts +5 -1
  170. package/src/storage/object-store.ts +12 -1
  171. package/src/storage/test-case-results.test.ts +64 -0
  172. package/src/storage/test-case-results.ts +49 -0
  173. package/src/vendor/byok.ts +371 -0
@@ -1,5 +1,5 @@
1
- import { MAX_TASK_ATTEMPTS, backoffUntil, isResumableTask, resumedTaskFields } from "../registry/task-scheduling.js";
2
- import { getProjectArtifact, isRunManifestKey, listProjectArtifacts } from "./artifacts.js";
1
+ import { DEFAULT_APPROVAL_MODE, IN_REVIEW, LOCAL_WORKER_SEEN_THROTTLE_MS, MAX_TASK_ATTEMPTS, REVIEW_NOTE_MAX, approvalModeOf, autoApprover, backoffUntil, completionStatus, inReviewMessage, isResumableTask, needsApproval, resumedApproval, resumedTaskFields, resumePlacement, sentBackTaskFields, strandedTaskMessage } from "../registry/task-scheduling.js";
2
+ import { getProjectArtifact, isRunManifestKey, listProjectArtifacts, runIdOfArtifactKey, type ArtifactVisibility } from "./artifacts.js";
3
3
  import {
4
4
  type Env,
5
5
  type Principal,
@@ -14,6 +14,7 @@ import {
14
14
  projectAllowed,
15
15
  memberOfProject,
16
16
  memberOfWorkspace,
17
+ projectLocked,
17
18
  randomToken,
18
19
  base64,
19
20
  fromBase64,
@@ -25,7 +26,7 @@ export type { Env } from "./core.js";
25
26
  import { detachedContext, handleExtendedRoutes, handlePublicRoutes } from "./routes/index.js";
26
27
  import { runScheduledJobs } from "./jobs/index.js";
27
28
  import { actorOf, auditProject, recordAudit } from "./org/audit.js";
28
- import { checkCloudRun, checkProjectLimit, executionModeOf, historyCutoffFor } from "./billing/enforcement.js";
29
+ import { billingLinks, checkCloudRun, checkProjectLimit, executionModeOf, historyCutoffFor, runExpired } from "./billing/enforcement.js";
29
30
  import { workspaceOfProject } from "./billing/entitlements.js";
30
31
  import { heartbeatChecks, registryDispatch, runChangedHooks, runQueuedHooks, runService, taskChangedHooks, taskQueuedHooks, type InternalRunFields } from "./hooks.js";
31
32
  import { appendRunEvent, broadcastProjectEvent } from "./run-events.js";
@@ -37,6 +38,8 @@ import { cloudTargetId, parseCloudDevice } from "../runtime/device-clouds/target
37
38
  import type { CloudDevice } from "../runtime/device-clouds/types.js";
38
39
  import { cloudAccountError, freeWorkspaceLimit } from "./signup/policy.js";
39
40
  import { applyOrgMembership, isDeprovisioned } from "./sso/membership.js";
41
+ import { claimDomainIdentity, identityInUse, identityValues, identityView, invalidIdentity, memberProjectWithIdentity, readIdentity, workspaceProjectWithIdentity, type StoredIdentity } from "./projects/identity.js";
42
+ import { projectInactiveRefusal } from "./projects/inactivity.js";
40
43
  import "./features.js";
41
44
 
42
45
  function deviceCode(): string {
@@ -50,10 +53,14 @@ async function createDeviceAuthorization(request: Request, env: Env): Promise<Re
50
53
  projectName?: string;
51
54
  environment?: string;
52
55
  storageProvider?: string;
56
+ /** Which app this is (src/registry/project-identity.ts), computed by the CLI. */
57
+ identity?: unknown;
53
58
  } | null;
54
59
  if (!body?.projectId || !body.projectName || !body.environment || !body.storageProvider) {
55
60
  return response({ error: "projectId, projectName, environment, and storageProvider are required" }, 422);
56
61
  }
62
+ const identity = await readIdentity(body.identity);
63
+ if (identity === "invalid") return invalidIdentity();
57
64
  const sourceIp = request.headers.get("CF-Connecting-IP") ?? "unknown";
58
65
  const recent = await env.DB.prepare(`
59
66
  SELECT COUNT(*) AS count FROM device_authorizations
@@ -66,8 +73,8 @@ async function createDeviceAuthorization(request: Request, env: Env): Promise<Re
66
73
  const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
67
74
  await env.DB.prepare(`
68
75
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
76
+ (attempt_id, code_hash, poll_secret_hash, project_id, project_name, environment, storage_provider, source_ip, expires_at, identity_json)
77
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
71
78
  `).bind(
72
79
  attemptId,
73
80
  await sha256(code),
@@ -78,6 +85,7 @@ async function createDeviceAuthorization(request: Request, env: Env): Promise<Re
78
85
  currentStorageProvider(body.storageProvider),
79
86
  sourceIp,
80
87
  expiresAt,
88
+ identity ? JSON.stringify(identity) : null,
81
89
  ).run();
82
90
  return response({ attemptId, code, pollSecret, expiresAt }, 201);
83
91
  }
@@ -95,6 +103,77 @@ function workspaceNameFromEmail(email: string | null | undefined): string {
95
103
  return domain.charAt(0).toUpperCase() + domain.slice(1);
96
104
  }
97
105
 
106
+ type DeviceRefusal = { error: string; code: string; upgradeUrl?: string; billingUrl?: string; [key: string]: unknown };
107
+
108
+ type DevicePlacement = {
109
+ workspaceId: string;
110
+ projectId: string;
111
+ /** The app is already a project in a workspace this person works in (a teammate's clone of the same repo). */
112
+ joined: boolean;
113
+ };
114
+
115
+ /**
116
+ * Where a CLI-registered project goes, under the same plan checks as creating
117
+ * a workspace or project in the dashboard: one Free workspace per person (a
118
+ * second one is refused, so the project joins the Free workspace they already
119
+ * own) and the plan's project limit for a project that doesn't exist yet.
120
+ * One app is one project: when the app's identity is already a project in a
121
+ * workspace the person belongs to, the machine pairs with that project.
122
+ */
123
+ async function deviceProjectPlacement(env: Env, principal: Principal, projectId: string, identity: StoredIdentity | null): Promise<DevicePlacement | { refusal: DeviceRefusal; status: number }> {
124
+ if (identity) {
125
+ const joined = await memberProjectWithIdentity(env, principal, identity.hash);
126
+ if (joined) return { workspaceId: joined.workspaceId, projectId: joined.id, joined: true };
127
+ }
128
+ const existingProject = await env.DB.prepare("SELECT workspace_id AS workspaceId FROM projects WHERE id = ? AND deleted_at IS NULL")
129
+ .bind(projectId).first<{ workspaceId: string }>();
130
+ if (existingProject && !(await memberOfProject(env, principal, projectId, "project.manage"))) {
131
+ if (await projectLocked(env, projectId) && await memberOfProject(env, principal, projectId)) {
132
+ return {
133
+ refusal: {
134
+ error: "This project is read-only: the workspace has more projects than its plan includes. Upgrade, or choose which projects stay active in Billing.",
135
+ code: "plan_limit",
136
+ limit: "projects",
137
+ ...billingLinks(env, existingProject.workspaceId),
138
+ },
139
+ status: 402,
140
+ };
141
+ }
142
+ return { refusal: { error: "A project with this id already exists. Choose another project id.", code: "project_exists" }, status: 409 };
143
+ }
144
+ let workspaceId = `workspace_${principal.aegisId}`;
145
+ const workspace = await env.DB.prepare("SELECT id FROM workspaces WHERE id = ?").bind(workspaceId).first<{ id: string }>();
146
+ if (!workspace) {
147
+ const freeLimit = await freeWorkspaceLimit(env, principal);
148
+ if (freeLimit) {
149
+ const refusal = await freeLimit.clone().json() as DeviceRefusal & { workspaceId?: string };
150
+ if (!refusal.workspaceId || !(await memberOfWorkspace(env, principal, refusal.workspaceId, "workspace.write"))) {
151
+ return { refusal, status: freeLimit.status };
152
+ }
153
+ workspaceId = refusal.workspaceId;
154
+ }
155
+ }
156
+ if (!existingProject) {
157
+ if (identity) {
158
+ const inUse = await identityInUse(env, principal, workspaceId, identity);
159
+ if (inUse) return { refusal: await inUse.clone().json() as DeviceRefusal, status: inUse.status };
160
+ }
161
+ const projectLimit = await checkProjectLimit(env, workspaceId);
162
+ if (projectLimit) return { refusal: await projectLimit.clone().json() as DeviceRefusal, status: projectLimit.status };
163
+ }
164
+ return { workspaceId, projectId, joined: false };
165
+ }
166
+
167
+ function storedIdentityOf(json: string | null): StoredIdentity | null {
168
+ if (!json) return null;
169
+ try {
170
+ const value = JSON.parse(json) as StoredIdentity;
171
+ return value && typeof value.hash === "string" && typeof value.key === "string" ? value : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
98
177
  async function claimDeviceAuthorization(request: Request, env: Env, principal: Principal): Promise<Response> {
99
178
  const body = await request.json().catch(() => null) as {
100
179
  attemptId?: string;
@@ -108,9 +187,10 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
108
187
  const attempt = await env.DB.prepare(`
109
188
  SELECT attempt_id AS attemptId, code_hash AS codeHash, project_id AS projectId,
110
189
  project_name AS projectName, environment, storage_provider AS storageProvider,
111
- status, failed_attempts AS failedAttempts, expires_at AS expiresAt
190
+ status, failed_attempts AS failedAttempts, expires_at AS expiresAt, identity_json AS identityJson
112
191
  FROM device_authorizations WHERE attempt_id = ?
113
192
  `).bind(body.attemptId).first<{
193
+ identityJson: string | null;
114
194
  attemptId: string;
115
195
  codeHash: string;
116
196
  projectId: string;
@@ -133,11 +213,23 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
133
213
  if (body.deviceId !== attempt.projectId) {
134
214
  return response({ error: "Device binding does not match the Bugmole project" }, 403);
135
215
  }
216
+ const identity = storedIdentityOf(attempt.identityJson);
217
+ const placement = await deviceProjectPlacement(env, principal, attempt.projectId, identity);
218
+ if ("refusal" in placement) {
219
+ // Recorded on the attempt, so the waiting CLI prints why and where to upgrade.
220
+ await env.DB.prepare(`
221
+ UPDATE device_authorizations SET status = 'refused', refusal_json = ?, aegis_id = ?, claimed_at = CURRENT_TIMESTAMP
222
+ WHERE attempt_id = ? AND status = 'pending'
223
+ `).bind(JSON.stringify(placement.refusal), principal.aegisId, body.attemptId).run();
224
+ return response(placement.refusal, placement.status);
225
+ }
136
226
 
227
+ const projectId = placement.projectId;
137
228
  const claimResult = await env.DB.prepare(`
138
- UPDATE device_authorizations SET status = 'claimed', aegis_id = ?, aegis_email = ?, claimed_at = CURRENT_TIMESTAMP
229
+ UPDATE device_authorizations SET status = 'claimed', aegis_id = ?, aegis_email = ?, claimed_at = CURRENT_TIMESTAMP,
230
+ resolved_project_id = ?
139
231
  WHERE attempt_id = ? AND status = 'pending'
140
- `).bind(principal.aegisId, body.email?.trim() || null, body.attemptId).run() as { meta?: { changes?: number } };
232
+ `).bind(principal.aegisId, body.email?.trim() || null, projectId === attempt.projectId ? null : projectId, body.attemptId).run() as { meta?: { changes?: number } };
141
233
  if (claimResult.meta?.changes !== 1) {
142
234
  return response({ error: "Device authorization was claimed by another session" }, 409);
143
235
  }
@@ -149,15 +241,47 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
149
241
  const existingKey = existingKeys.results.find((key) => {
150
242
  try {
151
243
  const projectIds = JSON.parse(key.projectIds) as unknown;
152
- return Array.isArray(projectIds) && projectIds.includes(attempt.projectId);
244
+ return Array.isArray(projectIds) && projectIds.includes(projectId);
153
245
  } catch {
154
246
  return false;
155
247
  }
156
248
  });
157
249
  const now = new Date().toISOString();
158
- const workspaceId = `workspace_${principal.aegisId}`;
250
+ const workspaceId = placement.workspaceId;
159
251
  const workspaceName = workspaceNameFromEmail(body.email) || (attempt.projectName ? `${attempt.projectName} workspace` : "My workspace");
160
252
  const environmentId = attempt.environment.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "default";
253
+ // Joining a teammate's project pairs this machine with it as it is: its
254
+ // name, apps, environments and the person's workspace role stay unchanged.
255
+ const projectRows = placement.joined ? [] : [
256
+ env.DB.prepare(`
257
+ INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider,
258
+ identity_kind, identity_key, identity_hash, identity_origin, created_at)
259
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
260
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
261
+ storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id,
262
+ identity_kind = COALESCE(projects.identity_kind, excluded.identity_kind),
263
+ identity_key = COALESCE(projects.identity_key, excluded.identity_key),
264
+ identity_hash = COALESCE(projects.identity_hash, excluded.identity_hash),
265
+ identity_origin = COALESCE(projects.identity_origin, excluded.identity_origin)
266
+ `).bind(projectId, workspaceId, workspaceId, attempt.projectName, attempt.environment, currentStorageProvider(attempt.storageProvider), ...identityValues(identity)),
267
+ env.DB.prepare(`
268
+ INSERT INTO project_apps (id, project_id, name, type, is_default)
269
+ VALUES ('default', ?, ?, 'web', 1)
270
+ ON CONFLICT(project_id, id) DO UPDATE SET name = excluded.name
271
+ `).bind(projectId, attempt.projectName),
272
+ env.DB.prepare(`
273
+ -- A project that already has an environment keeps it: pairing
274
+ -- another machine must not add a same-named twin beside it.
275
+ INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
276
+ SELECT ?, ?, 'default', ?, '', 'active', 1
277
+ WHERE NOT EXISTS (SELECT 1 FROM project_environments WHERE project_id = ?)
278
+ `).bind(environmentId, projectId, attempt.environment, projectId),
279
+ env.DB.prepare(`
280
+ INSERT INTO project_memberships (project_id, aegis_id, role, status)
281
+ VALUES (?, ?, 'owner', 'active')
282
+ ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
283
+ `).bind(projectId, principal.aegisId),
284
+ ];
161
285
  if (existingKey) {
162
286
  try {
163
287
  await env.DB.batch([
@@ -172,27 +296,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
172
296
  VALUES (?, ?, 'owner', 'active')
173
297
  ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET status = 'active'
174
298
  `).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),
299
+ ...projectRows,
196
300
  env.DB.prepare(`
197
301
  UPDATE device_authorizations SET status = 'completed', aegis_id = ?, api_key_id = ?, completed_at = ?
198
302
  WHERE attempt_id = ? AND status = 'claimed'
@@ -202,7 +306,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
202
306
  await releaseClaimedDeviceAuthorization(env, body.attemptId);
203
307
  throw error;
204
308
  }
205
- return response({ claimed: true, alreadyInitialized: true }, 200);
309
+ return response({ claimed: true, alreadyInitialized: true, projectId, joined: placement.joined }, 200);
206
310
  }
207
311
 
208
312
  const id = `key_${randomToken(12)}`;
@@ -221,32 +325,12 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
221
325
  VALUES (?, ?, 'owner', 'active')
222
326
  ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET status = 'active'
223
327
  `).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),
328
+ ...projectRows,
245
329
  env.DB.prepare(`
246
330
  INSERT INTO api_keys (id, aegis_id, name, token_hash, scopes_json, project_ids_json, expires_at)
247
331
  VALUES (?, ?, ?, ?, ?, ?, NULL)
248
332
  `).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])),
333
+ JSON.stringify(["projects:read", "projects:write", "runs:write", "artifacts:write"]), JSON.stringify([projectId])),
250
334
  env.DB.prepare(`
251
335
  UPDATE device_authorizations
252
336
  SET status = 'completed', aegis_id = ?, api_key_id = ?, encrypted_token = ?, token_iv = ?, completed_at = ?
@@ -257,7 +341,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
257
341
  await releaseClaimedDeviceAuthorization(env, body.attemptId);
258
342
  throw error;
259
343
  }
260
- return response({ claimed: true, alreadyInitialized: false }, 200);
344
+ return response({ claimed: true, alreadyInitialized: false, projectId, joined: placement.joined }, 200);
261
345
  }
262
346
 
263
347
  async function releaseClaimedDeviceAuthorization(env: Env, attemptId: string): Promise<void> {
@@ -283,15 +367,18 @@ async function pollDeviceAuthorization(request: Request, env: Env): Promise<Resp
283
367
  if (!body?.attemptId || !body.pollSecret) return response({ error: "attemptId and pollSecret are required" }, 422);
284
368
  const attempt = await env.DB.prepare(`
285
369
  SELECT status, expires_at AS expiresAt, encrypted_token AS encryptedToken, token_iv AS tokenIv,
286
- poll_secret_hash AS pollSecretHash, aegis_email AS aegisEmail
370
+ poll_secret_hash AS pollSecretHash, aegis_email AS aegisEmail, refusal_json AS refusalJson,
371
+ COALESCE(resolved_project_id, project_id) AS projectId
287
372
  FROM device_authorizations WHERE attempt_id = ?
288
373
  `).bind(body.attemptId).first<{
374
+ projectId: string;
289
375
  status: string;
290
376
  expiresAt: string;
291
377
  encryptedToken: string | null;
292
378
  tokenIv: string | null;
293
379
  pollSecretHash: string;
294
380
  aegisEmail: string | null;
381
+ refusalJson: string | null;
295
382
  }>();
296
383
  if (!attempt || !constantTimeEqual(await sha256(body.pollSecret), attempt.pollSecretHash)) {
297
384
  return response({ error: "Device authorization not found" }, 404);
@@ -302,92 +389,22 @@ async function pollDeviceAuthorization(request: Request, env: Env): Promise<Resp
302
389
  return response({ status: "expired" }, 410);
303
390
  }
304
391
  if (attempt.status === "pending") return response({ status: "pending" }, 202);
305
- if (!attempt.encryptedToken || !attempt.tokenIv) return response({ status: "completed", alreadyInitialized: true }, 200);
392
+ if (attempt.status === "refused") {
393
+ const refusal = attempt.refusalJson ? JSON.parse(attempt.refusalJson) as Record<string, unknown> : { error: "Device registration was refused" };
394
+ return response({ status: "refused", ...refusal }, refusal.code === "plan_limit" || refusal.code === "free_workspace_exists" ? 402 : 409);
395
+ }
396
+ if (!attempt.encryptedToken || !attempt.tokenIv) return response({ status: "completed", alreadyInitialized: true, projectId: attempt.projectId }, 200);
306
397
  const result = await env.DB.prepare(`
307
398
  UPDATE device_authorizations SET encrypted_token = NULL, token_iv = NULL
308
399
  WHERE attempt_id = ? AND status = 'completed' AND encrypted_token = ?
309
400
  `).bind(body.attemptId, attempt.encryptedToken).run() as { meta?: { changes?: number } };
310
- if (result.meta?.changes !== 1) return response({ status: "completed", alreadyInitialized: true }, 200);
401
+ if (result.meta?.changes !== 1) return response({ status: "completed", alreadyInitialized: true, projectId: attempt.projectId }, 200);
311
402
  const token = await decryptForSecret(
312
403
  attempt.encryptedToken,
313
404
  attempt.tokenIv,
314
405
  await sha256(body.pollSecret),
315
406
  );
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);
407
+ return response({ status: "completed", alreadyInitialized: false, token, email: attempt.aegisEmail, projectId: attempt.projectId }, 200);
391
408
  }
392
409
 
393
410
  async function listWorkspaces(env: Env, principal: Principal): Promise<Response> {
@@ -400,7 +417,7 @@ async function listWorkspaces(env: Env, principal: Principal): Promise<Response>
400
417
  `).bind(principal.aegisId).all();
401
418
  const workspaces = await Promise.all(rows.results.map(async (workspace) => {
402
419
  const projects = await env.DB.prepare(`
403
- SELECT p.id, p.name, p.environment, p.storage_provider AS storageProvider
420
+ SELECT p.id, p.name, p.environment, p.storage_provider AS storageProvider, p.locked_at AS lockedAt, p.inactive_at AS inactiveAt
404
421
  FROM projects p
405
422
  WHERE p.workspace_id = ? AND p.deleted_at IS NULL
406
423
  ORDER BY p.created_at ASC
@@ -461,6 +478,10 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
461
478
  if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Project creation is not allowed" }, 403);
462
479
  const body = await request.json().catch(() => null) as {
463
480
  id?: string; name?: string; workspaceId?: string; environment?: string; storageProvider?: string;
481
+ /** Which app this is (src/registry/project-identity.ts): a git remote plus the app's folder, or a domain. */
482
+ identity?: unknown;
483
+ /** The app's site; a public one identifies the project by its domain when no identity is sent. */
484
+ appUrl?: unknown;
464
485
  } | null;
465
486
  const projectId = body?.id?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
466
487
  const name = body?.name?.trim();
@@ -469,14 +490,32 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
469
490
  return response({ error: "id, name, workspaceId, environment, and storageProvider are required" }, 422);
470
491
  }
471
492
  if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.write"))) return response({ error: "Workspace scope is not allowed" }, 403);
493
+ const identity = body.identity !== undefined && body.identity !== null
494
+ ? await readIdentity(body.identity)
495
+ : await readIdentity(body.appUrl ? { appUrl: body.appUrl } : null);
496
+ if (identity === "invalid") return invalidIdentity();
497
+ if (identity) {
498
+ // One app is one project: the same app in this workspace is the project that's already here.
499
+ const existing = await workspaceProjectWithIdentity(env, workspaceId, identity.hash);
500
+ if (existing) {
501
+ return response({
502
+ project: { id: existing.id, name: existing.name, workspaceId, inactiveAt: existing.inactiveAt },
503
+ existing: true,
504
+ identity: identityView({ identityKind: identity.kind, identityKey: identity.key }),
505
+ }, 200);
506
+ }
507
+ const inUse = await identityInUse(env, principal, workspaceId, identity);
508
+ if (inUse) return inUse;
509
+ }
472
510
  const projectLimit = await checkProjectLimit(env, workspaceId);
473
511
  if (projectLimit) return projectLimit;
474
512
  try {
475
513
  await env.DB.batch([
476
514
  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)),
515
+ INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode,
516
+ identity_kind, identity_key, identity_hash, identity_origin, created_at)
517
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
518
+ `).bind(projectId, workspaceId, workspaceId, name, body.environment.trim(), currentStorageProvider(body.storageProvider), storageModeOf(body.storageProvider), ...identityValues(identity)),
480
519
  env.DB.prepare("INSERT INTO project_memberships (project_id, aegis_id, role, status) VALUES (?, ?, 'owner', 'active')")
481
520
  .bind(projectId, principal.aegisId),
482
521
  env.DB.prepare("INSERT INTO project_apps (id, project_id, name, type, is_default) VALUES ('default', ?, ?, 'web', 1)")
@@ -485,8 +524,12 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
485
524
  } catch {
486
525
  return response({ error: "A project with this id already exists" }, 409);
487
526
  }
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);
527
+ const view = identity ? identityView({ identityKind: identity.kind, identityKey: identity.key }) : null;
528
+ await auditProject(env, principal, projectId, { action: "project.created", targetType: "project", targetId: projectId, ...(view ? { data: { identity: view.label } } : {}) }, request);
529
+ return response({
530
+ project: { id: projectId, name, workspaceId, environment: body.environment.trim(), storageProvider: body.storageProvider.trim() },
531
+ identity: view,
532
+ }, 201);
490
533
  }
491
534
 
492
535
  async function updateProject(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
@@ -586,7 +629,8 @@ async function listProjects(env: Env, principal: Principal): Promise<Response> {
586
629
  END AS workspaceName,
587
630
  p.tenant_id AS tenantId, p.name, p.environment,
588
631
  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
632
+ p.default_execution_mode AS defaultExecutionMode, p.task_approval_mode AS taskApprovalMode, p.locked_at AS lockedAt,
633
+ p.inactive_at AS inactiveAt, p.identity_kind AS identityKind, p.identity_key AS identityKey, p.created_at AS createdAt
590
634
  FROM projects p
591
635
  JOIN workspace_memberships wm ON wm.workspace_id = p.workspace_id
592
636
  JOIN workspaces w ON w.id = p.workspace_id
@@ -622,8 +666,10 @@ async function listProjects(env: Env, principal: Principal): Promise<Response> {
622
666
  status: string;
623
667
  isDefault: number;
624
668
  }>();
669
+ const { identityKind, identityKey, ...rest } = project as Record<string, unknown> & { identityKind?: string | null; identityKey?: string | null };
625
670
  return {
626
- ...project,
671
+ ...rest,
672
+ identity: identityView({ identityKind, identityKey }),
627
673
  apps: apps.results.map((app) => ({
628
674
  ...app,
629
675
  location: app.location ?? undefined,
@@ -717,6 +763,14 @@ async function createEnvironment(
717
763
  "SELECT id FROM project_apps WHERE project_id = ? AND id = ?",
718
764
  ).bind(projectId, appId).first<{ id: string }>();
719
765
  if (!app) return response({ error: "App does not belong to this project" }, 422);
766
+ // Two environments with one name can't be told apart in the picker.
767
+ const sameName = await env.DB.prepare(
768
+ "SELECT id FROM project_environments WHERE project_id = ? AND COALESCE(app_id, 'default') = ? AND lower(trim(name)) = lower(?)",
769
+ ).bind(projectId, appId, body.name.trim()).first<{ id: string }>();
770
+ if (sameName) return response({ error: `This app already has an environment named "${body.name.trim()}" (${sameName.id})` }, 409);
771
+ // A project with no identity yet is identified by its public site's domain.
772
+ const identityRefusal = await claimDomainIdentity(env, principal, projectId, body.baseUrl.trim());
773
+ if (identityRefusal) return identityRefusal;
720
774
  try {
721
775
  await env.DB.prepare(`
722
776
  INSERT INTO project_environments (id, project_id, app_id, name, base_url, api_url)
@@ -773,6 +827,9 @@ async function updateEnvironment(
773
827
  .bind(projectId, body.appId.trim()).first<{ id: string }>();
774
828
  if (!app) return response({ error: "App does not belong to this project" }, 422);
775
829
  }
830
+ // A project with no identity yet is identified by its public site's domain.
831
+ const identityRefusal = await claimDomainIdentity(env, principal, projectId, baseUrl);
832
+ if (identityRefusal) return identityRefusal;
776
833
  const result = await env.DB.prepare(`
777
834
  UPDATE project_environments
778
835
  SET app_id = COALESCE(?, app_id), name = ?, base_url = ?, api_url = ?, status = COALESCE(?, status),
@@ -911,7 +968,7 @@ function parseJsonColumn(value: unknown): unknown {
911
968
  }
912
969
 
913
970
  function hydrateRun<T extends Record<string, unknown>>(row: T) {
914
- const { targetsJson, targetResultsJson, fixFromJson, ...rest } = row as Record<string, unknown>;
971
+ const { targetsJson, targetResultsJson, fixFromJson, caseScopeJson, ...rest } = row as Record<string, unknown>;
915
972
  const started = typeof rest.startedAt === "string" ? Date.parse(`${rest.startedAt.replace(" ", "T")}Z`) : NaN;
916
973
  const completed = typeof rest.completedAt === "string" ? Date.parse(`${rest.completedAt.replace(" ", "T")}Z`) : NaN;
917
974
  return {
@@ -919,10 +976,34 @@ function hydrateRun<T extends Record<string, unknown>>(row: T) {
919
976
  targets: parseJsonColumn(targetsJson),
920
977
  targetResults: parseJsonColumn(targetResultsJson),
921
978
  fixFrom: parseJsonColumn(fixFromJson),
979
+ caseScope: parseJsonColumn(caseScopeJson),
922
980
  durationMs: Number.isFinite(started) && Number.isFinite(completed) ? Math.max(0, completed - started) : null,
923
981
  };
924
982
  }
925
983
 
984
+ /**
985
+ * Run evidence follows its run: once the run is past the plan's history, its
986
+ * objects are hidden too (the retention job deletes both). Evidence of a run
987
+ * the registry no longer knows is judged by when it was written.
988
+ */
989
+ async function runEvidenceVisibility(env: Env, projectId: string): Promise<ArtifactVisibility | undefined> {
990
+ const cutoff = await historyCutoffFor(env, await workspaceOfProject(env, projectId));
991
+ if (!cutoff) return undefined;
992
+ const expired = new Map<string, boolean | null>();
993
+ return async (key, uploaded) => {
994
+ const runId = runIdOfArtifactKey(projectId, key);
995
+ if (!runId) return true;
996
+ if (!expired.has(runId)) {
997
+ const run = await env.DB.prepare("SELECT status, completed_at AS completedAt, requested_at AS requestedAt FROM runs WHERE id = ? AND project_id = ?")
998
+ .bind(runId, projectId).first<{ status: string; completedAt: string | null; requestedAt: string | null }>();
999
+ expired.set(runId, run ? runExpired(run, cutoff) : null);
1000
+ }
1001
+ const known = expired.get(runId);
1002
+ if (known === null || known === undefined) return !uploaded || uploaded.getTime() >= Date.parse(cutoff);
1003
+ return !known;
1004
+ };
1005
+ }
1006
+
926
1007
  async function listRuns(env: Env, principal: Principal, projectId: string): Promise<Response> {
927
1008
  if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
928
1009
  const cutoff = await historyCutoffFor(env, await workspaceOfProject(env, projectId));
@@ -938,9 +1019,9 @@ async function listRuns(env: Env, principal: Principal, projectId: string): Prom
938
1019
  manifest_key AS manifestKey, requested_at AS requestedAt,
939
1020
  started_at AS startedAt, completed_at AS completedAt,
940
1021
  targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
941
- target_results_json AS targetResultsJson, fix_from_json AS fixFromJson,
1022
+ target_results_json AS targetResultsJson, fix_from_json AS fixFromJson, case_scope_json AS caseScopeJson,
942
1023
  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,
1024
+ trigger_source AS triggerSource, repo_full_name AS repoFullName, commit_sha AS commitSha, pr_number AS prNumber, check_run_id AS checkRunId,
944
1025
  gitlab_binding_id AS gitlabBindingId
945
1026
  FROM runs
946
1027
  WHERE project_id = ? AND (? IS NULL OR COALESCE(completed_at, requested_at) >= ? OR status NOT IN ('success', 'partial', 'error', 'cancelled'))
@@ -952,6 +1033,8 @@ async function listRuns(env: Env, principal: Principal, projectId: string): Prom
952
1033
  async function createRun(request: Request, env: Env, principal: Principal, projectId: string, ctx: ExecutionContext, internal: InternalRunFields = {}): Promise<Response> {
953
1034
  if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Run creation is not allowed" }, 403);
954
1035
  if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
1036
+ const paused = await projectInactiveRefusal(env, projectId);
1037
+ if (paused) return paused;
955
1038
  const body = await requestJson(request);
956
1039
  const planId = typeof body?.planId === "string" ? body.planId.trim() : "";
957
1040
  const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
@@ -1025,8 +1108,8 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
1025
1108
  const targets = matrix.targets.length > 0 ? matrix.targets : null;
1026
1109
  await env.DB.prepare(`
1027
1110
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1111
+ trigger_source, repo_full_name, commit_sha, pr_number, github_installation_id, gitlab_binding_id, case_scope_json)
1112
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1030
1113
  `).bind(
1031
1114
  runId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, planId, draft.journeyId,
1032
1115
  Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
@@ -1044,6 +1127,7 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
1044
1127
  internal.prNumber ?? null,
1045
1128
  internal.githubInstallationId ?? null,
1046
1129
  internal.gitlabBindingId ?? null,
1130
+ internal.caseScope ? JSON.stringify(internal.caseScope) : null,
1047
1131
  ).run();
1048
1132
  await appendRunEvent(env, runId, "queued", { status: "queued", targets, environmentBaseUrl, executionMode });
1049
1133
  await broadcastProjectEvent(env, projectId, { type: "job_queued", runId, projectId, planId, executionMode });
@@ -1053,6 +1137,7 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
1053
1137
  run: {
1054
1138
  id: runId, projectId, planId, journeyId: draft.journeyId, status: "queued", executionMode,
1055
1139
  environmentId, environmentBaseUrl, targets, targetResults: null, durationMs: null, fixFrom,
1140
+ caseScope: internal.caseScope ?? null,
1056
1141
  },
1057
1142
  }, 201);
1058
1143
  }
@@ -1069,11 +1154,15 @@ async function getRun(env: Env, principal: Principal, runId: string): Promise<Re
1069
1154
  manifest_key AS manifestKey, requested_at AS requestedAt,
1070
1155
  started_at AS startedAt, completed_at AS completedAt,
1071
1156
  targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
1072
- target_results_json AS targetResultsJson, fix_from_json AS fixFromJson,
1157
+ target_results_json AS targetResultsJson, fix_from_json AS fixFromJson, case_scope_json AS caseScopeJson,
1073
1158
  execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes
1074
1159
  FROM runs WHERE id = ?
1075
1160
  `).bind(runId).first<{ projectId: string } & Record<string, unknown>>();
1076
1161
  if (!run || !(await memberOfProject(env, principal, run.projectId))) return response({ error: "Run not found" }, 404);
1162
+ const workspaceId = await workspaceOfProject(env, run.projectId);
1163
+ if (runExpired({ status: String(run.status), completedAt: run.completedAt as string | null, requestedAt: run.requestedAt as string | null }, await historyCutoffFor(env, workspaceId))) {
1164
+ return response({ error: "This run is older than your plan's run history. Upgrade to keep history longer.", code: "history_expired", ...billingLinks(env, workspaceId) }, 404);
1165
+ }
1077
1166
  const events = await env.DB.prepare(`
1078
1167
  SELECT sequence, type, payload_json AS payload, created_at AS createdAt
1079
1168
  FROM run_events WHERE run_id = ? ORDER BY sequence ASC
@@ -1088,10 +1177,12 @@ async function claimRun(request: Request, env: Env, principal: Principal, runId:
1088
1177
  if (!workerId) return response({ error: "workerId is required" }, 422);
1089
1178
  const run = await env.DB.prepare(`
1090
1179
  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
1180
+ fix_from_json AS fixFromJson, case_scope_json AS caseScopeJson, execution_mode AS executionMode, workspace_id AS workspaceId, status
1092
1181
  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 }>();
1182
+ `).bind(runId).first<{ projectId: string; targetsJson: string | null; environmentBaseUrl: string | null; fixFromJson: string | null; caseScopeJson: string | null; executionMode: string; workspaceId: string | null; status: string }>();
1094
1183
  if (!run || !(await memberOfProject(env, principal, run.projectId, "project.write"))) return response({ error: "Run not found" }, 404);
1184
+ const paused = await projectInactiveRefusal(env, run.projectId);
1185
+ if (paused) return paused;
1095
1186
  // Bugmole Cloud runs are billed; only Bugmole's own runners may take them.
1096
1187
  if (run.executionMode === "cloud") return response({ error: "This run is queued for Bugmole Cloud" }, 409);
1097
1188
  const lease = new Date(Date.now() + 60_000).toISOString();
@@ -1116,6 +1207,7 @@ async function claimRun(request: Request, env: Env, principal: Principal, runId:
1116
1207
  environmentBaseUrl: run.environmentBaseUrl,
1117
1208
  targets: parseJsonColumn(run.targetsJson),
1118
1209
  fixFrom: parseJsonColumn(run.fixFromJson),
1210
+ caseScope: parseJsonColumn(run.caseScopeJson),
1119
1211
  });
1120
1212
  }
1121
1213
 
@@ -1201,16 +1293,35 @@ const taskSelect = `
1201
1293
  idempotency_key AS idempotencyKey, requested_at AS requestedAt,
1202
1294
  started_at AS startedAt, completed_at AS completedAt, device_session_id AS deviceSessionId,
1203
1295
  attempts, next_attempt_at AS nextAttemptAt,
1204
- execution_mode AS executionMode, billed_minutes AS billedMinutes
1296
+ execution_mode AS executionMode, billed_minutes AS billedMinutes,
1297
+ approved_at AS approvedAt, approved_by AS approvedBy,
1298
+ reviewed_at AS reviewedAt, reviewed_by AS reviewedBy, review_note AS reviewNote
1205
1299
  FROM agent_tasks
1206
1300
  `;
1207
1301
 
1208
1302
 
1209
- async function listAgentTasks(env: Env, principal: Principal, projectId: string): Promise<Response> {
1303
+ // A worker on someone's machine lists its project's tasks on every poll, with
1304
+ // the machine's API key. That is how the registry knows a machine is
1305
+ // connected: resume won't queue a machine-only task on a cloud project with
1306
+ // none. Written at most once a minute, whatever the poll rate.
1307
+ async function noteLocalWorker(env: Env, principal: Principal, projectId: string): Promise<void> {
1308
+ if (!principal.apiKeyId || principal.runner) return;
1309
+ const now = Date.now();
1310
+ await env.DB.prepare(`
1311
+ UPDATE projects SET local_worker_seen_at = ?
1312
+ WHERE id = ? AND (local_worker_seen_at IS NULL OR local_worker_seen_at < ?)
1313
+ `).bind(new Date(now).toISOString(), projectId, new Date(now - LOCAL_WORKER_SEEN_THROTTLE_MS).toISOString()).run();
1314
+ }
1315
+
1316
+ async function listAgentTasks(env: Env, principal: Principal, projectId: string, ctx: ExecutionContext): Promise<Response> {
1210
1317
  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 });
1318
+ ctx.waitUntil(noteLocalWorker(env, principal, projectId).catch(() => undefined));
1319
+ const [rows, project] = await Promise.all([
1320
+ env.DB.prepare(`${taskSelect} WHERE project_id = ? ORDER BY priority DESC, requested_at ASC LIMIT 100`).bind(projectId).all(),
1321
+ env.DB.prepare("SELECT local_worker_seen_at AS localWorkerSeenAt FROM projects WHERE id = ?")
1322
+ .bind(projectId).first<{ localWorkerSeenAt: string | null }>(),
1323
+ ]);
1324
+ return response({ tasks: rows.results, localWorkerSeenAt: project?.localWorkerSeenAt ?? null });
1214
1325
  }
1215
1326
 
1216
1327
  function extractDiscoveryUrl(description: string): string | null {
@@ -1251,6 +1362,17 @@ async function fillDiscoveredEnvironmentUrl(env: Env, projectId: string, descrip
1251
1362
  .bind(origin, projectId, existing.id).run();
1252
1363
  return;
1253
1364
  }
1365
+ // An environment that isn't marked default (made by hand, or by an older
1366
+ // onboarding) is still the project's environment: promote it rather than
1367
+ // add a second "Production" beside it.
1368
+ const other = await env.DB.prepare(
1369
+ "SELECT id, base_url AS baseUrl FROM project_environments WHERE project_id = ? ORDER BY rowid LIMIT 1",
1370
+ ).bind(projectId).first<{ id: string; baseUrl: string }>();
1371
+ if (other) {
1372
+ await env.DB.prepare("UPDATE project_environments SET is_default = 1, base_url = CASE WHEN base_url = '' THEN ? ELSE base_url END WHERE project_id = ? AND id = ?")
1373
+ .bind(origin, projectId, other.id).run();
1374
+ return;
1375
+ }
1254
1376
  const app = await env.DB.prepare("SELECT id FROM project_apps WHERE project_id = ? AND is_default = 1")
1255
1377
  .bind(projectId).first<{ id: string }>();
1256
1378
  if (!app) return;
@@ -1265,6 +1387,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1265
1387
  return response({ error: "Task creation is not allowed" }, 403);
1266
1388
  }
1267
1389
  if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
1390
+ const paused = await projectInactiveRefusal(env, projectId);
1391
+ if (paused) return paused;
1268
1392
  const body = await requestJson(request) as {
1269
1393
  title?: string; description?: string; taskType?: string; idempotencyKey?: string; priority?: number;
1270
1394
  journeyId?: string; journeyRevision?: number; deviceSessionId?: string; stepId?: string;
@@ -1297,7 +1421,14 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1297
1421
  if (domainProblem) return domainProblem;
1298
1422
  }
1299
1423
  }
1424
+ // The project's approval mode decides whether this task can start by
1425
+ // itself or waits for someone to press Approve.
1426
+ const approvalMode = approvalModeOf((await env.DB.prepare("SELECT task_approval_mode AS mode FROM projects WHERE id = ?")
1427
+ .bind(projectId).first<{ mode: string }>())?.mode) ?? DEFAULT_APPROVAL_MODE;
1428
+ const approvedBy = needsApproval(approvalMode, taskType) ? null : autoApprover(approvalMode);
1300
1429
  const queued = (taskId: string) => {
1430
+ // Waiting for approval isn't queued for a runner yet; approving it is.
1431
+ if (!approvedBy) return;
1301
1432
  for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId, workspaceId: taskWorkspaceId, executionMode }));
1302
1433
  };
1303
1434
  const existing = await env.DB.prepare(`${taskSelect} WHERE project_id = ? AND idempotency_key = ?`)
@@ -1317,7 +1448,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1317
1448
  attempts = 0, next_attempt_at = NULL,
1318
1449
  execution_mode = ?, workspace_id = ?, claimed_at = NULL, billed_at = NULL, billed_minutes = NULL,
1319
1450
  blocked_reason = NULL, dispatch_attempts = 0, next_dispatch_at = NULL,
1320
- requested_at = CURRENT_TIMESTAMP, started_at = NULL, completed_at = NULL
1451
+ requested_at = CURRENT_TIMESTAMP, started_at = NULL, completed_at = NULL,
1452
+ approved_at = CASE WHEN ? IS NULL THEN NULL ELSE CURRENT_TIMESTAMP END, approved_by = ?
1321
1453
  WHERE id = ?
1322
1454
  `).bind(
1323
1455
  title, description, taskType,
@@ -1326,6 +1458,7 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1326
1458
  typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
1327
1459
  typeof body?.stepId === "string" ? body.stepId : null,
1328
1460
  executionMode, taskWorkspaceId,
1461
+ approvedBy, approvedBy,
1329
1462
  existing.id,
1330
1463
  ).run();
1331
1464
  await broadcastProjectEvent(env, projectId, { type: "task_event", taskId: existing.id, projectId, status: "queued" });
@@ -1337,8 +1470,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1337
1470
  const taskId = id("task");
1338
1471
  await env.DB.prepare(`
1339
1472
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1473
+ (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, approved_at, approved_by)
1474
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN NULL ELSE CURRENT_TIMESTAMP END, ?)
1342
1475
  `).bind(
1343
1476
  taskId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, title, description, taskType,
1344
1477
  Number.isFinite(Number(body?.priority)) ? Number(body?.priority) : 0, principal.aegisId, idempotencyKey,
@@ -1346,6 +1479,7 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1346
1479
  typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
1347
1480
  typeof body?.stepId === "string" ? body.stepId : null,
1348
1481
  executionMode, taskWorkspaceId,
1482
+ approvedBy, approvedBy,
1349
1483
  ).run();
1350
1484
  await broadcastProjectEvent(env, projectId, { type: "task_event", taskId, projectId, status: "queued" });
1351
1485
  queued(taskId);
@@ -1362,6 +1496,8 @@ async function claimAgentTask(request: Request, env: Env, principal: Principal,
1362
1496
  .bind(taskId).first<{ projectId: string; executionMode: string }>();
1363
1497
  if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
1364
1498
  if (task.executionMode === "cloud") return response({ error: "This task is queued for Bugmole Cloud" }, 409);
1499
+ const paused = await projectInactiveRefusal(env, task.projectId);
1500
+ if (paused) return paused;
1365
1501
  const lease = new Date(Date.now() + 60_000).toISOString();
1366
1502
  const deviceSessionId = typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null;
1367
1503
  // Claiming is what counts as an attempt: it's the only point both the
@@ -1375,8 +1511,11 @@ async function claimAgentTask(request: Request, env: Env, principal: Principal,
1375
1511
  WHERE id = ? AND attempts < ${MAX_TASK_ATTEMPTS}
1376
1512
  AND (next_attempt_at IS NULL OR next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
1377
1513
  AND (status = 'queued' OR status = 'blocked' OR (status = 'running' AND lease_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))
1514
+ AND approved_at IS NOT NULL
1378
1515
  `).bind(workerId, lease, deviceSessionId, taskId).run() as { meta?: { changes?: number } };
1379
1516
  if (claimed.meta?.changes !== 1) {
1517
+ const waiting = await env.DB.prepare("SELECT 1 AS waiting FROM agent_tasks WHERE id = ? AND approved_at IS NULL").bind(taskId).first();
1518
+ if (waiting) return response({ error: "Task is waiting for approval" }, 409);
1380
1519
  return response({ error: "Task is already claimed, complete, or has exhausted its attempts" }, 409);
1381
1520
  }
1382
1521
  await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "running", workerId });
@@ -1393,11 +1532,25 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
1393
1532
  `).bind(taskId).first<{ projectId: string; workerId: string | null; taskType: string; attempts: number; status: string; executionMode: string; workspaceId: string | null; description: string | null }>();
1394
1533
  if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
1395
1534
  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")) {
1535
+ const reported = typeof body?.status === "string" ? body.status : "running";
1536
+ if (!["running", "blocked", "completed", "error"].includes(reported)) return response({ error: "Invalid task status" }, 422);
1537
+ if (reported === "completed" && task.taskType === "ui" && (!body?.parityAuditId || typeof body?.parityMatchScore !== "number")) {
1399
1538
  return response({ error: "UI tasks require parityAuditId and parityMatchScore before completion" }, 422);
1400
1539
  }
1540
+ // A task in review is the worker's finished work waiting for a person.
1541
+ // Reporting completed again (the agent and its driver both close it) is a
1542
+ // no-op; anything else would overwrite what the reviewer is looking at.
1543
+ if (task.status === IN_REVIEW) {
1544
+ if (reported !== "completed") return response({ error: "Task is waiting for review" }, 409);
1545
+ return response({ updated: false, inReview: true, message: "Task is already waiting for review.", task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
1546
+ }
1547
+ // Completion review follows the approval mode, the same risk model as
1548
+ // starting: the worker says completed, the project decides whether a
1549
+ // person looks at it first.
1550
+ const mode = reported === "completed"
1551
+ ? approvalModeOf((await env.DB.prepare("SELECT task_approval_mode AS mode FROM projects WHERE id = ?").bind(task.projectId).first<{ mode: string }>())?.mode) ?? DEFAULT_APPROVAL_MODE
1552
+ : DEFAULT_APPROVAL_MODE;
1553
+ const status = reported === "completed" ? completionStatus(mode, task.taskType) : reported;
1401
1554
  // Falling back to blocked schedules the next attempt rather than making
1402
1555
  // the task immediately eligible again on the worker's next 5s poll.
1403
1556
  const nextAttemptAt = status === "blocked" ? backoffUntil(task.attempts ?? 0) : null;
@@ -1409,7 +1562,7 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
1409
1562
  completed_at = CASE WHEN ? IN ('completed', 'error') THEN CURRENT_TIMESTAMP ELSE completed_at END
1410
1563
  WHERE id = ?
1411
1564
  `).bind(
1412
- status, new Date(Date.now() + 60_000).toISOString(),
1565
+ status, status === IN_REVIEW ? null : new Date(Date.now() + 60_000).toISOString(),
1413
1566
  typeof body?.progressMessage === "string" ? body.progressMessage : null,
1414
1567
  body?.continuation ? JSON.stringify(body.continuation) : null,
1415
1568
  typeof body?.parityMatchScore === "number" ? body.parityMatchScore : null,
@@ -1422,8 +1575,10 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
1422
1575
  if (status === "completed" && task.taskType === "discovery") {
1423
1576
  await fillDiscoveredEnvironmentUrl(env, task.projectId, task.description);
1424
1577
  }
1425
- await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status, ...body });
1426
- const terminal = status === "completed" || status === "error";
1578
+ await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, ...body, status });
1579
+ // In review, the worker's part is over: it stops being metered and frees
1580
+ // its cloud slot, though the task isn't completed until someone accepts it.
1581
+ const terminal = status === "completed" || status === "error" || status === IN_REVIEW;
1427
1582
  if (status !== task.status) {
1428
1583
  const event = {
1429
1584
  taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode,
@@ -1440,34 +1595,170 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
1440
1595
  }
1441
1596
  return response({
1442
1597
  updated: true,
1598
+ ...(status === IN_REVIEW ? { inReview: true, message: inReviewMessage(mode) } : {}),
1443
1599
  task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first(),
1444
1600
  ...(stop ? { stop: true, reason: stop.reason, message: stop.message } : {}),
1445
1601
  });
1446
1602
  }
1447
1603
 
1604
+ // Reviewing is a person's call on work an AI worker says it finished. Only a
1605
+ // person may make it — never an API key or runner, or an agent could accept
1606
+ // its own work — and only someone who can manage the project.
1607
+ async function reviewTarget(env: Env, principal: Principal, taskId: string) {
1608
+ if (principal.apiKeyId || principal.runner) return { error: response({ error: "Only a person can review a task" }, 403) };
1609
+ const task = await env.DB.prepare(`
1610
+ SELECT project_id AS projectId, status, task_type AS taskType, description,
1611
+ execution_mode AS executionMode, workspace_id AS workspaceId, approved_at AS approvedAt
1612
+ FROM agent_tasks WHERE id = ?
1613
+ `).bind(taskId).first<{ projectId: string; status: string; taskType: string; description: string | null; executionMode: "self" | "cloud"; workspaceId: string | null; approvedAt: string | null }>();
1614
+ if (!task || !(await memberOfProject(env, principal, task.projectId))) return { error: response({ error: "Task not found" }, 404) };
1615
+ if (!(await memberOfProject(env, principal, task.projectId, "project.manage"))) return { error: response({ error: "Only project managers can review tasks" }, 403) };
1616
+ if (task.status !== IN_REVIEW) return { error: response({ error: "Only a task in review can be accepted or sent back" }, 409) };
1617
+ return { task };
1618
+ }
1619
+
1620
+ async function acceptAgentTask(request: Request, env: Env, principal: Principal, taskId: string, ctx: ExecutionContext): Promise<Response> {
1621
+ const target = await reviewTarget(env, principal, taskId);
1622
+ if (target.error) return target.error;
1623
+ const { task } = target;
1624
+ const body = await requestJson(request);
1625
+ const note = typeof body?.note === "string" && body.note.trim() ? body.note.trim().slice(0, REVIEW_NOTE_MAX) : null;
1626
+ const reviewer = principal.email || principal.aegisId;
1627
+ const updated = await env.DB.prepare(`
1628
+ UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP,
1629
+ reviewed_at = CURRENT_TIMESTAMP, reviewed_by = ?, review_note = ?
1630
+ WHERE id = ? AND status = ?
1631
+ `).bind(reviewer, note, taskId, IN_REVIEW).run();
1632
+ if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Only a task in review can be accepted or sent back" }, 409);
1633
+ if (task.taskType === "discovery") await fillDiscoveredEnvironmentUrl(env, task.projectId, task.description);
1634
+ await auditProject(env, principal, task.projectId, { action: "task.review_accepted", targetType: "task", targetId: taskId, ...(note ? { data: { note } } : {}) }, request);
1635
+ await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "completed" });
1636
+ const event = {
1637
+ taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode,
1638
+ // Already metered when it went into review; fromDispatcher keeps the
1639
+ // metering hook from dispatching again for a slot freed back then.
1640
+ status: "completed", previousStatus: IN_REVIEW, terminal: true, fromDispatcher: true,
1641
+ };
1642
+ for (const hook of taskChangedHooks) ctx.waitUntil(hook(env, event));
1643
+ return response({ accepted: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
1644
+ }
1645
+
1646
+ async function sendBackAgentTask(request: Request, env: Env, principal: Principal, taskId: string, ctx: ExecutionContext): Promise<Response> {
1647
+ const target = await reviewTarget(env, principal, taskId);
1648
+ if (target.error) return target.error;
1649
+ const { task } = target;
1650
+ const body = await requestJson(request);
1651
+ const note = typeof body?.note === "string" ? body.note.trim().slice(0, REVIEW_NOTE_MAX) : "";
1652
+ if (!note) return response({ error: "Say what needs to change in note" }, 422);
1653
+ const fields = sentBackTaskFields(note);
1654
+ const reviewer = principal.email || principal.aegisId;
1655
+ // The approval stands: the reviewer is asking for more of the same work.
1656
+ const updated = await env.DB.prepare(`
1657
+ UPDATE agent_tasks SET status = ?, attempts = ?, next_attempt_at = NULL, lease_expires_at = NULL,
1658
+ worker_id = NULL, error_message = NULL, completed_at = NULL,
1659
+ progress_message = ?, heartbeat_at = CURRENT_TIMESTAMP,
1660
+ claimed_at = NULL, billed_at = NULL, billed_minutes = NULL,
1661
+ blocked_reason = NULL, dispatch_attempts = 0, next_dispatch_at = NULL,
1662
+ reviewed_at = CURRENT_TIMESTAMP, reviewed_by = ?, review_note = ?
1663
+ WHERE id = ? AND status = ?
1664
+ `).bind(fields.status, fields.attempts, fields.progressMessage, reviewer, fields.reviewNote, taskId, IN_REVIEW).run();
1665
+ if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Only a task in review can be accepted or sent back" }, 409);
1666
+ await auditProject(env, principal, task.projectId, { action: "task.review_sent_back", targetType: "task", targetId: taskId, data: { note } }, request);
1667
+ await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "queued", progressMessage: fields.progressMessage });
1668
+ if (task.approvedAt) {
1669
+ for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode }));
1670
+ }
1671
+ return response({ sentBack: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
1672
+ }
1673
+
1448
1674
  // The counterpart to cancelling: a human answering what a parked task asked
1449
1675
  // for and putting it back in the queue. Same trust boundary as cancel (no
1450
1676
  // worker-lease check — the worker that gave up is exactly who we are
1451
1677
  // overriding), and the attempt cap is deliberately not consulted, since
1452
1678
  // being over it is the normal reason to be here.
1453
- async function resumeAgentTask(request: Request, env: Env, principal: Principal, taskId: string): Promise<Response> {
1679
+ //
1680
+ // Resuming used to only flip the row back to queued. A cloud task then sat
1681
+ // until the next cron tick (the dispatcher was never told), and a task only a
1682
+ // machine can run went back into a queue nothing on a cloud project ever
1683
+ // reads. Now a cloud task is handed to the dispatcher as approving does, a
1684
+ // machine-only task on a cloud project with no machine connected is refused
1685
+ // with what to do instead, and a discovery can be moved to Bugmole Cloud with
1686
+ // `executionMode: "cloud"`. Approval is decided again by the project's mode
1687
+ // (see resumedApproval).
1688
+ async function resumeAgentTask(request: Request, env: Env, principal: Principal, taskId: string, ctx: ExecutionContext): Promise<Response> {
1454
1689
  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 }>();
1690
+ const task = await env.DB.prepare(`
1691
+ SELECT project_id AS projectId, status, lease_expires_at AS leaseExpiresAt, task_type AS taskType,
1692
+ execution_mode AS executionMode, workspace_id AS workspaceId, description,
1693
+ approved_at AS approvedAt, approved_by AS approvedBy
1694
+ FROM agent_tasks WHERE id = ?
1695
+ `).bind(taskId).first<{
1696
+ projectId: string; status: string; leaseExpiresAt: string | null; taskType: string; executionMode: "self" | "cloud";
1697
+ workspaceId: string | null; description: string | null; approvedAt: string | null; approvedBy: string | null;
1698
+ }>();
1457
1699
  if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
1458
1700
  if (!isResumableTask(task)) {
1459
- return response({ error: task.status === "completed" ? "Task is already finished" : "Task is still held by a live worker" }, 409);
1701
+ return response({
1702
+ error: task.status === "completed" ? "Task is already finished"
1703
+ : task.status === IN_REVIEW ? "Task is waiting for review; accept it or send it back"
1704
+ : "Task is still held by a live worker",
1705
+ }, 409);
1460
1706
  }
1461
1707
  const body = await requestJson(request);
1708
+ const project = await env.DB.prepare(`
1709
+ SELECT storage_mode AS storageMode, default_execution_mode AS defaultExecutionMode,
1710
+ local_worker_seen_at AS localWorkerSeenAt, task_approval_mode AS approvalMode
1711
+ FROM projects WHERE id = ?
1712
+ `).bind(task.projectId).first<{ storageMode: string; defaultExecutionMode: string; localWorkerSeenAt: string | null; approvalMode: string }>();
1713
+ let executionMode = task.executionMode;
1714
+ let workspaceId = task.workspaceId;
1715
+ const requested = (body as { executionMode?: unknown } | null)?.executionMode;
1716
+ if (requested !== undefined && requested !== executionMode) {
1717
+ // Moving to the cloud passes the same checks as starting there.
1718
+ if (requested !== "cloud") return response({ error: "A task can only be moved to Bugmole Cloud" }, 422);
1719
+ if (task.taskType !== "discovery") return response({ error: "Only discovery can run on Bugmole Cloud" }, 422);
1720
+ workspaceId = await workspaceOfProject(env, task.projectId);
1721
+ if (!workspaceId) return response({ error: "This project isn't in a workspace" }, 422);
1722
+ const storageProblem = await cloudStorageError(env, task.projectId);
1723
+ if (storageProblem) return storageProblem;
1724
+ const accountProblem = await cloudAccountError(env, principal, workspaceId);
1725
+ if (accountProblem) return accountProblem;
1726
+ const blocked = await checkCloudRun(env, workspaceId);
1727
+ if (blocked) return blocked;
1728
+ const discoveryUrl = extractDiscoveryUrl(task.description ?? "");
1729
+ if (!discoveryUrl) return response({ error: "Discovery needs an http(s) URL to explore" }, 422);
1730
+ const domainProblem = await requireVerifiedDomainForCloud(env, workspaceId, discoveryUrl);
1731
+ if (domainProblem) return domainProblem;
1732
+ executionMode = "cloud";
1733
+ }
1734
+ const placement = resumePlacement({ executionMode, taskType: task.taskType }, project ?? {});
1735
+ if (placement.kind === "stranded") {
1736
+ return response({ error: strandedTaskMessage(placement.canRunInCloud), code: "needs_local_worker", canRunInCloud: placement.canRunInCloud }, 409);
1737
+ }
1462
1738
  const note = typeof body?.note === "string" && body.note.trim() ? body.note.trim() : "Resumed by user";
1463
1739
  const fields = resumedTaskFields();
1740
+ const { approvedBy } = resumedApproval(task, approvalModeOf(project?.approvalMode) ?? DEFAULT_APPROVAL_MODE);
1741
+ // SET expressions read the row as it was, so a person's approval keeps its time.
1464
1742
  await env.DB.prepare(`
1465
1743
  UPDATE agent_tasks SET status = ?, attempts = ?, next_attempt_at = NULL, lease_expires_at = NULL,
1466
1744
  worker_id = NULL, error_message = NULL, completed_at = NULL,
1467
- progress_message = ?, heartbeat_at = CURRENT_TIMESTAMP
1745
+ progress_message = ?, heartbeat_at = CURRENT_TIMESTAMP,
1746
+ execution_mode = ?, workspace_id = ?,
1747
+ claimed_at = NULL, billed_at = NULL, billed_minutes = NULL,
1748
+ blocked_reason = NULL, dispatch_attempts = 0, next_dispatch_at = NULL,
1749
+ approved_at = CASE WHEN ? IS NULL THEN NULL
1750
+ WHEN approved_by IS ? AND approved_at IS NOT NULL THEN approved_at
1751
+ ELSE CURRENT_TIMESTAMP END,
1752
+ approved_by = ?
1468
1753
  WHERE id = ?
1469
- `).bind(fields.status, fields.attempts, note, taskId).run();
1754
+ `).bind(fields.status, fields.attempts, note, executionMode, workspaceId, approvedBy, approvedBy, approvedBy, taskId).run();
1470
1755
  await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: fields.status, progressMessage: note });
1756
+ // A cloud task is only ever started by the dispatcher, so tell it now
1757
+ // rather than leaving the task to the next cron tick. One that waits for
1758
+ // approval again is handed over when someone approves it.
1759
+ if (approvedBy) {
1760
+ for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId: task.projectId, workspaceId, executionMode }));
1761
+ }
1471
1762
  return response({ resumed: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
1472
1763
  }
1473
1764
 
@@ -1476,6 +1767,29 @@ async function resumeAgentTask(request: Request, env: Env, principal: Principal,
1476
1767
  // lease check (`body.workerId` must match `worker_id`) deliberately doesn't
1477
1768
  // apply here, since the whole point is overriding a task that's stuck
1478
1769
  // (claimed by a worker that died, or never should have been claimed).
1770
+ // Approving is a person's go-ahead for a task the project's approval mode held
1771
+ // back. Like cancel and resume it is a human action on the task, so it needs
1772
+ // project management rights rather than a worker lease.
1773
+ async function approveAgentTask(request: Request, env: Env, principal: Principal, taskId: string, ctx: ExecutionContext): Promise<Response> {
1774
+ if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task approval is not allowed" }, 403);
1775
+ const task = await env.DB.prepare(`
1776
+ SELECT project_id AS projectId, status, approved_at AS approvedAt, execution_mode AS executionMode, workspace_id AS workspaceId
1777
+ FROM agent_tasks WHERE id = ?
1778
+ `).bind(taskId).first<{ projectId: string; status: string; approvedAt: string | null; executionMode: "self" | "cloud"; workspaceId: string | null }>();
1779
+ if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
1780
+ if (!(await memberOfProject(env, principal, task.projectId, "project.manage"))) return response({ error: "Only project managers can approve tasks" }, 403);
1781
+ if (task.approvedAt) return response({ error: "Task is already approved" }, 409);
1782
+ if (!["queued", "blocked"].includes(task.status)) return response({ error: "Only a waiting task can be approved" }, 409);
1783
+ const approvedBy = principal.email || principal.aegisId;
1784
+ await env.DB.prepare("UPDATE agent_tasks SET approved_at = CURRENT_TIMESTAMP, approved_by = ? WHERE id = ? AND approved_at IS NULL")
1785
+ .bind(approvedBy, taskId).run();
1786
+ await auditProject(env, principal, task.projectId, { action: "task.approved", targetType: "task", targetId: taskId }, request);
1787
+ await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: task.status });
1788
+ // A cloud task was never handed to a runner while it waited; do that now.
1789
+ for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode }));
1790
+ return response({ approved: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
1791
+ }
1792
+
1479
1793
  async function cancelAgentTask(request: Request, env: Env, principal: Principal, taskId: string): Promise<Response> {
1480
1794
  if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task cancellation is not allowed" }, 403);
1481
1795
  const task = await env.DB.prepare("SELECT project_id AS projectId, status FROM agent_tasks WHERE id = ?")
@@ -2006,9 +2320,10 @@ const registryWorker = {
2006
2320
  if (artifactsMatch && request.method === "GET") {
2007
2321
  const projectId = decodeURIComponent(artifactsMatch[1]);
2008
2322
  if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
2323
+ const visible = await runEvidenceVisibility(env, projectId);
2009
2324
  return artifactsMatch[2]
2010
- ? getProjectArtifact(env.ARTIFACTS, projectId, url)
2011
- : listProjectArtifacts(env.ARTIFACTS, projectId, url);
2325
+ ? getProjectArtifact(env.ARTIFACTS, projectId, url, visible)
2326
+ : listProjectArtifacts(env.ARTIFACTS, projectId, url, visible);
2012
2327
  }
2013
2328
  const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
2014
2329
  if (runMatch && request.method === "GET") return getRun(env, principal, decodeURIComponent(runMatch[1]));
@@ -2016,14 +2331,20 @@ const registryWorker = {
2016
2331
  const claimMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/claim$/);
2017
2332
  if (claimMatch && request.method === "POST") return claimRun(request, env, principal, decodeURIComponent(claimMatch[1]), context);
2018
2333
  const tasksMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/tasks$/);
2019
- if (tasksMatch && request.method === "GET") return listAgentTasks(env, principal, decodeURIComponent(tasksMatch[1]));
2334
+ if (tasksMatch && request.method === "GET") return listAgentTasks(env, principal, decodeURIComponent(tasksMatch[1]), context);
2020
2335
  if (tasksMatch && request.method === "POST") return createAgentTask(request, env, principal, decodeURIComponent(tasksMatch[1]), context);
2021
2336
  const taskClaimMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/claim$/);
2022
2337
  if (taskClaimMatch && request.method === "POST") return claimAgentTask(request, env, principal, decodeURIComponent(taskClaimMatch[1]));
2023
2338
  const taskCancelMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
2024
2339
  if (taskCancelMatch && request.method === "POST") return cancelAgentTask(request, env, principal, decodeURIComponent(taskCancelMatch[1]));
2340
+ const taskApproveMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/approve$/);
2341
+ if (taskApproveMatch && request.method === "POST") return approveAgentTask(request, env, principal, decodeURIComponent(taskApproveMatch[1]), context);
2342
+ const taskAcceptMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/accept$/);
2343
+ if (taskAcceptMatch && request.method === "POST") return acceptAgentTask(request, env, principal, decodeURIComponent(taskAcceptMatch[1]), context);
2344
+ const taskSendBackMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/send-back$/);
2345
+ if (taskSendBackMatch && request.method === "POST") return sendBackAgentTask(request, env, principal, decodeURIComponent(taskSendBackMatch[1]), context);
2025
2346
  const taskResumeMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/resume$/);
2026
- if (taskResumeMatch && request.method === "POST") return resumeAgentTask(request, env, principal, decodeURIComponent(taskResumeMatch[1]));
2347
+ if (taskResumeMatch && request.method === "POST") return resumeAgentTask(request, env, principal, decodeURIComponent(taskResumeMatch[1]), context);
2027
2348
  const taskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
2028
2349
  if (taskMatch && request.method === "POST") return updateAgentTask(request, env, principal, decodeURIComponent(taskMatch[1]), context);
2029
2350
  if (request.method === "GET" && url.pathname === "/api/device-sessions") return listDeviceSessions(env, principal);