@bugmole/cli 0.5.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 (153) hide show
  1. package/package.json +5 -2
  2. package/scripts/bugmole-admin.mjs +194 -0
  3. package/scripts/bugmole-admin.test.mjs +62 -0
  4. package/scripts/bugmole.test.ts +94 -2
  5. package/scripts/bugmole.ts +233 -46
  6. package/scripts/sync-byok.d.mts +2 -0
  7. package/scripts/sync-byok.mjs +18 -0
  8. package/scripts/sync-plan-catalog.d.mts +3 -0
  9. package/scripts/sync-plan-catalog.mjs +16 -6
  10. package/spec/domain_rules.yaml +139 -0
  11. package/spec/roles.yaml +20 -0
  12. package/spec/test-case-results.schema.json +20 -4
  13. package/spec/test-cases.schema.json +131 -16
  14. package/src/billing/plan-catalog.test.ts +82 -2
  15. package/src/billing/plan-catalog.ts +212 -9
  16. package/src/integrations/jira.ts +264 -0
  17. package/src/mcp/roles-and-review.test.ts +111 -0
  18. package/src/mcp/server.ts +358 -22
  19. package/src/mcp/write-test-cases.test.ts +68 -0
  20. package/src/registry/control-plane-client.ts +121 -6
  21. package/src/registry/migrations/0036_task_approval.sql +11 -0
  22. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  23. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  24. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  25. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  26. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  27. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  28. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  29. package/src/registry/migrations/0044_project_identity.sql +31 -0
  30. package/src/registry/project-identity.test.ts +99 -0
  31. package/src/registry/project-identity.ts +206 -0
  32. package/src/registry/roles.test.ts +66 -0
  33. package/src/registry/roles.ts +201 -0
  34. package/src/registry/task-scheduling.test.ts +94 -0
  35. package/src/registry/task-scheduling.ts +199 -2
  36. package/src/registry/test-case-revisions.test.ts +57 -0
  37. package/src/registry/test-case-revisions.ts +132 -0
  38. package/src/registry-worker/ai/routes.ts +3 -3
  39. package/src/registry-worker/artifacts.ts +23 -4
  40. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  41. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  42. package/src/registry-worker/billing/enforcement.ts +67 -12
  43. package/src/registry-worker/billing/paypal/api.ts +14 -0
  44. package/src/registry-worker/billing/paypal/client.ts +9 -0
  45. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  46. package/src/registry-worker/billing/paypal.test.ts +108 -1
  47. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  48. package/src/registry-worker/billing/provider.ts +8 -0
  49. package/src/registry-worker/billing/routes.ts +2 -1
  50. package/src/registry-worker/billing/subscriptions.ts +114 -4
  51. package/src/registry-worker/core.ts +22 -0
  52. package/src/registry-worker/devices/policy.ts +4 -5
  53. package/src/registry-worker/devices/routes.ts +8 -8
  54. package/src/registry-worker/feature-access.ts +165 -0
  55. package/src/registry-worker/feature-flags/admin.ts +300 -0
  56. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  57. package/src/registry-worker/feature-flags/routes.ts +102 -0
  58. package/src/registry-worker/features.ts +9 -0
  59. package/src/registry-worker/feedback/routes.ts +2 -2
  60. package/src/registry-worker/flags.ts +80 -18
  61. package/src/registry-worker/github/checks.ts +4 -4
  62. package/src/registry-worker/hooks.ts +9 -0
  63. package/src/registry-worker/identity/oidc.test.ts +103 -0
  64. package/src/registry-worker/identity/oidc.ts +176 -0
  65. package/src/registry-worker/index.ts +477 -174
  66. package/src/registry-worker/jira/connection.ts +111 -0
  67. package/src/registry-worker/jira/jira.test.ts +404 -0
  68. package/src/registry-worker/jira/routes.ts +432 -0
  69. package/src/registry-worker/jira/workflow.ts +577 -0
  70. package/src/registry-worker/jobs/retention.ts +21 -5
  71. package/src/registry-worker/mcp/tools.ts +2 -1
  72. package/src/registry-worker/notifications/alerts.ts +4 -4
  73. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  74. package/src/registry-worker/notifications/routes.ts +9 -10
  75. package/src/registry-worker/notifications/teams.ts +6 -2
  76. package/src/registry-worker/org/routes.ts +4 -4
  77. package/src/registry-worker/projects/identity.ts +194 -0
  78. package/src/registry-worker/projects/inactivity.ts +162 -0
  79. package/src/registry-worker/projects/projects.test.ts +283 -0
  80. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  81. package/src/registry-worker/proposals/routes.ts +183 -0
  82. package/src/registry-worker/roles/roles.test.ts +84 -0
  83. package/src/registry-worker/roles/routes.ts +141 -0
  84. package/src/registry-worker/runner/dispatch.ts +1 -0
  85. package/src/registry-worker/runner/routes.ts +22 -5
  86. package/src/registry-worker/runner/runner.test.ts +21 -0
  87. package/src/registry-worker/runner/tokens.ts +8 -0
  88. package/src/registry-worker/secrets/crypto.ts +135 -0
  89. package/src/registry-worker/secrets/routes.ts +296 -0
  90. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  91. package/src/registry-worker/signup/policy.ts +2 -2
  92. package/src/registry-worker/signup/routes.ts +12 -3
  93. package/src/registry-worker/sso/membership.ts +4 -2
  94. package/src/registry-worker/sso/routes.ts +24 -8
  95. package/src/registry-worker/sso/sso.test.ts +3 -2
  96. package/src/registry-worker/task-approval.test.ts +67 -0
  97. package/src/registry-worker/task-resume.test.ts +196 -0
  98. package/src/registry-worker/task-review.test.ts +121 -0
  99. package/src/runtime/appium-driver.ts +1 -1
  100. package/src/runtime/apply-proposals.test.ts +74 -0
  101. package/src/runtime/apply-proposals.ts +41 -0
  102. package/src/runtime/cloud-secrets.test.ts +201 -0
  103. package/src/runtime/cloud-secrets.ts +210 -0
  104. package/src/runtime/config-validate.ts +12 -9
  105. package/src/runtime/cursor-driver-run.ts +14 -3
  106. package/src/runtime/discovery-task.test.ts +26 -1
  107. package/src/runtime/discovery-task.ts +67 -6
  108. package/src/runtime/executor.ts +18 -10
  109. package/src/runtime/explorer.test.ts +32 -0
  110. package/src/runtime/explorer.ts +48 -0
  111. package/src/runtime/flow-language.ts +22 -8
  112. package/src/runtime/init-wizard.ts +112 -6
  113. package/src/runtime/journey-editor.ts +39 -2
  114. package/src/runtime/journey-graph.test.ts +18 -0
  115. package/src/runtime/local-registry-stub.test.ts +64 -0
  116. package/src/runtime/local-registry-stub.ts +255 -7
  117. package/src/runtime/local-vault.ts +65 -0
  118. package/src/runtime/pipeline.test.ts +23 -0
  119. package/src/runtime/pipeline.ts +61 -20
  120. package/src/runtime/planner.test.ts +24 -1
  121. package/src/runtime/planner.ts +88 -23
  122. package/src/runtime/playwright-driver.test.ts +21 -2
  123. package/src/runtime/playwright-driver.ts +49 -7
  124. package/src/runtime/project-identity.test.ts +161 -0
  125. package/src/runtime/project-identity.ts +232 -0
  126. package/src/runtime/project-roles.test.ts +107 -0
  127. package/src/runtime/project-roles.ts +158 -0
  128. package/src/runtime/propose-cli.ts +66 -0
  129. package/src/runtime/record-run-verdicts.test.ts +58 -0
  130. package/src/runtime/record-run-verdicts.ts +38 -4
  131. package/src/runtime/reporter.ts +1 -1
  132. package/src/runtime/reset.ts +2 -0
  133. package/src/runtime/roles-cli.test.ts +47 -0
  134. package/src/runtime/roles-cli.ts +60 -0
  135. package/src/runtime/run-once.ts +68 -5
  136. package/src/runtime/scenario-matrix.test.ts +41 -0
  137. package/src/runtime/scenario-matrix.ts +98 -0
  138. package/src/runtime/secret-driver.ts +77 -0
  139. package/src/runtime/secret-redaction.ts +69 -0
  140. package/src/runtime/secret-sources.test.ts +442 -0
  141. package/src/runtime/secret-sources.ts +176 -0
  142. package/src/runtime/secrets-cli.ts +146 -0
  143. package/src/runtime/serve-worker.ts +64 -6
  144. package/src/runtime/site-discovery.test.ts +47 -4
  145. package/src/runtime/site-discovery.ts +79 -9
  146. package/src/runtime/vault-federation.test.ts +37 -0
  147. package/src/runtime/vault-federation.ts +128 -0
  148. package/src/runtime/web-suite.ts +5 -2
  149. package/src/storage/create-object-store.ts +5 -1
  150. package/src/storage/object-store.ts +12 -1
  151. package/src/storage/test-case-results.test.ts +64 -0
  152. package/src/storage/test-case-results.ts +49 -0
  153. 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,29 +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
