@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
@@ -24,8 +24,112 @@ export type SchedulableTask = {
24
24
  attempts?: number;
25
25
  nextAttemptAt?: string | null;
26
26
  leaseExpiresAt?: string | null;
27
+ taskType?: string;
28
+ /** Null while the task waits for someone to approve it; absent from registries that predate approval. */
29
+ approvedAt?: string | null;
30
+ /** Who approved it: an Aegis id, or `auto:<mode>` when the project's mode approved it on creation. */
31
+ approvedBy?: string | null;
27
32
  };
28
33
 
34
+ /**
35
+ * How much a project lets agents do without a person saying yes first.
36
+ *
37
+ * - `manual`: every task waits for someone to press Approve.
38
+ * - `auto`: explorations and investigations start by themselves; tasks that
39
+ * change code (`code`, `ui`) wait for approval.
40
+ * - `extreme`: everything starts by itself, code changes included.
41
+ */
42
+ export type ApprovalMode = "manual" | "auto" | "extreme";
43
+ export const APPROVAL_MODES: readonly ApprovalMode[] = ["manual", "auto", "extreme"];
44
+ export const DEFAULT_APPROVAL_MODE: ApprovalMode = "auto";
45
+ const CODE_CHANGING_TASKS = new Set(["code", "ui"]);
46
+
47
+ export function approvalModeOf(value: unknown): ApprovalMode | null {
48
+ return typeof value === "string" && (APPROVAL_MODES as readonly string[]).includes(value) ? value as ApprovalMode : null;
49
+ }
50
+
51
+ /** Whether a task of this type needs a person's approval under this mode. */
52
+ export function needsApproval(mode: ApprovalMode, taskType: string | undefined): boolean {
53
+ if (mode === "manual") return true;
54
+ if (mode === "extreme") return false;
55
+ return CODE_CHANGING_TASKS.has(taskType ?? "code");
56
+ }
57
+
58
+ /**
59
+ * A task an AI worker says it finished, waiting for a person to accept it or
60
+ * send it back. Not claimable, not resumable, not finished.
61
+ */
62
+ export const IN_REVIEW = "in_review";
63
+
64
+ /**
65
+ * Whether a finished task needs a person to review it before it counts as
66
+ * completed. Same risk model as starting: manual reviews everything, auto
67
+ * reviews code-changing tasks (`code`, `ui`), extreme reviews nothing.
68
+ */
69
+ export function needsCompletionReview(mode: ApprovalMode, taskType: string | undefined): boolean {
70
+ return needsApproval(mode, taskType);
71
+ }
72
+
73
+ /**
74
+ * The status a task gets when its worker reports `completed`: `in_review`
75
+ * when the project's mode wants a person to look first, else `completed`.
76
+ */
77
+ export function completionStatus(mode: ApprovalMode, taskType: string | undefined): "completed" | "in_review" {
78
+ return needsCompletionReview(mode, taskType) ? IN_REVIEW : "completed";
79
+ }
80
+
81
+ /** The message a worker gets back when its `completed` became `in_review`. */
82
+ export function inReviewMessage(mode: ApprovalMode): string {
83
+ return `This project's approval mode is ${mode}, so a person reviews this task before it counts as completed. `
84
+ + "It is now in review: someone with project management rights accepts it or sends it back with a note. "
85
+ + "Don't start dependent work on the assumption it is accepted.";
86
+ }
87
+
88
+ /** The longest review note kept on a task. */
89
+ export const REVIEW_NOTE_MAX = 2000;
90
+
91
+ /**
92
+ * What a task sent back from review becomes: queued again with a fresh retry
93
+ * budget and no lease, carrying the reviewer's note. Its approval is kept —
94
+ * the person who sent it back is asking for more of the same work.
95
+ */
96
+ export function sentBackTaskFields(note: string): {
97
+ status: "queued";
98
+ attempts: number;
99
+ nextAttemptAt: null;
100
+ leaseExpiresAt: null;
101
+ workerId: null;
102
+ reviewNote: string;
103
+ progressMessage: string;
104
+ } {
105
+ return { ...resumedTaskFields(), reviewNote: note, progressMessage: `Sent back from review: ${note}` };
106
+ }
107
+
108
+ /**
109
+ * This worker's own approval mode, from BUGMOLE_APPROVAL (set by
110
+ * `bugmole serve --approval`). Unset means "whatever the project allows".
111
+ */
112
+ export function workerApprovalMode(env: Record<string, string | undefined> = process.env): ApprovalMode | undefined {
113
+ return approvalModeOf(env.BUGMOLE_APPROVAL?.trim()) ?? undefined;
114
+ }
115
+
116
+ /** The approver recorded when a project's mode approves a task on creation. */
117
+ export function autoApprover(mode: ApprovalMode): string {
118
+ return `auto:${mode}`;
119
+ }
120
+
121
+ /**
122
+ * Whether a worker running in `workerMode` may take a task. A worker can be
123
+ * stricter than its project, never looser: a task the project approved
124
+ * automatically is only taken if the worker's own mode would have approved
125
+ * it too; otherwise the worker waits for a person's approval.
126
+ */
127
+ export function workerMayTake(task: SchedulableTask, workerMode: ApprovalMode | undefined): boolean {
128
+ if (task.approvedAt === null) return false;
129
+ if (!workerMode || !needsApproval(workerMode, task.taskType)) return true;
130
+ return Boolean(task.approvedBy && !task.approvedBy.startsWith("auto:"));
131
+ }
132
+
29
133
  /**
30
134
  * Whether a human may release a parked task back to the queue.
31
135
  *
@@ -43,6 +147,9 @@ export type SchedulableTask = {
43
147
  */
