@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.
- package/LICENSE +18 -4
- package/package.json +7 -4
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +494 -173
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +87 -7
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- 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
|
-
/**
|
|
67
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
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");
|