- -- A project that already has an environment keeps it: pairing
188
- -- another machine must not add a same-named twin beside it.
189
- INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
190
- SELECT ?, ?, 'default', ?, '', 'active', 1
191
- WHERE NOT EXISTS (SELECT 1 FROM project_environments WHERE project_id = ?)
192
- `).bind(environmentId, attempt.projectId, attempt.environment, attempt.projectId),
193
- env.DB.prepare(`
194
- INSERT INTO project_memberships (project_id, aegis_id, role, status)
195
- VALUES (?, ?, 'owner', 'active')
196
- ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
197
- `).bind(attempt.projectId, principal.aegisId),
299
+ ...projectRows,
198
300
  env.DB.prepare(`
199
301
  UPDATE device_authorizations SET status = 'completed', aegis_id = ?, api_key_id = ?, completed_at = ?
200
302
  WHERE attempt_id = ? AND status = 'claimed'
@@ -204,7 +306,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
204
306
  await releaseClaimedDeviceAuthorization(env, body.attemptId);
205
307
  throw error;
206
308
  }
207
- return response({ claimed: true, alreadyInitialized: true }, 200);
309
+ return response({ claimed: true, alreadyInitialized: true, projectId, joined: placement.joined }, 200);
208
310
  }
209
311
 
210
312
  const id = `key_${randomToken(12)}`;