44
148
  export function isResumableTask(task: SchedulableTask, now = Date.now()): boolean {
45
149
  if (task.status === "completed") return false;
150
+ // A task in review is finished work waiting for a person; sending it back
151
+ // is how it goes back to the queue.
152
+ if (task.status === IN_REVIEW) return false;
46
153
  const heldByLiveWorker = task.status === "running"
47
154
  && Boolean(task.leaseExpiresAt)
48
155
  && Date.parse(task.leaseExpiresAt!) > now;
@@ -63,8 +170,98 @@ export function resumedTaskFields(): {
63
170
  return { status: "queued", attempts: 0, nextAttemptAt: null, leaseExpiresAt: null, workerId: null };
64
171
  }
65
172
 
66
- /** Whether a task should be picked up right now. */
67
- export function isClaimableTask(task: SchedulableTask, now = Date.now()): boolean {
173
+ /**
174
+ * What a resumed task's approval becomes. A person's approval stands: they
175
+ * already said yes to this task. An automatic one (or none) is decided again
176
+ * by the project's current mode, so resuming never skips an approval step the
177
+ * project has since switched on, and never holds a task behind a step the
178
+ * project has since switched off.
179
+ */
180
+ export function resumedApproval(
181
+ task: Pick<SchedulableTask, "approvedAt" | "approvedBy" | "taskType">,
182
+ mode: ApprovalMode,
183
+ ): { approvedBy: string | null } {
184
+ if (task.approvedAt && task.approvedBy && !task.approvedBy.startsWith("auto:")) return { approvedBy: task.approvedBy };
185
+ return { approvedBy: needsApproval(mode, task.taskType) ? null : autoApprover(mode) };
186
+ }
187
+
188
+ /**
189
+ * A worker on someone's machine (`bugmole serve`, or a coding agent using the
190
+ * Bugmole MCP) lists the project's tasks every few seconds with its API key.
191
+ * The registry records that at most once a minute, and counts the machine as
192
+ * connected while its last listing is this recent.
193
+ */
194
+ export const LOCAL_WORKER_FRESH_MS = 2 * 60_000;
195
+ export const LOCAL_WORKER_SEEN_THROTTLE_MS = 60_000;
196
+
197
+ export function localWorkerConnected(seenAt: string | null | undefined, now = Date.now()): boolean {
198
+ if (!seenAt) return false;
199
+ const seen = Date.parse(seenAt);
200
+ return Number.isFinite(seen) && now - seen <= LOCAL_WORKER_FRESH_MS;
201
+ }
202
+
203
+ export type TaskPlacementProject = {
204
+ storageMode?: string | null;
205
+ defaultExecutionMode?: string | null;
206
+ localWorkerSeenAt?: string | null;
207
+ };
208
+
209
+ /**
210
+ * A project that runs in Bugmole Cloud: its results live in Bugmole storage,
211
+ * or its work goes to Bugmole Cloud by default. Nobody on such a project is
212
+ * expected to keep a machine connected, so a task only a machine can run
213
+ * would wait there with nothing to pick it up.
214
+ */
215
+ export function projectRunsInCloud(project: TaskPlacementProject): boolean {
216
+ return project.storageMode === "managed" || project.defaultExecutionMode === "cloud";
217
+ }
218
+
219
+ /** Only discovery runs on Bugmole Cloud, and only for a project on Bugmole storage. */
220
+ export function taskCanRunInCloud(task: { taskType?: string }, project: TaskPlacementProject): boolean {
221
+ return task.taskType === "discovery" && project.storageMode === "managed";
222
+ }
223
+
224
+ /**
225
+ * Where a resumed task goes.
226
+ *
227
+ * - `cloud`: it runs on Bugmole Cloud, so resuming hands it to the dispatcher.
228
+ * - `machine`: it runs on a machine, and one is connected (or the project
229
+ * keeps its work on a machine anyway, so queuing ahead of `bugmole serve`
230
+ * is expected).
231
+ * - `stranded`: only a machine can run it, the project runs in the cloud and
232
+ * no machine is connected. Queuing it would leave it waiting forever, so
233
+ * resume refuses and says what to do; `canRunInCloud` says whether it can
234
+ * be resumed on Bugmole Cloud instead.
235
+ */
236
+ export type ResumePlacement =
237
+ | { kind: "cloud" }
238
+ | { kind: "machine" }
239
+ | { kind: "stranded"; canRunInCloud: boolean };
240
+
241
+ export function resumePlacement(
242
+ task: { executionMode?: string | null; taskType?: string },
243
+ project: TaskPlacementProject,
244
+ now = Date.now(),
245
+ ): ResumePlacement {
246
+ if (task.executionMode === "cloud") return { kind: "cloud" };
247
+ if (!projectRunsInCloud(project) || localWorkerConnected(project.localWorkerSeenAt, now)) return { kind: "machine" };
248
+ return { kind: "stranded", canRunInCloud: taskCanRunInCloud(task, project) };
249
+ }
250
+
251
+ /** The registry's answer when resume refuses a stranded task. */
252
+ export function strandedTaskMessage(canRunInCloud: boolean): string {
253
+ return "This task runs on your machine, and no machine is connected to this project. "
254
+ + "Start `npx bugmole serve` in your project, then resume it again."
255
+ + (canRunInCloud ? " Or resume it on Bugmole Cloud." : "");
256
+ }
257
+
258
+ /**
259
+ * Whether a task should be picked up right now. `workerMode` is the worker's
260
+ * own approval mode (`bugmole serve --approval`), which can only add to what
261
+ * the project requires.
262
+ */
263
+ export function isClaimableTask(task: SchedulableTask, now = Date.now(), workerMode?: ApprovalMode): boolean {
264
+ if (!workerMayTake(task, workerMode)) return false;
68
265
  // A worker that dies mid-task leaves it "running" forever. The registry
69
266
  // already allows reclaiming such a task once its lease lapses, but the
70
267
  // client-side selection never offered it, so one crash could wedge the
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { activeRevision, applyTestCaseChange, type CaseSetDocument } from "./test-case-revisions.js";
4
+
5
+ const base = (overrides: Record<string, unknown> = {}) => ({
6
+ caseId: "TC-login",
7
+ title: "Sign in works",
8
+ nodeId: "screen:/login",
9
+ intent: "Prove a person can sign in.",
10
+ steps: ["Open /login", "Sign in"],
11
+ expected: ["The dashboard opens"],
12
+ confidence: "high",
13
+ basis: ["spec"],
14
+ ...overrides,
15
+ });
16
+
17
+ const set = (): CaseSetDocument => ({ caseSetId: "login", journeyId: "j", journeyRevision: 1, cases: [base()] });
18
+
19
+ test("a change adds revision 2 and makes revision 1 obsolete, keeping it", () => {
20
+ const result = applyTestCaseChange(set(), { action: "change", caseId: "TC-login", baseRevision: 1, case: base({ title: "Sign in with a password works" }) }, { proposalId: "prop_1", at: "2026-09-29T10:00:00Z" });
21
+ assert.ok(result.ok);
22
+ if (!result.ok) return;
23
+ assert.equal(result.revision, 2);
24
+ assert.equal(result.caseSet.cases.length, 2);
25
+ const [old, current] = result.caseSet.cases;
26
+ assert.deepEqual([old.status, old.supersededBy, old.obsoleteReason, old.obsoletedAt], ["obsolete", 2, "revised", "2026-09-29T10:00:00Z"]);
27
+ assert.deepEqual([current.revision, current.status, current.title, current.proposalId], [2, "active", "Sign in with a password works", "prop_1"]);
28
+ assert.equal(activeRevision(result.caseSet, "TC-login")?.revision, 2);
29
+ });
30
+
31
+ test("a change against a revision that moved on is outdated, not an overwrite", () => {
32
+ const first = applyTestCaseChange(set(), { action: "change", caseId: "TC-login", baseRevision: 1, case: base({ title: "B" }) });
33
+ assert.ok(first.ok);
34
+ if (!first.ok) return;
35
+ const stale = applyTestCaseChange(first.caseSet, { action: "change", caseId: "TC-login", baseRevision: 1, case: base({ title: "C" }) });
36
+ assert.deepEqual(stale.ok ? null : stale.reason, "outdated");
37
+ });
38
+
39
+ test("retiring leaves no current revision; adding it back starts the next revision", () => {
40
+ const retired = applyTestCaseChange(set(), { action: "retire", caseId: "TC-login", baseRevision: 1 });
41
+ assert.ok(retired.ok);
42
+ if (!retired.ok) return;
43
+ assert.equal(activeRevision(retired.caseSet, "TC-login"), undefined);
44
+ assert.equal(retired.caseSet.cases[0].obsoleteReason, "retired");
45
+ const back = applyTestCaseChange(retired.caseSet, { action: "add", case: base() });
46
+ assert.ok(back.ok);
47
+ if (back.ok) assert.equal(back.revision, 2, "revision numbers never repeat");
48
+ });
49
+
50
+ test("proposals can't smuggle in revision bookkeeping, a duplicate or an invalid case", () => {
51
+ const sneaky = applyTestCaseChange(set(), { action: "change", caseId: "TC-login", baseRevision: 1, case: base({ revision: 99, status: "obsolete" }) });
52
+ assert.ok(sneaky.ok);
53
+ if (sneaky.ok) assert.deepEqual([sneaky.caseSet.cases[1].revision, sneaky.caseSet.cases[1].status], [2, "active"]);
54
+ assert.equal((applyTestCaseChange(set(), { action: "add", case: base() }) as { reason?: string }).reason, "exists");
55
+ assert.equal((applyTestCaseChange(set(), { action: "change", caseId: "TC-x", baseRevision: 1, case: base({ caseId: "TC-x" }) }) as { reason?: string }).reason, "missing");
56
+ assert.equal((applyTestCaseChange(set(), { action: "add", case: base({ caseId: "TC-new", steps: [] }) }) as { reason?: string }).reason, "invalid");
57
+ });
@@ -0,0 +1,132 @@
1
+ // Test cases change over time: a flow is reworded, a check is tightened, a
2
+ // case stops making sense. A change never edits a case in place. It adds a
3
+ // new revision and marks the one it replaces obsolete, so past results keep
4
+ // pointing at exactly what they judged, and a new revision counts as untested
5
+ // until a run proves it.
6
+ //
7
+ // Shared by the registry (Bugmole storage), `bugmole serve` and the CLI
8
+ // (a project's own storage), so every place that applies a change agrees.
9
+
10
+ import { SCENARIO_CATEGORIES, isScenarioCategory } from "../runtime/scenario-matrix.js";
11
+
12
+ export type CaseStatus = "active" | "obsolete";
13
+
14
+ export type RevisionedCase = {
15
+ caseId: string;
16
+ /** 1 when absent: case sets written before revisions existed. */
17
+ revision?: number;
18
+ status?: CaseStatus;
19
+ /** The revision that replaced this one; absent when it was retired. */
20
+ supersededBy?: number;
21
+ obsoletedAt?: string;
22
+ obsoleteReason?: "revised" | "retired";
23
+ /** The accepted proposal that created this revision. */
24
+ proposalId?: string;
25
+ title?: string;
26
+ nodeId?: string;
27
+ steps?: unknown;
28
+ expected?: unknown;
29
+ [field: string]: unknown;
30
+ };
31
+
32
+ export type CaseSetDocument = {
33
+ caseSetId: string;
34
+ cases: RevisionedCase[];
35
+ [field: string]: unknown;
36
+ };
37
+
38
+ export type TestCaseChange =
39
+ | { action: "add"; case: RevisionedCase }
40
+ | { action: "change"; caseId: string; baseRevision: number; case: RevisionedCase }
41
+ | { action: "retire"; caseId: string; baseRevision: number };
42
+
43
+ export type ApplyResult =
44
+ | { ok: true; caseSet: CaseSetDocument; revision: number }
45
+ | { ok: false; reason: "outdated" | "exists" | "missing" | "invalid"; message: string };
46
+
47
+ export function revisionOf(testCase: Pick<RevisionedCase, "revision">): number {
48
+ return Number.isInteger(testCase.revision) && (testCase.revision as number) > 0 ? testCase.revision as number : 1;
49
+ }
50
+
51
+ export function isObsolete(testCase: Pick<RevisionedCase, "status">): boolean {
52
+ return testCase.status === "obsolete";
53
+ }
54
+
55
+ /** The revision of a case that currently runs, or undefined if it was retired or never existed. */
56
+ export function activeRevision(caseSet: Pick<CaseSetDocument, "cases">, caseId: string): RevisionedCase | undefined {
57
+ return caseSet.cases.find((testCase) => testCase.caseId === caseId && !isObsolete(testCase));
58
+ }
59
+
60
+ const CASE_ID = /^[a-zA-Z0-9._-]+$/;
61
+
62
+ /** Why a proposed case can't be stored, or null. Grounding in the journey graph is checked where the graph is. */
63
+ export function caseProblem(testCase: RevisionedCase | undefined): string | null {
64
+ if (!testCase || typeof testCase !== "object") return "the case must be an object";
65
+ if (typeof testCase.caseId !== "string" || !CASE_ID.test(testCase.caseId)) return "caseId may contain only letters, numbers, ., _ and -";
66
+ for (const field of ["title", "nodeId", "intent"] as const) {
67
+ if (typeof testCase[field] !== "string" || !(testCase[field] as string).trim()) return `${field} is required`;
68
+ }
69
+ for (const field of ["steps", "expected"] as const) {
70
+ const value = testCase[field];
71
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || !item.trim())) {
72
+ return `${field} must be a non-empty list of text`;
73
+ }
74
+ }
75
+ if (!["high", "medium", "low"].includes(testCase.confidence as string)) return "confidence must be high, medium or low";
76
+ const category = (testCase as { category?: unknown }).category;
77
+ if (category !== undefined && !isScenarioCategory(category)) return `category must be one of ${SCENARIO_CATEGORIES.join(", ")}`;
78
+ return null;
79
+ }
80
+
81
+ function nextRevision(caseSet: CaseSetDocument, caseId: string): number {
82
+ return Math.max(0, ...caseSet.cases.filter((testCase) => testCase.caseId === caseId).map(revisionOf)) + 1;
83
+ }
84
+
85
+ /** Strips the fields this module owns, so a proposal can't set them. */
86
+ function content(testCase: RevisionedCase): RevisionedCase {
87
+ const { revision: _r, status: _s, supersededBy: _b, obsoletedAt: _o, obsoleteReason: _w, proposalId: _p, ...rest } = testCase;
88
+ return rest as RevisionedCase;
89
+ }
90
+
91
+ /**
92
+ * Applies one change. `change` and `retire` name the revision the proposer
93
+ * saw: if the case has moved on since, the proposal is outdated rather than
94
+ * silently overwriting someone else's revision.
95
+ */
96
+ export function applyTestCaseChange(
97
+ caseSet: CaseSetDocument,
98
+ change: TestCaseChange,
99
+ meta: { proposalId?: string; at?: string } = {},
100
+ ): ApplyResult {
101
+ const at = meta.at ?? new Date().toISOString();
102
+ const cases = caseSet.cases.map((testCase) => ({ ...testCase }));
103
+ const caseId = change.action === "add" ? change.case?.caseId : change.caseId;
104
+ if (typeof caseId !== "string" || !CASE_ID.test(caseId)) return { ok: false, reason: "invalid", message: "caseId may contain only letters, numbers, ., _ and -" };
105
+ const current = cases.find((testCase) => testCase.caseId === caseId && !isObsolete(testCase));
106
+
107
+ if (change.action === "add") {
108
+ const problem = caseProblem(change.case);
109
+ if (problem) return { ok: false, reason: "invalid", message: problem };
110
+ if (current) return { ok: false, reason: "exists", message: `${caseId} already exists at revision ${revisionOf(current)}; propose a change to it instead` };
111
+ const revision = nextRevision({ ...caseSet, cases }, caseId);
112
+ cases.push({ ...content(change.case), caseId, revision, status: "active", ...(meta.proposalId ? { proposalId: meta.proposalId } : {}) });
113
+ return { ok: true, caseSet: { ...caseSet, cases }, revision };
114
+ }
115
+
116
+ if (!current) return { ok: false, reason: "missing", message: `${caseId} has no current revision to ${change.action}` };
117
+ if (revisionOf(current) !== change.baseRevision) {
118
+ return { ok: false, reason: "outdated", message: `${caseId} is at revision ${revisionOf(current)}, not ${change.baseRevision}; propose again against the current revision` };
119
+ }
120
+
121
+ if (change.action === "retire") {
122
+ Object.assign(current, { status: "obsolete", obsoletedAt: at, obsoleteReason: "retired" });
123
+ return { ok: true, caseSet: { ...caseSet, cases }, revision: revisionOf(current) };
124
+ }
125
+
126
+ const problem = caseProblem({ ...change.case, caseId });
127
+ if (problem) return { ok: false, reason: "invalid", message: problem };
128
+ const revision = nextRevision({ ...caseSet, cases }, caseId);
129
+ Object.assign(current, { status: "obsolete", supersededBy: revision, obsoletedAt: at, obsoleteReason: "revised" });
130
+ cases.push({ ...content(change.case), caseId, revision, status: "active", ...(meta.proposalId ? { proposalId: meta.proposalId } : {}) });
131
+ return { ok: true, caseSet: { ...caseSet, cases }, revision };
132
+ }
@@ -3,7 +3,8 @@
3
3
  // connect their own key call their provider directly and never come here.
4
4
  import { AI_CREDIT_COST, type AiAction } from "../../billing/plan-catalog.js";
5
5
  import { type Env, type Principal, memberOfProject, requestJson, response } from "../core.js";
6
- import { getEntitlements, workspaceOfProject } from "../billing/entitlements.js";
6
+ import { workspaceOfProject } from "../billing/entitlements.js";
7
+ import { featureEnabled } from "../feature-access.js";
7
8
  import { canSpend, recordUsage } from "../billing/ledger.js";
8
9
  import { checkUsageThresholds } from "../billing/thresholds.js";
9
10
  import { type AuthedRouteContext, registerRoutes } from "../routes/index.js";
@@ -41,8 +42,7 @@ async function complete({ env, principal, request, ctx }: AuthedRouteContext): P
41
42
  }
