@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
@@ -0,0 +1,577 @@
1
+ // The Jira workflow, on top of Bugmole's own pieces:
2
+ //
3
+ // 1. A project manager links a Jira site and project (routes.ts).
4
+ // 2. proposePlan: pending test case proposals become one Jira issue
5
+ // (label bugmole-plan) summarizing the proposed scope and cases.
6
+ // 3. People approve in Bugmole (proposals + the project's approval mode);
7
+ // refreshPlanIssue reflects the decisions on that issue.
8
+ // 4. syncTickets: every approved case gets one Jira ticket (label
9
+ // bugmole-test), found again by a per-case label so it's never duplicated.
10
+ // 5. reportRun: after a run, passed tickets move to Done; failed ones move to
11
+ // Blocked (or get the bugmole-blocked label) and @mention the assignee and
12
+ // reporter, with the result and a link to the evidence.
13
+ // 7/8. requestRetest: a comment that mentions Bugmole queues a run scoped to
14
+ // that ticket's case, which reportRun then reports on the same ticket.
15
+ import type { Env, Principal } from "../core.js";
16
+ import { newId, sha256 } from "../core.js";
17
+ import { runService, type RunCaseScope } from "../hooks.js";
18
+ import { detachedContext } from "../routes/index.js";
19
+ import {
20
+ adf, blockedTransition, doneTransition, JiraApiError, type AdfNode, type JiraClient, type JiraIssue,
21
+ } from "../../integrations/jira.js";
22
+ import { clientFor, connectionOf, dashboardUrl, issueUrl, type ConnectionRow } from "./connection.js";
23
+
24
+ export const PLAN_LABEL = "bugmole-plan";
25
+ export const TEST_LABEL = "bugmole-test";
26
+ export const BLOCKED_LABEL = "bugmole-blocked";
27
+ export const RETIRED_LABEL = "bugmole-retired";
28
+
29
+ /** Thrown when a run's results aren't written yet: the outbox retries later. */
30
+ export class ResultsNotReady extends Error {}
31
+
32
+ export type CaseContent = {
33
+ caseSetId: string;
34
+ caseId: string;
35
+ revision: number;
36
+ title: string;
37
+ intent?: string;
38
+ steps: string[];
39
+ expected: string[];
40
+ journeyId?: string | null;
41
+ };
42
+
43
+ export type Verdict = { caseSetId: string; caseId: string; status: "passed" | "failed" | "blocked" | "needs_setup"; detail: string };
44
+
45
+ type LinkRow = {
46
+ project_id: string;
47
+ case_set_id: string;
48
+ case_id: string;
49
+ case_revision: number;
50
+ case_title: string | null;
51
+ journey_id: string | null;
52
+ jira_issue_key: string | null;
53
+ jira_issue_id: string | null;
54
+ last_result: string | null;
55
+ last_run_id: string | null;
56
+ updated_at: string;
57
+ };
58
+
59
+ type ProposalRow = {
60
+ id: string;
61
+ caseSetId: string;
62
+ caseId: string;
63
+ action: "add" | "change" | "retire";
64
+ baseRevision: number | null;
65
+ proposedJson: string | null;
66
+ summary: string;
67
+ status: string;
68
+ appliedRevision: number | null;
69
+ };
70
+
71
+ function strings(value: unknown): string[] {
72
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())) : [];
73
+ }
74
+
75
+ function parse(json: string | null): Record<string, unknown> {
76
+ try {
77
+ return json ? JSON.parse(json) as Record<string, unknown> : {};
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ function testsUrl(env: Env, projectId: string): string {
84
+ return `${dashboardUrl(env)}/plans?${new URLSearchParams({ project: projectId })}`;
85
+ }
86
+
87
+ function runUrl(env: Env, projectId: string, runId: string): string {
88
+ return `${dashboardUrl(env)}/runs?${new URLSearchParams({ project: projectId, run: runId })}`;
89
+ }
90
+
91
+ /** A label only this case's ticket carries, so a lost write never becomes a second ticket. */
92
+ export async function caseLabel(projectId: string, caseSetId: string, caseId: string): Promise<string> {
93
+ return `bugmole-case-${(await sha256(`${projectId}|${caseSetId}|${caseId}`)).slice(0, 16)}`;
94
+ }
95
+
96
+ async function managedStorage(env: Env, projectId: string): Promise<boolean> {
97
+ if (!env.ARTIFACTS) return false;
98
+ const row = await env.DB.prepare("SELECT storage_mode AS storageMode FROM projects WHERE id = ?").bind(projectId).first<{ storageMode: string }>();
99
+ return row?.storageMode === "managed";
100
+ }
101
+
102
+ async function readJson(env: Env, key: string): Promise<any | null> {
103
+ const object = await env.ARTIFACTS?.get(key);
104
+ if (!object) return null;
105
+ try {
106
+ return JSON.parse(await object.text());
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ async function listKeys(env: Env, prefix: string): Promise<string[]> {
113
+ if (!env.ARTIFACTS) return [];
114
+ const keys: string[] = [];
115
+ let cursor: string | undefined;
116
+ for (let page = 0; page < 20; page += 1) {
117
+ const listed = await env.ARTIFACTS.list({ prefix, cursor, limit: 1000 });
118
+ keys.push(...listed.objects.map((object) => object.key));
119
+ if (!listed.truncated) break;
120
+ cursor = (listed as { cursor?: string }).cursor;
121
+ if (!cursor) break;
122
+ }
123
+ return keys;
124
+ }
125
+
126
+ // --- Step 2/3: the proposed test plan as one Jira issue -------------------------------------
127
+
128
+ function proposalCase(row: ProposalRow): Partial<CaseContent> {
129
+ const proposed = parse(row.proposedJson);
130
+ return {
131
+ title: typeof proposed.title === "string" ? proposed.title : row.caseId,
132
+ intent: typeof proposed.intent === "string" ? proposed.intent : undefined,
133
+ steps: strings(proposed.steps),
134
+ expected: strings(proposed.expected),
135
+ };
136
+ }
137
+
138
+ function planDescription(env: Env, projectId: string, proposals: ProposalRow[]) {
139
+ const caseSets = [...new Set(proposals.map((row) => row.caseSetId))];
140
+ const blocks: AdfNode[] = [
141
+ adf.paragraph(adf.text(`Bugmole proposes ${proposals.length} test case change${proposals.length === 1 ? "" : "s"} across ${caseSets.length} case set${caseSets.length === 1 ? "" : "s"} (${caseSets.join(", ")}).`)),
142
+ adf.paragraph(adf.text("Review and approve them in Bugmole: "), adf.link("open the proposals", testsUrl(env, projectId)), adf.text(". Once approved, Bugmole creates one ticket per test and reports every run's result on it.")),
143
+ adf.heading(3, "Proposed scope"),
144
+ adf.bullets(proposals.map((row) => {
145
+ const content = proposalCase(row);
146
+ return [adf.strong(`${row.action === "add" ? "Add" : row.action === "change" ? "Change" : "Retire"}: ${content.title}`), adf.text(` (${row.caseSetId}/${row.caseId}) — ${row.summary}`)];
147
+ })),
148
+ ];
149
+ for (const row of proposals.filter((item) => item.action !== "retire")) {
150
+ const content = proposalCase(row);
151
+ blocks.push(adf.heading(4, content.title ?? row.caseId));
152
+ if (content.intent) blocks.push(adf.paragraph(adf.text(content.intent)));
153
+ if (content.steps?.length) blocks.push(adf.paragraph(adf.strong("Steps")), adf.ordered(content.steps));
154
+ if (content.expected?.length) blocks.push(adf.paragraph(adf.strong("Expected")), adf.bullets(content.expected.map((item) => [adf.text(item)])));
155
+ }
156
+ return adf.doc(...blocks);
157
+ }
158
+
159
+ export type PlanIssueResult = { status: 200 | 201; planIssue: { id: string; key: string; url: string; proposalIds: string[]; status: string } };
160
+
161
+ export async function proposePlan(env: Env, principal: Principal, row: ConnectionRow): Promise<PlanIssueResult | { error: string; status: 422 }> {
162
+ const projectId = row.project_id;
163
+ const pending = await env.DB.prepare(`
164
+ SELECT id, case_set_id AS caseSetId, case_id AS caseId, action, base_revision AS baseRevision, proposed_json AS proposedJson,
165
+ summary, status, applied_revision AS appliedRevision
166
+ FROM spec_proposals WHERE project_id = ? AND kind = 'test_case' AND status = 'pending'
167
+ ORDER BY created_at ASC LIMIT 100
168
+ `).bind(projectId).all<ProposalRow>();
169
+ if (!pending.results.length) return { error: "There are no proposed test cases waiting for review.", status: 422 };
170
+ const proposalIds = pending.results.map((proposal) => proposal.id).sort();
171
+ const open = await env.DB.prepare("SELECT id, jira_issue_key AS key, proposal_ids_json AS ids, status FROM jira_plan_issues WHERE project_id = ? AND status = 'proposed'")
172
+ .bind(projectId).all<{ id: string; key: string; ids: string; status: string }>();
173
+ const same = open.results.find((issue) => JSON.stringify(JSON.parse(issue.ids)) === JSON.stringify(proposalIds));
174
+ if (same) return { status: 200, planIssue: { id: same.id, key: same.key, url: issueUrl(row, same.key), proposalIds, status: same.status } };
175
+
176
+ const client = await clientFor(env, row);
177
+ const project = await env.DB.prepare("SELECT name FROM projects WHERE id = ?").bind(projectId).first<{ name: string }>();
178
+ const created = await client.createIssue({
179
+ project: { key: row.jira_project_key },
180
+ issuetype: { name: "Task" },
181
+ summary: `[Bugmole] Proposed test plan for ${project?.name ?? projectId}: ${proposalIds.length} test case${proposalIds.length === 1 ? "" : "s"}`.slice(0, 250),
182
+ labels: [PLAN_LABEL],
183
+ description: planDescription(env, projectId, pending.results),
184
+ });
185
+ const id = newId("jplan");
186
+ await env.DB.prepare(`
187
+ INSERT INTO jira_plan_issues (id, project_id, jira_issue_key, proposal_ids_json, status, created_by) VALUES (?, ?, ?, ?, 'proposed', ?)
188
+ `).bind(id, projectId, created.key, JSON.stringify(proposalIds), principal.aegisId).run();
189
+ return { status: 201, planIssue: { id, key: created.key, url: issueUrl(row, created.key), proposalIds, status: "proposed" } };
190
+ }
191
+
192
+ /** Once every proposal on a plan issue is decided, says so on the issue and moves it to Done. */
193
+ export async function refreshPlanIssue(env: Env, planIssueId: string): Promise<void> {
194
+ const plan = await env.DB.prepare("SELECT id, project_id AS projectId, jira_issue_key AS key, proposal_ids_json AS ids, status FROM jira_plan_issues WHERE id = ?")
195
+ .bind(planIssueId).first<{ id: string; projectId: string; key: string; ids: string; status: string }>();
196
+ if (!plan || plan.status !== "proposed") return;
197
+ const row = await connectionOf(env, plan.projectId);
198
+ if (!row?.enabled) return;
199
+ const ids = JSON.parse(plan.ids) as string[];
200
+ const placeholders = ids.map(() => "?").join(", ");
201
+ const proposals = await env.DB.prepare(`SELECT id, status FROM spec_proposals WHERE project_id = ? AND id IN (${placeholders})`)
202
+ .bind(plan.projectId, ...ids).all<{ id: string; status: string }>();
203
+ if (proposals.results.some((proposal) => proposal.status === "pending")) return;
204
+ const accepted = proposals.results.filter((proposal) => proposal.status === "accepted").length;
205
+ const status = accepted === 0 ? "rejected" : accepted === ids.length ? "approved" : "partially_approved";
206
+ // Claim the change first, so a repeated delivery doesn't comment twice.
207
+ const claimed = await env.DB.prepare("UPDATE jira_plan_issues SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'proposed'")
208
+ .bind(status, plan.id).run();
209
+ if ((claimed.meta?.changes ?? 0) !== 1) return;
210
+ try {
211
+ const client = await clientFor(env, row);
212
+ const words = status === "approved" ? "The test plan was approved in Bugmole" : status === "rejected" ? "The test plan was rejected in Bugmole" : "The test plan was partly approved in Bugmole";
213
+ await client.comment(plan.key, adf.doc(adf.paragraph(
214
+ adf.text(`${words}: ${accepted} of ${ids.length} proposed test case change${ids.length === 1 ? "" : "s"} accepted. `),
215
+ ...(accepted ? [adf.text("Bugmole creates a ticket (label bugmole-test) for each approved test.")] : []),
216
+ )));
217
+ const transition = doneTransition(await client.transitions(plan.key));
218
+ if (transition) await client.transition(plan.key, transition.id);
219
+ } catch (error) {
220
+ await env.DB.prepare("UPDATE jira_plan_issues SET status = 'proposed' WHERE id = ?").bind(plan.id).run();
221
+ throw error;
222
+ }
223
+ }
224
+
225
+ // --- Step 4: one ticket per approved test case ---------------------------------------------
226
+
227
+ /** Approved cases: accepted proposals, plus (on request, Bugmole storage only) every active case already in the spec. */
228
+ export async function approvedCases(env: Env, projectId: string, includeExisting: boolean): Promise<{ cases: CaseContent[]; retired: Array<{ caseSetId: string; caseId: string }> }> {
229
+ const byCase = new Map<string, CaseContent>();
230
+ const retired = new Map<string, { caseSetId: string; caseId: string }>();
231
+ const caseSets = new Map<string, any>();
232
+ const managed = await managedStorage(env, projectId);
233
+ const caseSetOf = async (caseSetId: string) => {
234
+ if (!managed) return null;
235
+ if (!caseSets.has(caseSetId)) caseSets.set(caseSetId, await readJson(env, `projects/${projectId}/test-cases/${caseSetId}.json`));
236
+ return caseSets.get(caseSetId);
237
+ };
238
+ if (includeExisting && managed) {
239
+ for (const key of await listKeys(env, `projects/${projectId}/test-cases/`)) {
240
+ if (!key.endsWith(".json")) continue;
241
+ const caseSet = await readJson(env, key);
242
+ if (!caseSet || typeof caseSet.caseSetId !== "string" || !Array.isArray(caseSet.cases)) continue;
243
+ caseSets.set(caseSet.caseSetId, caseSet);
244
+ for (const item of caseSet.cases) {
245
+ if (typeof item?.caseId !== "string" || item.status === "obsolete") continue;
246
+ byCase.set(`${caseSet.caseSetId}/${item.caseId}`, {
247
+ caseSetId: caseSet.caseSetId,
248
+ caseId: item.caseId,
249
+ revision: Number.isInteger(item.revision) && item.revision > 0 ? item.revision : 1,
250
+ title: typeof item.title === "string" ? item.title : item.caseId,
251
+ intent: typeof item.intent === "string" ? item.intent : undefined,
252
+ steps: strings(item.steps),
253
+ expected: strings(item.expected),
254
+ journeyId: typeof caseSet.journeyId === "string" ? caseSet.journeyId : null,
255
+ });
256
+ }
257
+ }
258
+ }
259
+ const accepted = await env.DB.prepare(`
260
+ SELECT id, case_set_id AS caseSetId, case_id AS caseId, action, base_revision AS baseRevision, proposed_json AS proposedJson,
261
+ summary, status, applied_revision AS appliedRevision
262
+ FROM spec_proposals WHERE project_id = ? AND kind = 'test_case' AND status = 'accepted'
263
+ ORDER BY created_at ASC
264
+ `).bind(projectId).all<ProposalRow>();
265
+ for (const proposal of accepted.results) {
266
+ const key = `${proposal.caseSetId}/${proposal.caseId}`;
267
+ if (proposal.action === "retire") {
268
+ byCase.delete(key);
269
+ retired.set(key, { caseSetId: proposal.caseSetId, caseId: proposal.caseId });
270
+ continue;
271
+ }
272
+ retired.delete(key);
273
+ const content = proposalCase(proposal);
274
+ const caseSet = await caseSetOf(proposal.caseSetId);
275
+ byCase.set(key, {
276
+ caseSetId: proposal.caseSetId,
277
+ caseId: proposal.caseId,
278
+ revision: proposal.appliedRevision ?? (proposal.action === "add" ? 1 : (proposal.baseRevision ?? 0) + 1),
279
+ title: content.title ?? proposal.caseId,
280
+ intent: content.intent,
281
+ steps: content.steps ?? [],
282
+ expected: content.expected ?? [],
283
+ journeyId: typeof caseSet?.journeyId === "string" ? caseSet.journeyId : null,
284
+ });
285
+ }
286
+ return { cases: [...byCase.values()], retired: [...retired.values()] };
287
+ }
288
+
289
+ function ticketDescription(env: Env, projectId: string, content: CaseContent) {
290
+ const blocks: AdfNode[] = [];
291
+ if (content.intent) blocks.push(adf.paragraph(adf.text(content.intent)));
292
+ if (content.steps.length) blocks.push(adf.heading(3, "Steps"), adf.ordered(content.steps));
293
+ if (content.expected.length) blocks.push(adf.heading(3, "Expected"), adf.bullets(content.expected.map((item) => [adf.text(item)])));
294
+ blocks.push(adf.paragraph(
295
+ adf.text(`Bugmole test case ${content.caseSetId}/${content.caseId}, revision ${content.revision}. `),
296
+ adf.link("Open in Bugmole", testsUrl(env, projectId)),
297
+ ));
298
+ blocks.push(adf.paragraph(adf.text("Bugmole moves this ticket to Done when the test passes and to Blocked when it fails. After a fix, mention Bugmole in a comment and it retests just this case.")));
299
+ return adf.doc(...blocks);
300
+ }
301
+
302
+ function ticketSummary(content: CaseContent): string {
303
+ return `[Bugmole] ${content.title}`.slice(0, 250);
304
+ }
305
+
306
+ export type SyncResult = { created: string[]; updated: string[]; linked: string[]; retired: string[] };
307
+
308
+ export async function syncTickets(env: Env, projectId: string, options: { includeExisting?: boolean } = {}): Promise<SyncResult> {
309
+ const result: SyncResult = { created: [], updated: [], linked: [], retired: [] };
310
+ const row = await connectionOf(env, projectId);
311
+ if (!row?.enabled) return result;
312
+ const { cases, retired } = await approvedCases(env, projectId, Boolean(options.includeExisting));
313
+ if (!cases.length && !retired.length) return result;
314
+ const client = await clientFor(env, row);
315
+ for (const content of cases) {
316
+ await env.DB.prepare(`
317
+ INSERT INTO jira_issue_links (project_id, case_set_id, case_id, case_revision, case_title, journey_id) VALUES (?, ?, ?, ?, ?, ?)
318
+ ON CONFLICT(project_id, case_set_id, case_id) DO NOTHING
319
+ `).bind(projectId, content.caseSetId, content.caseId, content.revision, content.title, content.journeyId ?? null).run();
320
+ const link = (await env.DB.prepare("SELECT * FROM jira_issue_links WHERE project_id = ? AND case_set_id = ? AND case_id = ?")
321
+ .bind(projectId, content.caseSetId, content.caseId).first<LinkRow>())!;
322
+ if (link.jira_issue_key) {
323
+ if (link.case_revision !== content.revision) {
324
+ await client.editIssue(link.jira_issue_key, { fields: { summary: ticketSummary(content), description: ticketDescription(env, projectId, content) } });
325
+ await env.DB.prepare(`
326
+ UPDATE jira_issue_links SET case_revision = ?, case_title = ?, journey_id = COALESCE(?, journey_id), updated_at = CURRENT_TIMESTAMP
327
+ WHERE project_id = ? AND case_set_id = ? AND case_id = ?
328
+ `).bind(content.revision, content.title, content.journeyId ?? null, projectId, content.caseSetId, content.caseId).run();
329
+ result.updated.push(link.jira_issue_key);
330
+ }
331
+ continue;
332
+ }
333
+ // Claim the case, so two syncs at once don't both create a ticket. A
334
+ // claim older than five minutes is from a sync that died mid-way.
335
+ const token = `creating:${newId("c")}`;
336
+ const claimed = await env.DB.prepare(`
337
+ UPDATE jira_issue_links SET jira_issue_id = ?, updated_at = CURRENT_TIMESTAMP
338
+ WHERE project_id = ? AND case_set_id = ? AND case_id = ? AND jira_issue_key IS NULL
339
+ AND (jira_issue_id IS NULL OR updated_at < datetime('now', '-5 minutes'))
340
+ `).bind(token, projectId, content.caseSetId, content.caseId).run();
341
+ if ((claimed.meta?.changes ?? 0) !== 1) continue;
342
+ try {
343
+ const label = await caseLabel(projectId, content.caseSetId, content.caseId);
344
+ const [found] = await client.search(`project = "${row.jira_project_key}" AND labels = "${TEST_LABEL}" AND labels = "${label}" ORDER BY created ASC`);
345
+ const issue = found ?? await client.createIssue({
346
+ project: { key: row.jira_project_key },
347
+ issuetype: { name: "Task" },
348
+ summary: ticketSummary(content),
349
+ labels: [TEST_LABEL, label],
350
+ description: ticketDescription(env, projectId, content),
351
+ });
352
+ await env.DB.prepare(`
353
+ UPDATE jira_issue_links SET jira_issue_key = ?, jira_issue_id = ?, case_revision = ?, case_title = ?, journey_id = COALESCE(?, journey_id), updated_at = CURRENT_TIMESTAMP
354
+ WHERE project_id = ? AND case_set_id = ? AND case_id = ?
355
+ `).bind(issue.key, issue.id, content.revision, content.title, content.journeyId ?? null, projectId, content.caseSetId, content.caseId).run();
356
+ (found ? result.linked : result.created).push(issue.key);
357
+ } catch (error) {
358
+ await env.DB.prepare("UPDATE jira_issue_links SET jira_issue_id = NULL WHERE project_id = ? AND case_set_id = ? AND case_id = ? AND jira_issue_id = ?")
359
+ .bind(projectId, content.caseSetId, content.caseId, token).run();
360
+ throw error;
361
+ }
362
+ }
363
+ for (const item of retired) {
364
+ const link = await env.DB.prepare("SELECT * FROM jira_issue_links WHERE project_id = ? AND case_set_id = ? AND case_id = ?")
365
+ .bind(projectId, item.caseSetId, item.caseId).first<LinkRow>();
366
+ if (!link?.jira_issue_key || link.last_result === "retired") continue;
367
+ await client.labels(link.jira_issue_key, { add: [RETIRED_LABEL] });
368
+ await client.comment(link.jira_issue_key, adf.doc(adf.paragraph(adf.text("Bugmole: this test case was retired in Bugmole, so it no longer runs and this ticket gets no more results."))));
369
+ await env.DB.prepare("UPDATE jira_issue_links SET last_result = 'retired', updated_at = CURRENT_TIMESTAMP WHERE project_id = ? AND case_set_id = ? AND case_id = ?")
370
+ .bind(projectId, item.caseSetId, item.caseId).run();
371
+ result.retired.push(link.jira_issue_key);
372
+ }
373
+ return result;
374
+ }
375
+
376
+ // --- Step 5/8: results back onto tickets ---------------------------------------------------
377
+
378
+ type RunRow = {
379
+ id: string;
380
+ projectId: string;
381
+ status: string;
382
+ completedAt: string | null;
383
+ caseScopeJson: string | null;
384
+ errorMessage: string | null;
385
+ stepsFailed: number | null;
386
+ };
387
+
388
+ const TERMINAL = new Set(["success", "partial", "error", "cancelled"]);
389
+ const VERDICTS = new Set(["passed", "failed", "blocked", "needs_setup"]);
390
+
391
+ function resultsOf(record: any, runId: string): Verdict[] {
392
+ if (!record || record.runId !== runId || typeof record.caseSetId !== "string" || !Array.isArray(record.results)) return [];
393
+ return record.results
394
+ .filter((item: any) => typeof item?.caseId === "string" && VERDICTS.has(item.status))
395
+ .map((item: any) => ({ caseSetId: record.caseSetId, caseId: item.caseId, status: item.status, detail: typeof item.detail === "string" ? item.detail : "" }));
396
+ }
397
+
398
+ function minutesSince(timestamp: string | null): number {
399
+ if (!timestamp) return Number.POSITIVE_INFINITY;
400
+ const parsed = Date.parse(timestamp.includes("T") ? timestamp : `${timestamp.replace(" ", "T")}Z`);
401
+ return Number.isFinite(parsed) ? (Date.now() - parsed) / 60_000 : Number.POSITIVE_INFINITY;
402
+ }
403
+
404
+ /**
405
+ * The case verdicts a run recorded. Runs record them in the project's
406
+ * storage just after they finish, so an empty answer soon after the run
407
+ * throws ResultsNotReady and the outbox asks again. A retest of one case
408
+ * whose verdicts can't be read (a project's own storage) is judged by the
409
+ * run's own outcome, since that run tested only that case.
410
+ */
411
+ export async function verdictsForRun(env: Env, run: RunRow, caseSetIds: string[]): Promise<Verdict[]> {
412
+ const scope = run.caseScopeJson ? parse(run.caseScopeJson) as unknown as RunCaseScope : null;
413
+ const managed = await managedStorage(env, run.projectId);
414
+ if (managed) {
415
+ const found: Verdict[] = [];
416
+ const wanted = scope ? [scope.caseSetId] : caseSetIds;
417
+ for (const caseSetId of wanted) {
418
+ const latest = resultsOf(await readJson(env, `projects/${run.projectId}/test-case-results/${caseSetId}.json`), run.id);
419
+ if (latest.length) {
420
+ found.push(...latest);
421
+ continue;
422
+ }
423
+ const history = (await listKeys(env, `projects/${run.projectId}/test-case-history/${caseSetId}/`)).filter((key) => key.endsWith(`-${run.id}.json`));
424
+ for (const key of history) found.push(...resultsOf(await readJson(env, key), run.id));
425
+ }
426
+ const scoped = scope ? found.filter((verdict) => scope.caseIds.includes(verdict.caseId)) : found;
427
+ if (scoped.length) return scoped;
428
+ if (minutesSince(run.completedAt) < (scope ? 3 : 30)) throw new ResultsNotReady(`No case verdicts for run ${run.id} yet`);
429
+ }
430
+ if (!scope || run.status === "cancelled") return [];
431
+ const status = run.status === "success" ? "passed" : "failed";
432
+ const detail = status === "passed" ? "The retest run passed." : run.errorMessage?.trim() || `The retest run ended ${run.status}${run.stepsFailed ? ` with ${run.stepsFailed} failed step(s)` : ""}.`;
433
+ return scope.caseIds.map((caseId) => ({ caseSetId: scope.caseSetId, caseId, status, detail }));
434
+ }
435
+
436
+ function mentionsFor(issue: JiraIssue, row: ConnectionRow): AdfNode[] {
437
+ const people = new Map<string, string | undefined>();
438
+ for (const person of [issue.fields.assignee, issue.fields.reporter]) {
439
+ if (person?.accountId && person.accountId !== row.account_id) people.set(person.accountId, person.displayName);
440
+ }
441
+ return [...people].flatMap(([accountId, name]) => [adf.mention(accountId, name), adf.text(" ")]);
442
+ }
443
+
444
+ /** Moves one ticket for one verdict and comments with the result. */
445
+ export async function applyVerdict(env: Env, client: JiraClient, row: ConnectionRow, issueKey: string, verdict: Verdict, run: { id: string; projectId: string }, revision: number): Promise<"done" | "blocked" | "labelled" | "commented"> {
446
+ const issue = await client.getIssue(issueKey);
447
+ const evidence = adf.link("the run and its evidence", runUrl(env, run.projectId, run.id));
448
+ const detail = verdict.detail ? ` ${verdict.detail}` : "";
449
+ const labels = issue.fields.labels ?? [];
450
+ if (verdict.status === "passed") {
451
+ let outcome: "done" | "commented" = "commented";
452
+ if (issue.fields.status?.statusCategory?.key !== "done") {
453
+ const transition = doneTransition(await client.transitions(issueKey));
454
+ if (transition) {
455
+ await client.transition(issueKey, transition.id);
456
+ outcome = "done";
457
+ }
458
+ } else {
459
+ outcome = "done";
460
+ }
461
+ if (labels.includes(BLOCKED_LABEL)) await client.labels(issueKey, { remove: [BLOCKED_LABEL] });
462
+ await client.comment(issueKey, adf.doc(adf.paragraph(
463
+ adf.strong("Bugmole: passed."),
464
+ adf.text(`${detail} Revision ${revision} of ${verdict.caseSetId}/${verdict.caseId}. See `),
465
+ evidence,
466
+ adf.text("."),
467
+ )));
468
+ return outcome;
469
+ }
470
+ if (verdict.status === "needs_setup") {
471
+ await client.comment(issueKey, adf.doc(adf.paragraph(
472
+ adf.strong("Bugmole: couldn't run this test yet."),
473
+ adf.text(`${detail} See `),
474
+ evidence,
475
+ adf.text("."),
476
+ )));
477
+ return "commented";
478
+ }
479
+ let outcome: "blocked" | "labelled" = "labelled";
480
+ const transition = issue.fields.status?.name?.toLowerCase() === "blocked" ? null : blockedTransition(await client.transitions(issueKey));
481
+ if (issue.fields.status?.name?.toLowerCase() === "blocked") outcome = "blocked";
482
+ else if (transition) {
483
+ await client.transition(issueKey, transition.id);
484
+ outcome = "blocked";
485
+ }
486
+ if (outcome === "labelled" && !labels.includes(BLOCKED_LABEL)) await client.labels(issueKey, { add: [BLOCKED_LABEL] });
487
+ await client.comment(issueKey, adf.doc(adf.paragraph(
488
+ ...mentionsFor(issue, row),
489
+ adf.strong(verdict.status === "blocked" ? "Bugmole: blocked." : "Bugmole: failed."),
490
+ adf.text(`${detail} Revision ${revision} of ${verdict.caseSetId}/${verdict.caseId}. See `),
491
+ evidence,
492
+ adf.text(". When it's fixed, mention Bugmole in a comment on this ticket to retest just this case."),
493
+ )));
494
+ return outcome;
495
+ }
496
+
497
+ export type ReportResult = { reported: Array<{ issueKey: string; status: string; outcome: string }> };
498
+
499
+ export async function reportRun(env: Env, runId: string): Promise<ReportResult> {
500
+ const result: ReportResult = { reported: [] };
501
+ const run = await env.DB.prepare(`
502
+ SELECT id, project_id AS projectId, status, completed_at AS completedAt, case_scope_json AS caseScopeJson,
503
+ error_message AS errorMessage, steps_failed AS stepsFailed
504
+ FROM runs WHERE id = ?
505
+ `).bind(runId).first<RunRow>();
506
+ if (!run || !TERMINAL.has(run.status)) return result;
507
+ const row = await connectionOf(env, run.projectId);
508
+ if (!row?.enabled) return result;
509
+ const links = await env.DB.prepare("SELECT * FROM jira_issue_links WHERE project_id = ? AND jira_issue_key IS NOT NULL AND COALESCE(last_result, '') <> 'retired'")
510
+ .bind(run.projectId).all<LinkRow>();
511
+ if (!links.results.length) return result;
512
+ const verdicts = await verdictsForRun(env, run, [...new Set(links.results.map((link) => link.case_set_id))]);
513
+ if (!verdicts.length) return result;
514
+ const client = await clientFor(env, row);
515
+ for (const verdict of verdicts) {
516
+ const link = links.results.find((item) => item.case_set_id === verdict.caseSetId && item.case_id === verdict.caseId);
517
+ if (!link?.jira_issue_key || link.last_run_id === run.id) continue;
518
+ const outcome = await applyVerdict(env, client, row, link.jira_issue_key, verdict, run, link.case_revision);
519
+ await env.DB.prepare(`
520
+ UPDATE jira_issue_links SET last_result = ?, last_run_id = ?, updated_at = CURRENT_TIMESTAMP
521
+ WHERE project_id = ? AND case_set_id = ? AND case_id = ?
522
+ `).bind(verdict.status, run.id, run.projectId, link.case_set_id, link.case_id).run();
523
+ result.reported.push({ issueKey: link.jira_issue_key, status: verdict.status, outcome });
524
+ }
525
+ return result;
526
+ }
527
+
528
+ // --- Step 7/8: a mention asks for a retest of that ticket's case ---------------------------
529
+
530
+ export type RetestOutcome =
531
+ | { queued: true; runId: string; issueKey: string; caseSetId: string; caseId: string }
532
+ | { queued: false; reason: "not_bugmole_ticket" | "no_plan" | "run_refused"; issueKey: string; message?: string };
533
+
534
+ async function planFor(env: Env, projectId: string, link: LinkRow): Promise<string | null> {
535
+ let journeyId = link.journey_id;
536
+ if (!journeyId && (await managedStorage(env, projectId))) {
537
+ const caseSet = await readJson(env, `projects/${projectId}/test-cases/${link.case_set_id}.json`);
538
+ journeyId = typeof caseSet?.journeyId === "string" ? caseSet.journeyId : null;
539
+ if (journeyId) {
540
+ await env.DB.prepare("UPDATE jira_issue_links SET journey_id = ? WHERE project_id = ? AND case_set_id = ? AND case_id = ?")
541
+ .bind(journeyId, projectId, link.case_set_id, link.case_id).run();
542
+ }
543
+ }
544
+ if (!journeyId) return null;
545
+ const plan = await env.DB.prepare("SELECT plan_id AS planId FROM plan_drafts WHERE project_id = ? AND journey_id = ? ORDER BY plan_id LIMIT 1")
546
+ .bind(projectId, journeyId).first<{ planId: string }>();
547
+ return plan?.planId ?? null;
548
+ }
549
+
550
+ export async function requestRetest(env: Env, row: ConnectionRow, issueKey: string, commentId: string): Promise<RetestOutcome> {
551
+ const link = await env.DB.prepare("SELECT * FROM jira_issue_links WHERE project_id = ? AND jira_issue_key = ?")
552
+ .bind(row.project_id, issueKey).first<LinkRow>();
553
+ if (!link) return { queued: false, reason: "not_bugmole_ticket", issueKey };
554
+ const planId = await planFor(env, row.project_id, link);
555
+ if (!planId) return { queued: false, reason: "no_plan", issueKey };
556
+ if (!runService.create) throw new Error("Run creation isn't wired");
557
+ // Runs as the person who connected Jira, through the same checks as the API.
558
+ const principal: Principal = { aegisId: row.created_by, projectIds: [], scopes: ["*"] };
559
+ const request = new Request("https://registry.internal/jira", {
560
+ method: "POST",
561
+ body: JSON.stringify({
562
+ planId,
563
+ idempotencyKey: `jira:${row.id}:${issueKey}:${commentId}`,
564
+ executionMode: row.execution_mode,
565
+ ...(row.environment_id ? { environmentId: row.environment_id } : {}),
566
+ }),
567
+ });
568
+ const caseScope: RunCaseScope = { caseSetId: link.case_set_id, caseIds: [link.case_id], jiraIssueKey: issueKey };
569
+ const created = await runService.create(request, env, principal, row.project_id, detachedContext(), { triggerSource: "jira", caseScope });
570
+ const body = await created.json().catch(() => ({})) as { run?: { id: string }; error?: string };
571
+ if (!created.ok || !body.run) return { queued: false, reason: "run_refused", issueKey, message: body.error ?? `status ${created.status}` };
572
+ return { queued: true, runId: body.run.id, issueKey, caseSetId: link.case_set_id, caseId: link.case_id };
573
+ }
574
+
575
+ export function isPermanentJiraError(error: unknown): boolean {
576
+ return error instanceof JiraApiError && [400, 401, 403, 404].includes(error.status);
577
+ }
@@ -1,6 +1,7 @@
1
1
  // Deletes run history older than each workspace's plan allows, including the
2
2
  // evidence Bugmole stores for it. Runs in small batches every day.
3
3
  import type { Env } from "../core.js";
4
+ import { entitlementMode, historyCutoff } from "../billing/enforcement.js";
4
5
  import { getEntitlements } from "../billing/entitlements.js";
5
6
  import { recordAudit } from "../org/audit.js";
6
7
  import { registerJob } from "./index.js";
@@ -10,9 +11,7 @@ const MAX_RUNS_PER_TICK = 500;
10
11
  /** No plan keeps less history than this, so younger runs are never examined. */
11
12
  const MIN_HISTORY_DAYS = 7;
12
13
 
13
- export function historyCutoff(historyDays: number | null, now: Date): string | null {
14
- return historyDays === null ? null : new Date(now.getTime() - historyDays * 86_400_000).toISOString();
15
- }
14
+ export { historyCutoff };
16
15
 
17
16
  async function deletePrefix(bucket: R2Bucket, prefix: string): Promise<number> {
18
17
  let cursor: string | undefined;
@@ -29,7 +28,11 @@ async function deletePrefix(bucket: R2Bucket, prefix: string): Promise<number> {
29
28
  return deleted;
30
29
  }
31
30
 
32
- export async function enforceRetention(env: Env, now = new Date()): Promise<{ runs: number; objects: number }> {
31
+ /**
32
+ * Deletion follows the "entitlements" rollout like every other plan limit:
33
+ * enforced, it deletes; in shadow or off, it only logs what it would delete.
34
+ */
35
+ export async function enforceRetention(env: Env, now = new Date()): Promise<{ runs: number; objects: number; wouldDelete: number }> {
33
36
  const oldest = new Date(now.getTime() - MIN_HISTORY_DAYS * 86_400_000).toISOString();
34
37
  const workspaces = await env.DB.prepare(`
35
38
  SELECT DISTINCT workspace_id AS workspaceId FROM runs
@@ -37,11 +40,24 @@ export async function enforceRetention(env: Env, now = new Date()): Promise<{ ru
37
40
  `).bind(oldest).all<{ workspaceId: string }>();
38
41
  let runs = 0;
39
42
  let objects = 0;
43
+ let wouldDelete = 0;
40
44
  for (const { workspaceId } of workspaces.results) {
41
45
  if (runs >= MAX_RUNS_PER_TICK) break;
42
46
  const { historyDays } = await getEntitlements(env, workspaceId);
43
47
  const cutoff = historyCutoff(historyDays, now);
44
48
  if (!cutoff) continue;
49
+ const mode = await entitlementMode(env, workspaceId);
50
+ if (mode !== "enforce") {
51
+ const count = await env.DB.prepare(`
52
+ SELECT COUNT(*) AS count FROM runs
53
+ WHERE workspace_id = ? AND status IN ${TERMINAL} AND COALESCE(completed_at, requested_at) < ?
54
+ `).bind(workspaceId, cutoff).first<{ count: number }>();
55
+ if (count?.count) {
56
+ wouldDelete += count.count;
57
+ console.warn(`[entitlements] ${mode}: retention would delete ${count.count} run(s) older than ${historyDays} days for ${workspaceId}`);
58
+ }
59
+ continue;
60
+ }
45
61
  const expired = await env.DB.prepare(`
46
62
  SELECT id, project_id AS projectId FROM runs
47
63
  WHERE workspace_id = ? AND status IN ${TERMINAL} AND COALESCE(completed_at, requested_at) < ?
@@ -60,7 +76,7 @@ export async function enforceRetention(env: Env, now = new Date()): Promise<{ ru
60
76
  workspaceId, action: "runs.expired", data: { count: expired.results.length, historyDays, cutoff },
61
77
  });
62
78
  }
63
- return { runs, objects };
79
+ return { runs, objects, wouldDelete };
64
80
  }
65
81
 
66
82
  registerJob("daily", "retention", async (env, _ctx, now) => {
@@ -193,7 +193,8 @@ export const TOOLS: ToolDefinition[] = [
193
193
  workspace: project.workspaceName,
194
194
  storage: project.storageMode === "managed" ? "Bugmole" : "your own",
195
195
  runsGoTo: project.defaultExecutionMode === "cloud" ? "Bugmole Cloud" : "your workers",
196
- readOnly: Boolean(project.lockedAt),
196
+ readOnly: Boolean(project.lockedAt || project.inactiveAt),
197
+ paused: Boolean(project.inactiveAt),
197
198
  environments: (project.environments ?? []).map((environment: Record<string, any>) => ({
198
199
  id: environment.id,
199
200
  name: environment.name,