@@ -223,32 +325,12 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
223
325
  VALUES (?, ?, 'owner', 'active')
224
326
  ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET status = 'active'
225
327
  `).bind(workspaceId, principal.aegisId),
226
- env.DB.prepare(`
227
- INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
228
- VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
229
- ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
230
- storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
231
- `).bind(attempt.projectId, workspaceId, workspaceId, attempt.projectName, attempt.environment, currentStorageProvider(attempt.storageProvider)),
232
- env.DB.prepare(`
233
- INSERT INTO project_apps (id, project_id, name, type, is_default)
234
- VALUES ('default', ?, ?, 'web', 1)
235
- ON CONFLICT(project_id, id) DO UPDATE SET name = excluded.name
236
- `).bind(attempt.projectId, attempt.projectName),
237
- env.DB.prepare(`
238
- INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
239
- SELECT ?, ?, 'default', ?, '', 'active', 1
240
- WHERE NOT EXISTS (SELECT 1 FROM project_environments WHERE project_id = ?)
241
- `).bind(environmentId, attempt.projectId, attempt.environment, attempt.projectId),
242
- env.DB.prepare(`
243
- INSERT INTO project_memberships (project_id, aegis_id, role, status)
244
- VALUES (?, ?, 'owner', 'active')
245
- ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
246
- `).bind(attempt.projectId, principal.aegisId),
328
+ ...projectRows,
247
329
  env.DB.prepare(`
248
330
  INSERT INTO api_keys (id, aegis_id, name, token_hash, scopes_json, project_ids_json, expires_at)
249
331
  VALUES (?, ?, ?, ?, ?, ?, NULL)
250
332
  `).bind(id, principal.aegisId, `${attempt.projectName} CLI`, await sha256(token),
251
- 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])),
252
334
  env.DB.prepare(`
253
335
  UPDATE device_authorizations
254
336
  SET status = 'completed', aegis_id = ?, api_key_id = ?, encrypted_token = ?, token_iv = ?, completed_at = ?
@@ -259,7 +341,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
259
341
  await releaseClaimedDeviceAuthorization(env, body.attemptId);
260
342
  throw error;
261
343
  }
262
- return response({ claimed: true, alreadyInitialized: false }, 200);
344
+ return response({ claimed: true, alreadyInitialized: false, projectId, joined: placement.joined }, 200);
263
345
  }
264
346
 
265
347
  async function releaseClaimedDeviceAuthorization(env: Env, attemptId: string): Promise<void> {
@@ -285,15 +367,18 @@ async function pollDeviceAuthorization(request: Request, env: Env): Promise<Resp
285
367
  if (!body?.attemptId || !body.pollSecret) return response({ error: "attemptId and pollSecret are required" }, 422);
286
368
  const attempt = await env.DB.prepare(`
287
369
  SELECT status, expires_at AS expiresAt, encrypted_token AS encryptedToken, token_iv AS tokenIv,
288
- 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
289
372
  FROM device_authorizations WHERE attempt_id = ?
290
373
  `).bind(body.attemptId).first<{
374
+ projectId: string;
291
375
  status: string;
292
376
  expiresAt: string;
293
377
  encryptedToken: string | null;
294
378
  tokenIv: string | null;
295
379
  pollSecretHash: string;
296
380
  aegisEmail: string | null;
381
+ refusalJson: string | null;
297
382
  }>();
298
383
  if (!attempt || !constantTimeEqual(await sha256(body.pollSecret), attempt.pollSecretHash)) {
299
384
  return response({ error: "Device authorization not found" }, 404);
@@ -304,92 +389,22 @@ async function pollDeviceAuthorization(request: Request, env: Env): Promise<Resp
304
389
  return response({ status: "expired" }, 410);
305
390
  }
306
391
  if (attempt.status === "pending") return response({ status: "pending" }, 202);
307
- 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);
308
397
  const result = await env.DB.prepare(`
309
398
  UPDATE device_authorizations SET encrypted_token = NULL, token_iv = NULL
310
399
  WHERE attempt_id = ? AND status = 'completed' AND encrypted_token = ?
311
400
  `).bind(body.attemptId, attempt.encryptedToken).run() as { meta?: { changes?: number } };
312
- 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);
313
402
  const token = await decryptForSecret(
314
403
  attempt.encryptedToken,
315
404
  attempt.tokenIv,
316
405
  await sha256(body.pollSecret),
317
406
  );