42
43
  const workspaceId = await workspaceOfProject(env, projectId);
43
44
  if (!workspaceId) return response({ error: "This project isn't in a workspace" }, 422);
44
- const entitlements = await getEntitlements(env, workspaceId);
45
- if (purpose === "repair" && !entitlements.features.aiFixes) {
45
+ if (purpose === "repair" && !(await featureEnabled(env, workspaceId, "ai_fixes"))) {
46
46
  return response({ error: "AI test repair is available on Team and Business plans.", code: "plan_feature", feature: "aiFixes", upgradeUrl: "/billing" }, 402);
47
47
  }
48
48
 
@@ -17,9 +17,20 @@ export type ArtifactBucket = {
17
17
  truncated: boolean;
18
18
  cursor?: string;
19
19
  }>;
20
- get(key: string): Promise<{ body: ReadableStream; size: number; httpMetadata?: { contentType?: string } } | null>;
20
+ get(key: string): Promise<{ body: ReadableStream; size: number; uploaded?: Date; httpMetadata?: { contentType?: string } } | null>;
21
21
  };
22
22
 
23
+ /** Hides objects past the plan's history (see billing/enforcement.ts historyCutoffFor). */
24
+ export type ArtifactVisibility = (key: string, uploaded: Date | undefined) => Promise<boolean>;
25
+
26
+ /** The run a key belongs to: `projects/<id>/runs/<runId>/...`. */
27
+ export function runIdOfArtifactKey(projectId: string, key: string): string | null {
28
+ const prefix = `${artifactProjectPrefix(projectId)}runs/`;
29
+ if (!key.startsWith(prefix)) return null;
30
+ const segment = key.slice(prefix.length).split("/")[0];
31
+ return segment ? decodeURIComponent(segment) : null;
32
+ }
33
+
23
34
  export const ARTIFACT_STORAGE_UNCONFIGURED = "Artifact storage is not configured";