318
- return response({ status: "completed", alreadyInitialized: false, token, email: attempt.aegisEmail }, 200);
319
- }
320
-
321
- async function registerProject(request: Request, env: Env, principal: Principal): Promise<Response> {
322
- if (!can(principal, "projects:write")) return response({ error: "Project registration is not allowed" }, 403);
323
- const body = await request.json().catch(() => null) as {
324
- id?: string;
325
- name?: string;
326
- appId?: string;
327
- appName?: string;
328
- workspaceId?: string;
329
- workspaceName?: string;
330
- tenantId?: string;
331
- environment?: string;
332
- storageProvider?: string;
333
- } | null;
334
- if (!body?.id || !body.name || !body.environment || !body.storageProvider) {
335
- return response({ error: "id, name, environment, and storageProvider are required" }, 422);
336
- }
337
- if (!projectAllowed(principal, body.id)) return response({ error: "Project scope is not allowed" }, 403);
338
- const workspaceId = body.workspaceId ?? body.tenantId ?? `workspace_${principal.aegisId}`;
339
- const tenantId = body.tenantId ?? workspaceId;
340
- const existingWorkspace = await env.DB.prepare(
341
- "SELECT id FROM workspaces WHERE id = ?",
342
- ).bind(workspaceId).first<{ id: string }>();
343
- if (existingWorkspace && !(await memberOfWorkspace(env, principal, workspaceId, "workspace.write"))) {
344
- return response({ error: "Workspace scope is not allowed" }, 403);
345
- }
346
- // Re-registering a project is how the CLI refreshes it, but only for people
347
- // who may manage it; otherwise an id could be used to move someone else's
348
- // project into another workspace.
349
- const existingProject = await env.DB.prepare("SELECT workspace_id AS workspaceId FROM projects WHERE id = ?")
350
- .bind(body.id).first<{ workspaceId: string }>();
351
- if (existingProject && !(await memberOfProject(env, principal, body.id, "project.manage"))) {
352
- return response({ error: "A project with this id already exists" }, 409);
353
- }
354
- if (existingProject && existingProject.workspaceId !== workspaceId) {
355
- return response({ error: "This project belongs to another workspace" }, 409);
356
- }
357
- const joinedAt = new Date().toISOString();
358
- await env.DB.batch([
359
- env.DB.prepare(`
360
- INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING
361
- `).bind(principal.aegisId),
362
- env.DB.prepare(`
363
- INSERT INTO workspaces (id, name, created_by)
364
- VALUES (?, ?, ?)
365
- ON CONFLICT(id) DO NOTHING
366
- `).bind(workspaceId, body.workspaceName ?? workspaceId, principal.aegisId),
367
- // Only the creator of a brand-new workspace becomes its owner; an existing
368
- // membership (including a removed one) is left alone.
369
- env.DB.prepare(`
370
- INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status, created_at)
371
- SELECT ?, ?, 'owner', 'active', ?
372
- WHERE NOT EXISTS (SELECT 1 FROM workspace_memberships WHERE workspace_id = ?)
373
- `).bind(workspaceId, principal.aegisId, joinedAt, workspaceId),
374
- env.DB.prepare(`
375
- INSERT INTO project_apps (id, project_id, name, type, is_default)
376
- VALUES (?, ?, ?, 'web', 1)
377
- ON CONFLICT(project_id, id) DO NOTHING
378
- `).bind(body.appId?.trim() || "default", body.id, body.appName?.trim() || body.name),
379
- env.DB.prepare(`
380
- INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
381
- VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
382
- ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
383
- storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
384
- `).bind(body.id, workspaceId, tenantId, body.name, body.environment, currentStorageProvider(body.storageProvider)),
385
- env.DB.prepare(`
386
- INSERT INTO project_memberships (project_id, aegis_id, role, status)
387
- VALUES (?, ?, 'owner', 'active')
388
- ON CONFLICT(project_id, aegis_id) DO NOTHING
389
- `).bind(body.id, principal.aegisId),
390
- ]);
391
- await auditProject(env, principal, body.id, { action: "project.registered", targetType: "project", targetId: body.id, data: { environment: body.environment } }, request);
392
- 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);
393
408
  }
394
409
 
395
410
  async function listWorkspaces(env: Env, principal: Principal): Promise<Response> {
@@ -402,7 +417,7 @@ async function listWorkspaces(env: Env, principal: Principal): Promise<Response>
402
417
  `).bind(principal.aegisId).all();
403
418
  const workspaces = await Promise.all(rows.results.map(async (workspace) => {
404
419
  const projects = await env.DB.prepare(`
405
- 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
406
421
  FROM projects p
407
422
  WHERE p.workspace_id = ? AND p.deleted_at IS NULL
408
423
  ORDER BY p.created_at ASC
@@ -463,6 +478,10 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
463
478
  if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Project creation is not allowed" }, 403);
464
479
  const body = await request.json().catch(() => null) as {
465
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;
466
485
  } | null;
467
486
  const projectId = body?.id?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
468
487
  const name = body?.name?.trim();
@@ -471,14 +490,32 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
471
490
  return response({ error: "id, name, workspaceId, environment, and storageProvider are required" }, 422);
472
491
  }
473
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
+ }
474
510
  const projectLimit = await checkProjectLimit(env, workspaceId);
475
511
  if (projectLimit) return projectLimit;
476
512
  try {
477
513
  await env.DB.batch([
478
514
  env.DB.prepare(`
479
- INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode, created_at)
480
- VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
481
- `).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)),
482
519
  env.DB.prepare("INSERT INTO project_memberships (project_id, aegis_id, role, status) VALUES (?, ?, 'owner', 'active')")
483
520
  .bind(projectId, principal.aegisId),
484
521
  env.DB.prepare("INSERT INTO project_apps (id, project_id, name, type, is_default) VALUES ('default', ?, ?, 'web', 1)")
@@ -487,8 +524,12 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
487
524
  } catch {
488
525
  return response({ error: "A project with this id already exists" }, 409);
489
526
  }
490
- await auditProject(env, principal, projectId, { action: "project.created", targetType: "project", targetId: projectId }, request);
491
- 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);
492
533
  }
493
534
 
494
535
  async function updateProject(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
@@ -588,7 +629,8 @@ async function listProjects(env: Env, principal: Principal): Promise<Response> {
588
629
  END AS workspaceName,
589
630
  p.tenant_id AS tenantId, p.name, p.environment,
590
631
  p.storage_provider AS storageProvider, p.storage_mode AS storageMode,
591
- 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
592
634
  FROM projects p
593
635
  JOIN workspace_memberships wm ON wm.workspace_id = p.workspace_id
594
636
  JOIN workspaces w ON w.id = p.workspace_id
@@ -624,8 +666,10 @@ async function listProjects(env: Env, principal: Principal): Promise<Response> {
624
666
  status: string;
625
667
  isDefault: number;
626
668
  }>();
669
+ const { identityKind, identityKey, ...rest } = project as Record<string, unknown> & { identityKind?: string | null; identityKey?: string | null };
627
670
  return {
628
- ...project,
671
+ ...rest,
672
+ identity: identityView({ identityKind, identityKey }),
629
673
  apps: apps.results.map((app) => ({
630
674
  ...app,
631
675
  location: app.location ?? undefined,
@@ -724,6 +768,9 @@ async function createEnvironment(
724
768
  "SELECT id FROM project_environments WHERE project_id = ? AND COALESCE(app_id, 'default') = ? AND lower(trim(name)) = lower(?)",
725
769
  ).bind(projectId, appId, body.name.trim()).first<{ id: string }>();
726
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;
727
774
  try {
728
775
  await env.DB.prepare(`
729
776
  INSERT INTO project_environments (id, project_id, app_id, name, base_url, api_url)
@@ -780,6 +827,9 @@ async function updateEnvironment(
780
827
  .bind(projectId, body.appId.trim()).first<{ id: string }>();
781
828
  if (!app) return response({ error: "App does not belong to this project" }, 422);
782
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;
783
833
  const result = await env.DB.prepare(`
784
834
  UPDATE project_environments
785
835
  SET app_id = COALESCE(?, app_id), name = ?, base_url = ?, api_url = ?, status = COALESCE(?, status),
@@ -918,7 +968,7 @@ function parseJsonColumn(value: unknown): unknown {
918
968
  }
919
969
 
920
970
  function hydrateRun<T extends Record<string, unknown>>(row: T) {
921
- const { targetsJson, targetResultsJson, fixFromJson, ...rest } = row as Record<string, unknown>;
971
+ const { targetsJson, targetResultsJson, fixFromJson, caseScopeJson, ...rest } = row as Record<string, unknown>;
922
972
  const started = typeof rest.startedAt === "string" ? Date.parse(`${rest.startedAt.replace(" ", "T")}Z`) : NaN;
923
973
  const completed = typeof rest.completedAt === "string" ? Date.parse(`${rest.completedAt.replace(" ", "T")}Z`) : NaN;
924
974
  return {
@@ -926,10 +976,34 @@ function hydrateRun<T extends Record<string, unknown>>(row: T) {
926
976
  targets: parseJsonColumn(targetsJson),
927
977
  targetResults: parseJsonColumn(targetResultsJson),
928
978
  fixFrom: parseJsonColumn(fixFromJson),
979
+ caseScope: parseJsonColumn(caseScopeJson),
929
980
  durationMs: Number.isFinite(started) && Number.isFinite(completed) ? Math.max(0, completed - started) : null,
930
981
  };
931
982
  }
932
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
+
933
1007
  async function listRuns(env: Env, principal: Principal, projectId: string): Promise<Response> {
934
1008
  if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
935
1009
  const cutoff = await historyCutoffFor(env, await workspaceOfProject(env, projectId));
@@ -945,7 +1019,7 @@ async function listRuns(env: Env, principal: Principal, projectId: string): Prom
945
1019
  manifest_key AS manifestKey, requested_at AS requestedAt,
946
1020
  started_at AS startedAt, completed_at AS completedAt,
947
1021
  targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
948
- 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,
949
1023
  execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes,
950
1024
  trigger_source AS triggerSource, repo_full_name AS repoFullName, commit_sha AS commitSha, pr_number AS prNumber, check_run_id AS checkRunId,
951
1025
  gitlab_binding_id AS gitlabBindingId
@@ -959,6 +1033,8 @@ async function listRuns(env: Env, principal: Principal, projectId: string): Prom
959
1033
  async function createRun(request: Request, env: Env, principal: Principal, projectId: string, ctx: ExecutionContext, internal: InternalRunFields = {}): Promise<Response> {
960
1034
  if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Run creation is not allowed" }, 403);
961
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;
962
1038
  const body = await requestJson(request);
963
1039
  const planId = typeof body?.planId === "string" ? body.planId.trim() : "";
964
1040
  const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
@@ -1032,8 +1108,8 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
1032
1108
  const targets = matrix.targets.length > 0 ? matrix.targets : null;
1033
1109
  await env.DB.prepare(`
1034
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,
1035
- trigger_source, repo_full_name, commit_sha, pr_number, github_installation_id, gitlab_binding_id)
1036
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1111
+ trigger_source, repo_full_name, commit_sha, pr_number, github_installation_id, gitlab_binding_id, case_scope_json)
1112
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1037
1113
  `).bind(
1038
1114
  runId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, planId, draft.journeyId,
1039
1115
  Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
@@ -1051,6 +1127,7 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
1051
1127
  internal.prNumber ?? null,
1052
1128
  internal.githubInstallationId ?? null,
1053
1129
  internal.gitlabBindingId ?? null,
1130
+ internal.caseScope ? JSON.stringify(internal.caseScope) : null,
1054
1131
  ).run();
1055
1132
  await appendRunEvent(env, runId, "queued", { status: "queued", targets, environmentBaseUrl, executionMode });
1056
1133
  await broadcastProjectEvent(env, projectId, { type: "job_queued", runId, projectId, planId, executionMode });
@@ -1060,6 +1137,7 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
1060
1137
  run: {
1061
1138
  id: runId, projectId, planId, journeyId: draft.journeyId, status: "queued", executionMode,
1062
1139
  environmentId, environmentBaseUrl, targets, targetResults: null, durationMs: null, fixFrom,
1140
+ caseScope: internal.caseScope ?? null,
1063
1141
  },
1064
1142
  }, 201);
1065
1143
  }
@@ -1076,11 +1154,15 @@ async function getRun(env: Env, principal: Principal, runId: string): Promise<Re
1076
1154
  manifest_key AS manifestKey, requested_at AS requestedAt,
1077
1155
  started_at AS startedAt, completed_at AS completedAt,
1078
1156
  targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
1079
- 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,
1080
1158
  execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes
1081
1159
  FROM runs WHERE id = ?
1082
1160
  `).bind(runId).first<{ projectId: string } & Record<string, unknown>>();
1083
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
+ }
1084
1166
  const events = await env.DB.prepare(`
1085
1167
  SELECT sequence, type, payload_json AS payload, created_at AS createdAt
1086
1168
  FROM run_events WHERE run_id = ? ORDER BY sequence ASC
@@ -1095,10 +1177,12 @@ async function claimRun(request: Request, env: Env, principal: Principal, runId:
1095
1177
  if (!workerId) return response({ error: "workerId is required" }, 422);
1096
1178
  const run = await env.DB.prepare(`
1097
1179
  SELECT project_id AS projectId, targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
1098
- 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
1099
1181
  FROM runs WHERE id = ?
1100
- `).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 }>();
1101
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;
1102
1186
  // Bugmole Cloud runs are billed; only Bugmole's own runners may take them.
1103
1187
  if (run.executionMode === "cloud") return response({ error: "This run is queued for Bugmole Cloud" }, 409);
1104
1188
  const lease = new Date(Date.now() + 60_000).toISOString();
@@ -1123,6 +1207,7 @@ async function claimRun(request: Request, env: Env, principal: Principal, runId:
1123
1207
  environmentBaseUrl: run.environmentBaseUrl,
1124
1208
  targets: parseJsonColumn(run.targetsJson),
1125
1209
  fixFrom: parseJsonColumn(run.fixFromJson),
1210
+ caseScope: parseJsonColumn(run.caseScopeJson),
1126
1211
  });
1127
1212
  }
1128
1213
 
@@ -1208,16 +1293,35 @@ const taskSelect = `
1208
1293
  idempotency_key AS idempotencyKey, requested_at AS requestedAt,
1209
1294
  started_at AS startedAt, completed_at AS completedAt, device_session_id AS deviceSessionId,
1210
1295
  attempts, next_attempt_at AS nextAttemptAt,
1211
- 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
1212
1299
  FROM agent_tasks
1213
1300
  `;
1214
1301
 
1215
1302
 
1216
- 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> {
1217
1317
  if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
1218
- const rows = await env.DB.prepare(`${taskSelect} WHERE project_id = ? ORDER BY priority DESC, requested_at ASC LIMIT 100`)
1219
- .bind(projectId).all();
1220
- 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 });
1221
1325
  }
1222
1326
 
1223
1327
  function extractDiscoveryUrl(description: string): string | null {
@@ -1283,6 +1387,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1283
1387
  return response({ error: "Task creation is not allowed" }, 403);
1284
1388
  }
1285
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;
1286
1392
  const body = await requestJson(request) as {
1287
1393
  title?: string; description?: string; taskType?: string; idempotencyKey?: string; priority?: number;
1288
1394
  journeyId?: string; journeyRevision?: number; deviceSessionId?: string; stepId?: string;
@@ -1315,7 +1421,14 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1315
1421
  if (domainProblem) return domainProblem;
1316
1422
  }
1317
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);
1318
1429
  const queued = (taskId: string) => {
1430
+ // Waiting for approval isn't queued for a runner yet; approving it is.
1431
+ if (!approvedBy) return;
1319
1432
  for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId, workspaceId: taskWorkspaceId, executionMode }));
1320
1433
  };