24
35
  const LIST_LIMIT = 1000;
25
36
 
@@ -48,7 +59,7 @@ export function isRunManifestKey(projectId: string, runId: string, key: unknown)
48
59
  return typeof key === "string" && key === `${artifactProjectPrefix(projectId)}runs/${encodeURIComponent(runId)}/manifest.json`;
49
60
  }
50
61
 
51
- export async function listProjectArtifacts(bucket: ArtifactBucket | undefined, projectId: string, url: URL): Promise<Response> {
62
+ export async function listProjectArtifacts(bucket: ArtifactBucket | undefined, projectId: string, url: URL, visible?: ArtifactVisibility): Promise<Response> {
52
63
  if (!bucket) return json({ error: ARTIFACT_STORAGE_UNCONFIGURED }, 503);
53
64
  const prefix = normalizeArtifactPrefix(projectId, url.searchParams.get("prefix"));
54
65
  if (!prefix) return json({ error: "prefix must stay under this project's artifacts" }, 400);
@@ -58,8 +69,12 @@ export async function listProjectArtifacts(bucket: ArtifactBucket | undefined, p
58
69
  limit: LIST_LIMIT,
59
70
  include: ["httpMetadata"],
60
71
  });
72
+ const objects = [];
73
+ for (const object of listed.objects) {
74
+ if (!visible || (await visible(object.key, object.uploaded))) objects.push(object);
75
+ }
61
76
  return json({
62
- objects: listed.objects.map((object) => ({
77
+ objects: objects.map((object) => ({
63
78
  key: object.key,
64
79
  size: object.size,
65
80
  modifiedAt: object.uploaded.toISOString(),
@@ -69,12 +84,16 @@ export async function listProjectArtifacts(bucket: ArtifactBucket | undefined, p
69
84
  });
70
85
  }
71
86
 
72
- export async function getProjectArtifact(bucket: ArtifactBucket | undefined, projectId: string, url: URL): Promise<Response> {
87
+ export async function getProjectArtifact(bucket: ArtifactBucket | undefined, projectId: string, url: URL, visible?: ArtifactVisibility): Promise<Response> {
73
88
  if (!bucket) return json({ error: ARTIFACT_STORAGE_UNCONFIGURED }, 503);
74
89
  const key = url.searchParams.get("key") ?? "";
75
90
  if (!isProjectArtifactKey(projectId, key)) return json({ error: "key must stay under this project's artifacts" }, 400);
76
91
  const object = await bucket.get(key);
77
92
  if (!object) return json({ error: "Artifact not found" }, 404);
93
+ if (visible && !(await visible(key, object.uploaded))) {
94
+ await object.body.cancel().catch(() => undefined);
95
+ return json({ error: "This evidence is older than your plan's history", code: "history_expired" }, 404);
96
+ }
78
97
  return new Response(object.body, {
79
98
  headers: {
80
99
  "content-type": object.httpMetadata?.contentType || "application/octet-stream",
@@ -171,5 +171,5 @@ test("expired run history and its evidence are deleted daily", async () => {
171
171
  assert.ok(audit.json.entries.some((entry: { action: string }) => entry.action === "runs.expired"));
172
172
 
173
173
  db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, 'business')").run(workspaceId);
174
- assert.deepEqual(await enforceRetention(env), { runs: 0, objects: 0 });
174
+ assert.deepEqual(await enforceRetention(env), { runs: 0, objects: 0, wouldDelete: 0 });
175
175
  });
@@ -3,20 +3,22 @@
3
3
  import { PLANS, type BillingInterval } from "../../billing/plan-catalog.js";
4
4
  import type { Env } from "../core.js";
5
5
  import { memberOfWorkspace, requestJson, response } from "../core.js";
6
- import { flagOn } from "../flags.js";
6
+ import { featureEnabled } from "../feature-access.js";
7
7
  import { registerJob } from "../jobs/index.js";
8
8
  import { actorOf, recordAudit } from "../org/audit.js";
9
9
  import { matchPath, registerPublicRoutes, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
10
- import { defaultBillingProvider } from "./provider.js";
10
+ import { billingProvider, defaultBillingProvider } from "./provider.js";
11
11
  import {
12
12
  BillingError,
13
13
  cancelAtPeriodEnd,
14
+ chooseActiveProjects,
14
15
  chaseOverdueInvoices,
15
16
  closeUsagePeriods,
16
17
  completeCheckout,
17
18
  handleProviderWebhook,
18
19
  isPaidPlan,
19
20
  periodPriceCents,
21
+ resumeSubscription,
20
22
  startCheckout,
21
23
  syncAllBilling,
22
24
  } from "./subscriptions.js";
@@ -41,14 +43,14 @@ async function mayManage(context: AuthedRouteContext, workspaceId: string): Prom
41
43
  }
42
44
 
43
45
  async function billingEnabled(env: Env, workspaceId: string): Promise<boolean> {
44
- return flagOn(env, workspaceId, "billing");
46
+ return featureEnabled(env, workspaceId, "billing");
45
47
  }
46
48
 
47
49
  async function overview({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
48
50
  if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
49
51
  const canManage = await memberOfWorkspace(env, principal, workspaceId, "billing.manage");
50
52
  const row = await env.DB.prepare(`
51
- SELECT plan, billing_interval AS interval, status, current_period_end AS periodEnd, cancel_at_period_end AS cancelAtPeriodEnd,
53
+ SELECT plan, billing_interval AS interval, status, current_period_end AS periodEnd, cancel_at_period_end AS cancelAtPeriodEnd, provider,
52
54
  pending_plan AS pendingPlan, billing_email AS email, billing_name AS name, billing_address AS address, tax_id AS taxId
53
55
  FROM workspace_plans WHERE workspace_id = ?
54
56
  `).bind(workspaceId).first<Record<string, string | number | null>>();
@@ -70,8 +72,16 @@ async function overview({ env, principal }: AuthedRouteContext, workspaceId: str
70
72
  priceCents: periodPriceCents(plan, interval),
71
73
  available: enabled && provider.canSell(env, plan, interval),
72
74
  })));
75
+ // Where the subscriber changes the card or bank: the provider of their subscription, else the one new checkouts use.
76
+ let manageUrl: string | null = null;
77
+ try {
78
+ manageUrl = (row?.provider ? billingProvider(String(row.provider)) : provider).manageUrl(env);
79
+ } catch {
80
+ manageUrl = null;
81
+ }
73
82
  return response({
74
83
  canManage,
84
+ paymentProvider: { id: row?.provider ?? provider.id, manageUrl },
75
85
  subscription: row && row.status !== "canceled" && isPaidPlan(row.plan)
76
86
  ? {
77
87
  plan: row.plan,
@@ -129,6 +139,41 @@ async function cancel(context: AuthedRouteContext, workspaceId: string): Promise
129
139
  }
130
140
  }
131
141
 
142
+ async function resume(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
143
+ const denied = await mayManage(context, workspaceId);
144
+ if (denied) return denied;
145
+ try {
146
+ const result = await resumeSubscription(context.env, context.principal, workspaceId, context.request);
147
+ if (result.status === "resumed") {
148
+ return response({ status: "resumed", plan: { id: result.plan.plan, interval: result.plan.interval, periodEnd: result.plan.periodEnd } });
149
+ }
150
+ // PayPal can't restart a cancelled subscription: start the same plan again from the end of the period.
151
+ if (!(await billingEnabled(context.env, workspaceId))) {
152
+ return response({ error: `${result.message} Online checkout isn't open yet; contact us to keep your plan.`, code: "billing_unavailable" }, 503);
153
+ }
154
+ const started = await startCheckout(context.env, context.principal, workspaceId, result.plan, result.interval, context.request);
155
+ return response({ status: "checkout_required", message: result.message, plan: result.plan, interval: result.interval, ...started });
156
+ } catch (error) {
157
+ return billingError(error);
158
+ }
159
+ }
160
+
161
+ async function chooseProjects(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
162
+ if (!(await memberOfWorkspace(context.env, context.principal, workspaceId, "workspace.manage"))) {
163
+ return response({ error: "Only workspace owners and admins can choose which projects stay active" }, 403);
164
+ }
165
+ const body = await requestJson(context.request);
166
+ const projectIds = body?.projectIds;
167
+ if (!Array.isArray(projectIds) || projectIds.some((id) => typeof id !== "string") || projectIds.length > 500) {
168
+ return response({ error: "projectIds must be a list of project ids" }, 422);
169
+ }
170
+ try {
171
+ return response(await chooseActiveProjects(context.env, context.principal, workspaceId, projectIds as string[], context.request));
172
+ } catch (error) {
173
+ return billingError(error);
174
+ }
175
+ }
176
+
132
177
  const CONTACT_LIMITS = { name: 120, email: 254, address: 500, taxId: 64 } as const;
133
178
 
134
179
  async function updateContact(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
@@ -186,6 +231,10 @@ registerRoutes(async (context) => {
186
231
  if (params && request.method === "POST") return complete(context, params.workspaceId, params.checkoutId);
187
232
  params = matchPath(url.pathname, "/api/workspaces/:workspaceId/billing/cancel");
188
233
  if (params && request.method === "POST") return cancel(context, params.workspaceId);
234
+ params = matchPath(url.pathname, "/api/workspaces/:workspaceId/billing/resume");
235
+ if (params && request.method === "POST") return resume(context, params.workspaceId);
236
+ params = matchPath(url.pathname, "/api/workspaces/:workspaceId/projects/active");
237
+ if (params && request.method === "PUT") return chooseProjects(context, params.workspaceId);
189
238
  params = matchPath(url.pathname, "/api/workspaces/:workspaceId/billing/contact");
190
239
  if (params && request.method === "PUT") return updateContact(context, params.workspaceId);
191
240
  params = matchPath(url.pathname, "/api/workspaces/:workspaceId/invoices/:invoiceId");