1321
1434
  const existing = await env.DB.prepare(`${taskSelect} WHERE project_id = ? AND idempotency_key = ?`)
@@ -1335,7 +1448,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1335
1448
  attempts = 0, next_attempt_at = NULL,
1336
1449
  execution_mode = ?, workspace_id = ?, claimed_at = NULL, billed_at = NULL, billed_minutes = NULL,
1337
1450
  blocked_reason = NULL, dispatch_attempts = 0, next_dispatch_at = NULL,
1338
- 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 = ?
1339
1453
  WHERE id = ?
1340
1454
  `).bind(
1341
1455
  title, description, taskType,
@@ -1344,6 +1458,7 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1344
1458
  typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
1345
1459
  typeof body?.stepId === "string" ? body.stepId : null,
1346
1460
  executionMode, taskWorkspaceId,
1461
+ approvedBy, approvedBy,
1347
1462
  existing.id,
1348
1463
  ).run();
1349
1464
  await broadcastProjectEvent(env, projectId, { type: "task_event", taskId: existing.id, projectId, status: "queued" });
@@ -1355,8 +1470,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1355
1470
  const taskId = id("task");
1356
1471
  await env.DB.prepare(`
1357
1472
  INSERT INTO agent_tasks
1358
- (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)
1359
- 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, ?)
1360
1475
  `).bind(
1361
1476
  taskId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, title, description, taskType,
1362
1477
  Number.isFinite(Number(body?.priority)) ? Number(body?.priority) : 0, principal.aegisId, idempotencyKey,
@@ -1364,6 +1479,7 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
1364
1479
  typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
1365
1480
  typeof body?.stepId === "string" ? body.stepId : null,
1366
1481
  executionMode, taskWorkspaceId,
1482
+ approvedBy, approvedBy,
1367
1483
  ).run();
1368
1484
  await broadcastProjectEvent(env, projectId, { type: "task_event", taskId, projectId, status: "queued" });
1369
1485
  queued(taskId);
@@ -1380,6 +1496,8 @@ async function claimAgentTask(request: Request, env: Env, principal: Principal,
1380
1496
  .bind(taskId).first<{ projectId: string; executionMode: string }>();
1381
1497
  if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
1382
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;
1383
1501
  const lease = new Date(Date.now() + 60_000).toISOString();
1384
1502
  const deviceSessionId = typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null;
1385
1503
  // Claiming is what counts as an attempt: it's the only point both the
@@ -1393,8 +1511,11 @@ async function claimAgentTask(request: Request, env: Env, principal: Principal,
1393
1511
  WHERE id = ? AND attempts < ${MAX_TASK_ATTEMPTS}
1394
1512
  AND (next_attempt_at IS NULL OR next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
1395
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
1396
1515
  `).bind(workerId, lease, deviceSessionId, taskId).run() as { meta?: { changes?: number } };
1397
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);
1398
1519
  return response({ error: "Task is already claimed, complete, or has exhausted its attempts" }, 409);
1399
1520
  }
1400
1521
  await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "running", workerId });
@@ -1411,11 +1532,25 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
1411
1532
  `).bind(taskId).first<{ projectId: string; workerId: string | null; taskType: string; attempts: number; status: string; executionMode: string; workspaceId: string | null; description: string | null }>();
1412
1533
  if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
1413
1534
  if (task.workerId && body?.workerId !== task.workerId) return response({ error: "Worker lease does not match" }, 409);
1414
- const status = typeof body?.status === "string" ? body.status : "running";
1415
- if (!["running", "blocked", "completed", "error"].includes(status)) return response({ error: "Invalid task status" }, 422);
1416
- 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")) {
1417
1538
  return response({ error: "UI tasks require parityAuditId and parityMatchScore before completion" }, 422);
1418
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;
1419
1554
  // Falling back to blocked schedules the next attempt rather than making
1420
1555
  // the task immediately eligible again on the worker's next 5s poll.
1421
1556
  const nextAttemptAt = status === "blocked" ? backoffUntil(task.attempts ?? 0) : null;
@@ -1427,7 +1562,7 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
1427
1562
  completed_at = CASE WHEN ? IN ('completed', 'error') THEN CURRENT_TIMESTAMP ELSE completed_at END
1428
1563
  WHERE id = ?
1429
1564
  `).bind(
1430
- status, new Date(Date.now() + 60_000).toISOString(),
1565
+ status, status === IN_REVIEW ? null : new Date(Date.now() + 60_000).toISOString(),
1431
1566
  typeof body?.progressMessage === "string" ? body.progressMessage : null,
1432
1567
  body?.continuation ? JSON.stringify(body.continuation) : null,
1433
1568
  typeof body?.parityMatchScore === "number" ? body.parityMatchScore : null,
@@ -1440,8 +1575,10 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
1440
1575
  if (status === "completed" && task.taskType === "discovery") {
1441
1576
  await fillDiscoveredEnvironmentUrl(env, task.projectId, task.description);
1442
1577
  }
1443
- await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status, ...body });
1444
- 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;
1445
1582
  if (status !== task.status) {
1446
1583
  const event = {
1447
1584
  taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode,
@@ -1458,34 +1595,170 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
1458
1595
  }
1459
1596
  return response({
1460
1597
  updated: true,
1598
+ ...(status === IN_REVIEW ? { inReview: true, message: inReviewMessage(mode) } : {}),
1461
1599
  task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first(),
1462
1600
  ...(stop ? { stop: true, reason: stop.reason, message: stop.message } : {}),
1463
1601
  });
1464
1602
  }
1465
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
+
1466
1674
  // The counterpart to cancelling: a human answering what a parked task asked
1467
1675
  // for and putting it back in the queue. Same trust boundary as cancel (no
1468
1676
  // worker-lease check — the worker that gave up is exactly who we are
1469
1677
  // overriding), and the attempt cap is deliberately not consulted, since
1470
1678
  // being over it is the normal reason to be here.
1471
- 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> {
1472
1689
  if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task resume is not allowed" }, 403);
1473
- const task = await env.DB.prepare("SELECT project_id AS projectId, status, lease_expires_at AS leaseExpiresAt FROM agent_tasks WHERE id = ?")
1474
- .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
+ }>();
1475
1699
  if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
1476
1700
  if (!isResumableTask(task)) {
1477
- 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);
1478
1706
  }
1479
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
+ }
1480
1738
  const note = typeof body?.note === "string" && body.note.trim() ? body.note.trim() : "Resumed by user";
1481
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.
1482
1742
  await env.DB.prepare(`
1483
1743
  UPDATE agent_tasks SET status = ?, attempts = ?, next_attempt_at = NULL, lease_expires_at = NULL,
1484
1744
  worker_id = NULL, error_message = NULL, completed_at = NULL,
1485
- 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 = ?
1486
1753
  WHERE id = ?
1487
- `).bind(fields.status, fields.attempts, note, taskId).run();
1754
+ `).bind(fields.status, fields.attempts, note, executionMode, workspaceId, approvedBy, approvedBy, approvedBy, taskId).run();
1488
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
+ }
1489
1762
  return response({ resumed: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
1490
1763
  }
1491
1764
 
@@ -1494,6 +1767,29 @@ async function resumeAgentTask(request: Request, env: Env, principal: Principal,
1494
1767
  // lease check (`body.workerId` must match `worker_id`) deliberately doesn't
1495
1768
  // apply here, since the whole point is overriding a task that's stuck
1496
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
+
1497
1793
  async function cancelAgentTask(request: Request, env: Env, principal: Principal, taskId: string): Promise<Response> {
1498
1794
  if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task cancellation is not allowed" }, 403);
1499
1795
  const task = await env.DB.prepare("SELECT project_id AS projectId, status FROM agent_tasks WHERE id = ?")
@@ -2024,9 +2320,10 @@ const registryWorker = {
2024
2320
  if (artifactsMatch && request.method === "GET") {
2025
2321
  const projectId = decodeURIComponent(artifactsMatch[1]);
2026
2322
  if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
2323
+ const visible = await runEvidenceVisibility(env, projectId);
2027
2324
  return artifactsMatch[2]
2028
- ? getProjectArtifact(env.ARTIFACTS, projectId, url)
2029
- : listProjectArtifacts(env.ARTIFACTS, projectId, url);
2325
+ ? getProjectArtifact(env.ARTIFACTS, projectId, url, visible)
2326
+ : listProjectArtifacts(env.ARTIFACTS, projectId, url, visible);
2030
2327
  }
2031
2328
  const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
2032
2329
  if (runMatch && request.method === "GET") return getRun(env, principal, decodeURIComponent(runMatch[1]));
@@ -2034,14 +2331,20 @@ const registryWorker = {
2034
2331
  const claimMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/claim$/);
2035
2332
  if (claimMatch && request.method === "POST") return claimRun(request, env, principal, decodeURIComponent(claimMatch[1]), context);
2036
2333
  const tasksMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/tasks$/);
2037
- 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);
2038
2335
  if (tasksMatch && request.method === "POST") return createAgentTask(request, env, principal, decodeURIComponent(tasksMatch[1]), context);
2039
2336
  const taskClaimMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/claim$/);
2040
2337
  if (taskClaimMatch && request.method === "POST") return claimAgentTask(request, env, principal, decodeURIComponent(taskClaimMatch[1]));
2041
2338
  const taskCancelMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
2042
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);
2043
2346
  const taskResumeMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/resume$/);
2044
- 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);
2045
2348
  const taskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
2046
2349
  if (taskMatch && request.method === "POST") return updateAgentTask(request, env, principal, decodeURIComponent(taskMatch[1]), context);
2047
2350
  if (request.method === "GET" && url.pathname === "/api/device-sessions") return listDeviceSessions(env, principal);