co-maintainer 0.5.0 → 0.5.1-beta.2

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 (177) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/anthropic.d.ts +55 -0
  5. package/dist/src/ai/anthropic.js +262 -0
  6. package/dist/src/ai/batch.d.ts +2 -2
  7. package/dist/src/ai/batch.js +1 -1
  8. package/dist/src/ai/estimate.d.ts +4 -0
  9. package/dist/src/ai/estimate.js +17 -2
  10. package/dist/src/ai/mermaid_loop.js +7 -4
  11. package/dist/src/ai/openai.d.ts +45 -0
  12. package/dist/src/ai/openai.js +206 -0
  13. package/dist/src/ai/opencode.d.ts +10 -0
  14. package/dist/src/ai/opencode.js +76 -0
  15. package/dist/src/ai/openrouter.d.ts +23 -7
  16. package/dist/src/ai/openrouter.js +39 -23
  17. package/dist/src/ai/pricing.js +1 -1
  18. package/dist/src/ai/provider.d.ts +9 -0
  19. package/dist/src/ai/provider.js +42 -3
  20. package/dist/src/ai/verify.d.ts +9 -1
  21. package/dist/src/ai/verify.js +81 -3
  22. package/dist/src/cli/args.d.ts +1 -2
  23. package/dist/src/cli/args.js +38 -26
  24. package/dist/src/cli/commands/config.d.ts +1 -1
  25. package/dist/src/cli/commands/config.js +10 -4
  26. package/dist/src/cli/commands/probe.js +3 -11
  27. package/dist/src/cli/commands/registry.d.ts +1 -9
  28. package/dist/src/cli/commands/registry.js +75 -10
  29. package/dist/src/cli/commands/review.js +18 -13
  30. package/dist/src/cli/commands/rollback.d.ts +1 -0
  31. package/dist/src/cli/commands/rollback.js +42 -0
  32. package/dist/src/cli/commands/serve.js +2 -3
  33. package/dist/src/cli/commands/set.js +71 -7
  34. package/dist/src/cli/commands/view.js +10 -9
  35. package/dist/src/cli/error.d.ts +4 -4
  36. package/dist/src/cli/error.js +4 -4
  37. package/dist/src/cli/main.d.ts +4 -4
  38. package/dist/src/cli/main.js +13 -8
  39. package/dist/src/cli/prompt.js +1 -1
  40. package/dist/src/cli/review_args.d.ts +9 -3
  41. package/dist/src/cli/review_args.js +28 -5
  42. package/dist/src/cli/review_github.d.ts +31 -0
  43. package/dist/src/cli/review_github.js +128 -0
  44. package/dist/src/cli/review_result.d.ts +27 -16
  45. package/dist/src/cli/review_result.js +7 -7
  46. package/dist/src/config.d.ts +28 -6
  47. package/dist/src/config.js +15 -1
  48. package/dist/src/git/default_branch.d.ts +1 -1
  49. package/dist/src/git/default_branch.js +1 -1
  50. package/dist/src/github/app.js +2 -2
  51. package/dist/src/github/app_manifest.d.ts +1 -7
  52. package/dist/src/github/app_manifest.js +2 -7
  53. package/dist/src/github/client.d.ts +4 -1
  54. package/dist/src/github/client.js +14 -2
  55. package/dist/src/github/gh.js +10 -7
  56. package/dist/src/github/pat.js +4 -4
  57. package/dist/src/github/permissions.d.ts +36 -0
  58. package/dist/src/github/permissions.js +334 -0
  59. package/dist/src/knowledge/facts.js +1 -1
  60. package/dist/src/knowledge/guide.d.ts +3 -3
  61. package/dist/src/knowledge/guide.js +5 -5
  62. package/dist/src/knowledge/probe.js +1 -2
  63. package/dist/src/knowledge/sections.d.ts +3 -3
  64. package/dist/src/knowledge/sections.js +3 -3
  65. package/dist/src/knowledge/skill.d.ts +1 -1
  66. package/dist/src/knowledge/skill.js +4 -4
  67. package/dist/src/knowledge/synthesis.d.ts +1 -1
  68. package/dist/src/knowledge/synthesis.js +5 -5
  69. package/dist/src/knowledge/types.d.ts +1 -1
  70. package/dist/src/local/codegraph_exclude.d.ts +1 -1
  71. package/dist/src/local/codegraph_exclude.js +1 -1
  72. package/dist/src/local/codegraph_prepare.js +1 -1
  73. package/dist/src/local/git_ops.d.ts +1 -1
  74. package/dist/src/local/git_ops.js +1 -1
  75. package/dist/src/local/git_parse.d.ts +2 -2
  76. package/dist/src/local/git_parse.js +2 -2
  77. package/dist/src/local/git_revision.d.ts +1 -1
  78. package/dist/src/local/git_revision.js +1 -1
  79. package/dist/src/local/git_untracked.d.ts +1 -1
  80. package/dist/src/local/git_untracked.js +1 -1
  81. package/dist/src/local/review_local.js +17 -14
  82. package/dist/src/local/review_lock.d.ts +1 -1
  83. package/dist/src/local/review_lock.js +1 -1
  84. package/dist/src/pr/checkout.js +2 -2
  85. package/dist/src/pr/findings.js +1 -1
  86. package/dist/src/pr/findings_json.d.ts +2 -2
  87. package/dist/src/pr/findings_json.js +1 -1
  88. package/dist/src/pr/github_revision.js +1 -1
  89. package/dist/src/pr/review_copy.d.ts +2 -2
  90. package/dist/src/pr/review_copy.js +2 -2
  91. package/dist/src/pr/reviewer.d.ts +6 -0
  92. package/dist/src/pr/reviewer.js +49 -39
  93. package/dist/src/remote/client.d.ts +4 -0
  94. package/dist/src/remote/client.js +84 -23
  95. package/dist/src/remote/fixtures.js +1 -1
  96. package/dist/src/remote/http.d.ts +10 -1
  97. package/dist/src/remote/http.js +15 -3
  98. package/dist/src/remote/schema.d.ts +3 -3
  99. package/dist/src/remote/schema.js +3 -3
  100. package/dist/src/remote/server/body.d.ts +1 -1
  101. package/dist/src/remote/server/body.js +1 -1
  102. package/dist/src/remote/server/byok_keys.d.ts +15 -0
  103. package/dist/src/remote/server/byok_keys.js +40 -0
  104. package/dist/src/remote/server/guides.js +2 -2
  105. package/dist/src/remote/server/routes.js +8 -1
  106. package/dist/src/remote/validate.js +15 -1
  107. package/dist/src/review/blocking.d.ts +2 -2
  108. package/dist/src/review/blocking.js +2 -2
  109. package/dist/src/review/carry_over.d.ts +2 -2
  110. package/dist/src/review/carry_over.js +2 -2
  111. package/dist/src/review/guides.js +1 -1
  112. package/dist/src/server/api/remote_tokens.js +2 -0
  113. package/dist/src/server/api/repos.js +51 -0
  114. package/dist/src/server/api/settings.js +10 -0
  115. package/dist/src/server/errors.d.ts +1 -1
  116. package/dist/src/server/http.js +1 -1
  117. package/dist/src/server/pages/activity.d.ts +2 -0
  118. package/dist/src/server/pages/activity.js +18 -4
  119. package/dist/src/server/pages/add_repo.js +61 -1
  120. package/dist/src/server/pages/analytics.js +18 -8
  121. package/dist/src/server/pages/cost.d.ts +21 -0
  122. package/dist/src/server/pages/cost.js +72 -0
  123. package/dist/src/server/pages/pr.js +5 -4
  124. package/dist/src/server/pages/repo.js +3 -2
  125. package/dist/src/server/pages/repo_prs.js +3 -2
  126. package/dist/src/server/pages/repo_remote.js +3 -2
  127. package/dist/src/server/pages/repo_settings.js +326 -12
  128. package/dist/src/server/pages/router.js +3 -2
  129. package/dist/src/server/pages/settings.js +50 -6
  130. package/dist/src/server/pages/styles.d.ts +1 -1
  131. package/dist/src/server/pages/styles.js +1 -1
  132. package/dist/src/services/credentials.js +8 -11
  133. package/dist/src/services/dashboard.d.ts +22 -9
  134. package/dist/src/services/dashboard.js +16 -2
  135. package/dist/src/services/probe.js +1 -1
  136. package/dist/src/services/remote_review.js +142 -35
  137. package/dist/src/services/remote_review_abort.d.ts +5 -1
  138. package/dist/src/services/remote_review_abort.js +9 -1
  139. package/dist/src/services/review.d.ts +1 -1
  140. package/dist/src/services/review.js +19 -5
  141. package/dist/src/services/review_policy.d.ts +116 -0
  142. package/dist/src/services/review_policy.js +489 -0
  143. package/dist/src/services/setup.d.ts +4 -3
  144. package/dist/src/services/setup.js +18 -16
  145. package/dist/src/services/setup_checklist.js +2 -2
  146. package/dist/src/services/webhook.d.ts +23 -0
  147. package/dist/src/services/webhook.js +141 -34
  148. package/dist/src/store/app_db.d.ts +5 -0
  149. package/dist/src/store/app_db.js +69 -13
  150. package/dist/src/store/backup.d.ts +31 -0
  151. package/dist/src/store/backup.js +158 -0
  152. package/dist/src/store/migrations.d.ts +1 -1
  153. package/dist/src/store/migrations.js +21 -1
  154. package/dist/src/store/remote_review_inputs.d.ts +3 -0
  155. package/dist/src/store/remote_review_inputs.js +3 -3
  156. package/dist/src/store/remote_tokens.d.ts +1 -1
  157. package/dist/src/store/remote_tokens.js +1 -1
  158. package/dist/src/store/repos.d.ts +2 -2
  159. package/dist/src/store/repos.js +1 -1
  160. package/dist/src/store/reviews.d.ts +21 -13
  161. package/dist/src/store/reviews.js +60 -15
  162. package/dist/src/store/rows.d.ts +10 -0
  163. package/dist/src/tools/codegraph.d.ts +4 -4
  164. package/dist/src/tools/codegraph.js +5 -5
  165. package/dist/src/tools/codegraph_exec.d.ts +1 -1
  166. package/dist/src/tools/codegraph_exec.js +1 -1
  167. package/dist/src/types.d.ts +12 -6
  168. package/dist/src/util/cost.d.ts +52 -0
  169. package/dist/src/util/cost.js +70 -0
  170. package/dist/src/util/log.d.ts +1 -1
  171. package/dist/src/util/log.js +2 -2
  172. package/dist/src/util/redact.js +2 -0
  173. package/dist/src/util/run_summary.d.ts +7 -2
  174. package/dist/src/util/run_summary.js +28 -2
  175. package/dist/src/util/time.d.ts +1 -1
  176. package/dist/src/util/time.js +1 -1
  177. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { VERSION } from "../version.js";
2
+ import { CliError } from "../cli/error.js";
2
3
  import { resolvedFromFirstReview, revisionStats, sortResolvedFindings, summaryCounts, toJsonFinding, } from "../cli/review_result.js";
3
4
  import { parseFindings } from "../pr/findings.js";
4
5
  import { codegraphToolHandlersForBridge } from "../pr/codegraph_tools.js";
@@ -7,23 +8,51 @@ import { buildCarryPromptSection, classifyCarryItems, guideRebuiltSince, increme
7
8
  import { remoteBridgeToolHandlers } from "../remote/tool_bridge.js";
8
9
  import { revisionFromSubmitJson } from "../remote/revision_from_submit.js";
9
10
  import { closeRemoteSession, openRemoteSession, setRemoteSyncResult, } from "../remote/server/sessions.js";
10
- import { readConfig } from "../config.js";
11
+ import { readConfig, remoteByokPolicy } from "../config.js";
12
+ import { dropByokKey, getByokKey, hasByokKey, registerByokKey, } from "../remote/server/byok_keys.js";
11
13
  import { reviewBlockingFrom } from "../review/blocking.js";
12
14
  import { loadGuides } from "../review/guides.js";
13
15
  import { withLogSink } from "../util/log.js";
14
16
  import { cancel, registerHandler } from "./jobs.js";
15
17
  import { recordAiCost } from "./setup.js";
18
+ import { addResponseCost, costColumns, costUsage, emptyTally, settle, } from "../util/cost.js";
16
19
  import { findRepoByFullName } from "../store/repos.js";
17
20
  import { deleteRemoteReviewInput, findRemoteReviewInputByRequest, getRemoteReviewInput, insertRemoteReviewInput, } from "../store/remote_review_inputs.js";
18
21
  import { insertFinding, listCarryableFindingsForReview, } from "../store/findings.js";
19
22
  import { deleteSubjectRevision, getOrCreateRemoteSubject, getSubjectRevision, parseRevisionFiles, parseVisiblePaths, saveSubjectRevision, } from "../store/subjects.js";
20
23
  import { getReviewByJobId, insertRemoteReview, setReviewStatus, } from "../store/reviews.js";
21
24
  import { insertJob, listJobsByQueueKey, setJobStatus } from "../store/jobs.js";
25
+ import { getAppDb } from "../store/app_db.js";
22
26
  const REPO_UNAVAILABLE = "Sorry, we could not access this repository.";
27
+ /** A remote review failure whose `code` the client can branch on. `runJob`
28
+ * stores `error.message`, so the text is what reaches the client's abort
29
+ * message. */
30
+ class RemoteReviewError extends Error {
31
+ code;
32
+ constructor(code, message) {
33
+ super(message);
34
+ this.name = "RemoteReviewError";
35
+ this.code = code;
36
+ }
37
+ }
23
38
  function remoteQueueKey(repo, branch, tokenId) {
24
39
  return `remote:${repo}:${branch}:${tokenId}`;
25
40
  }
26
- function reviewOptionsForRepo(repo, useCodegraph) {
41
+ /** True only for a provider *authentication* rejection: the providers raise
42
+ * `openrouter_unauthorized`, `openai_unauthorized`, `anthropic_unauthorized`
43
+ * and `opencode_unauthorized`. The list is explicit so an unrelated error that
44
+ * happens to end in `_unauthorized` is not relabelled as a bad client key.
45
+ */
46
+ function isProviderAuthError(error) {
47
+ return (error instanceof CliError &&
48
+ [
49
+ "openrouter_unauthorized",
50
+ "openai_unauthorized",
51
+ "anthropic_unauthorized",
52
+ "opencode_unauthorized",
53
+ ].includes(error.code));
54
+ }
55
+ function reviewOptionsForRepo(repo, useCodegraph, aiToken) {
27
56
  const config = readConfig();
28
57
  return {
29
58
  command: "review",
@@ -37,7 +66,7 @@ function reviewOptionsForRepo(repo, useCodegraph) {
37
66
  auth: config.auth ?? "gh",
38
67
  githubPat: config.githubPat,
39
68
  ai: config.ai ?? "openrouter",
40
- aiToken: config.token,
69
+ aiToken: aiToken ?? config.token,
41
70
  lowModel: config.lowModel,
42
71
  highModel: config.highModel,
43
72
  synthesisVersion: 16,
@@ -54,10 +83,16 @@ function supersedeActiveRemoteJobs(queueKey, supersededBy) {
54
83
  for (const job of listJobsByQueueKey(queueKey, ["queued", "running"])) {
55
84
  if (job.id === supersededBy)
56
85
  continue;
86
+ // A superseded job never becomes terminal through `runJob`, so its BYOK
87
+ // key must be dropped here or it would sit in memory for the process
88
+ // lifetime, and its input row must go too so a later retry is not blocked
89
+ // by a dead row.
90
+ dropByokKey(job.id);
57
91
  if (job.status === "running") {
58
92
  cancel(job.id, "superseded");
59
93
  }
60
94
  else {
95
+ deleteRemoteReviewInput(job.id);
61
96
  setJobStatus(job.id, "canceled", {
62
97
  superseded_by: supersededBy,
63
98
  cancel_reason: "superseded",
@@ -69,6 +104,16 @@ export function submitRemoteReview(token, body) {
69
104
  const requestId = String(body.requestId);
70
105
  const existing = findRemoteReviewInputByRequest(token.id, requestId);
71
106
  if (existing) {
107
+ // A retried submit must not change who pays: the key is not in the body
108
+ // this time, so re-register it from the first request's recorded policy.
109
+ const bill = existing.billed_to === "byok" ? "byok" : "server";
110
+ if (bill === "byok" && !hasByokKey(existing.job_id)) {
111
+ return {
112
+ error: "Your key was not kept. Submit the review again.",
113
+ status: 409,
114
+ code: "byok_key_lost",
115
+ };
116
+ }
72
117
  const review = getReviewByJobId(existing.job_id);
73
118
  if (review) {
74
119
  return { jobId: existing.job_id, reviewId: review.id };
@@ -78,6 +123,24 @@ export function submitRemoteReview(token, body) {
78
123
  if (!repoRow || repoRow.active !== 1) {
79
124
  return { error: REPO_UNAVAILABLE, status: 404, code: "repo_unavailable" };
80
125
  }
126
+ const byokRaw = body.byok;
127
+ const submittedKey = byokRaw && typeof byokRaw.key === "string" ? byokRaw.key : undefined;
128
+ const policy = remoteByokPolicy();
129
+ if (policy === "off" && submittedKey !== undefined) {
130
+ return {
131
+ error: "This server does not accept your own key.",
132
+ status: 403,
133
+ code: "byok_not_allowed",
134
+ };
135
+ }
136
+ if (policy === "require" && submittedKey === undefined) {
137
+ return {
138
+ error: "This server requires your own key. Run with --remote-byok.",
139
+ status: 403,
140
+ code: "byok_required",
141
+ };
142
+ }
143
+ const billedTo = submittedKey !== undefined ? "byok" : "server";
81
144
  if (typeof body.branch !== "string") {
82
145
  return {
83
146
  error: "branch must be a string",
@@ -102,26 +165,50 @@ export function submitRemoteReview(token, body) {
102
165
  capabilitiesJson: JSON.stringify(capabilities),
103
166
  requestId,
104
167
  tokenId: token.id,
168
+ billedTo,
105
169
  });
170
+ // The review and job rows go in one transaction so a failure cannot leave a
171
+ // partially written submission behind: no review with no job, and no queued
172
+ // job whose input row points a retry at a job that never really started.
173
+ // The key is registered only after COMMIT, so it never outlives a failed
174
+ // setup.
106
175
  const config = readConfig();
107
- insertRemoteReview({
108
- id: reviewId,
109
- subjectId: subject.id,
110
- repo: repoRow.full_name,
111
- branch,
112
- tokenId: token.id,
113
- tokenName: token.name,
114
- jobId,
115
- scope: "remote",
116
- model: config.highModel ?? "unknown",
117
- });
118
- insertJob({
119
- id: jobId,
120
- type: "remote_review",
121
- repo: repoRow.full_name,
122
- queueKey,
123
- args: { subjectId: subject.id, requestId, reviewId },
124
- });
176
+ const db = getAppDb();
177
+ db.exec("BEGIN");
178
+ try {
179
+ insertRemoteReview({
180
+ id: reviewId,
181
+ subjectId: subject.id,
182
+ repo: repoRow.full_name,
183
+ branch,
184
+ tokenId: token.id,
185
+ tokenName: token.name,
186
+ jobId,
187
+ scope: "remote",
188
+ model: config.highModel ?? "unknown",
189
+ });
190
+ insertJob({
191
+ id: jobId,
192
+ type: "remote_review",
193
+ repo: repoRow.full_name,
194
+ queueKey,
195
+ args: { subjectId: subject.id, requestId, reviewId },
196
+ });
197
+ db.exec("COMMIT");
198
+ }
199
+ catch (error) {
200
+ db.exec("ROLLBACK");
201
+ // Nothing of this submission survives, so the input row must go too, or a
202
+ // retry with the same request id would find a row whose key is gone and be
203
+ // told byok_key_lost.
204
+ deleteRemoteReviewInput(jobId);
205
+ throw error;
206
+ }
207
+ // The key is pulled out of the body before anything persists it: it lives
208
+ // only in this process's memory, tied to the job.
209
+ if (billedTo === "byok" && submittedKey !== undefined) {
210
+ registerByokKey(jobId, submittedKey);
211
+ }
125
212
  openRemoteSession({
126
213
  jobId,
127
214
  reviewId,
@@ -147,22 +234,31 @@ export function registerRemoteReviewHandler() {
147
234
  setReviewStatus(reviewId, "running");
148
235
  log("info", `remote review started for ${job.repo}`);
149
236
  let completed = false;
237
+ // A BYOK review runs on the client's key, never the server's. If the key
238
+ // is gone (a restart between submit and run), stop with a typed message
239
+ // instead of silently falling back to the server key.
240
+ const billedTo = input.billed_to === "byok" ? "byok" : "server";
241
+ const byokKey = billedTo === "byok" ? getByokKey(job.id) : undefined;
150
242
  try {
243
+ if (billedTo === "byok" && byokKey === undefined) {
244
+ throw new RemoteReviewError("byok_key_lost", "Your key was not kept across a server restart. Submit the review again.");
245
+ }
151
246
  const revision = revisionFromSubmitJson(JSON.parse(input.revision_json));
152
247
  const caps = JSON.parse(input.capabilities_json);
153
248
  const allowed = new Set((caps.tools ?? [])
154
249
  .map((tool) => tool.name)
155
250
  .filter((name) => typeof name === "string" && name));
156
251
  const useCodegraph = allowed.size > 0;
157
- const options = reviewOptionsForRepo(job.repo, useCodegraph);
252
+ const options = reviewOptionsForRepo(job.repo, useCodegraph, byokKey);
158
253
  if (!options.aiToken || options.ai === "none") {
159
254
  throw new Error("server AI is not configured for remote review");
160
255
  }
256
+ const billed = byokKey !== undefined ? "byok" : "server";
161
257
  const extras = {};
162
258
  if (subjectId) {
163
259
  const subjectRevision = getSubjectRevision(subjectId);
164
260
  // A guide rebuilt after the previous review invalidates its verdicts
165
- // and its "unchanged" suppression (CORE-42 / F03): start fresh so a
261
+ // and its "unchanged" suppression: start fresh so a
166
262
  // stale finding cannot mask a new one.
167
263
  const guideRebuilt = subjectRevision !== undefined &&
168
264
  guideRebuiltSince(subjectRevision.guide_built_at, (await loadGuides(job.repo)).guideBuiltAt);
@@ -198,15 +294,11 @@ export function registerRemoteReviewHandler() {
198
294
  const started = performance.now();
199
295
  let tokensIn = 0;
200
296
  let tokensOut = 0;
201
- let costUsd = 0;
202
- let costKnown = true;
297
+ const costTally = emptyTally();
203
298
  const result = await withLogSink((phase, message) => log("info", `[${phase}] ${message}`), () => reviewWorkspaceRevision(revision, options, "remote", async (response) => {
204
299
  tokensIn += response.tokensIn;
205
300
  tokensOut += response.tokensOut;
206
- if (response.cost === undefined)
207
- costKnown = false;
208
- else
209
- costUsd = (costUsd ?? 0) + response.cost;
301
+ addResponseCost(costTally, response);
210
302
  await recordAiCost(job.repo, "remote_review", response);
211
303
  }, undefined, (message) => log("info", message), extras));
212
304
  if (signal.aborted)
@@ -227,11 +319,7 @@ export function registerRemoteReviewHandler() {
227
319
  codegraph: { state: result.codegraphState },
228
320
  summary: summaryCounts(findings, blockingMode),
229
321
  findings: findings.map((row) => toJsonFinding(row, blockingMode)),
230
- usage: {
231
- tokensIn,
232
- tokensOut,
233
- costUsd: costKnown ? costUsd : null,
234
- },
322
+ usage: { tokensIn, tokensOut, ...costUsage(costTally) },
235
323
  remote: { jobId: job.id, reviewId, clientVersion: VERSION },
236
324
  });
237
325
  setReviewStatus(reviewId, "done", {
@@ -240,7 +328,8 @@ export function registerRemoteReviewHandler() {
240
328
  duration_ms: durationMs,
241
329
  tokens_in: tokensIn,
242
330
  tokens_out: tokensOut,
243
- cost: costKnown ? costUsd : null,
331
+ billed_to: billed,
332
+ ...costColumns(settle(costTally)),
244
333
  });
245
334
  if (subjectId) {
246
335
  saveSubjectRevision({
@@ -271,11 +360,29 @@ export function registerRemoteReviewHandler() {
271
360
  completed = true;
272
361
  log("info", `remote review finished (${findings.length} findings)`);
273
362
  }
363
+ catch (error) {
364
+ // Only an authentication rejection proves the client's key is bad, so
365
+ // only that becomes `byok_rejected`. A provider outage, a parse error
366
+ // or a local failure must surface as itself rather than telling the
367
+ // user their key was refused. `byok_key_lost` is thrown before this
368
+ // try, so it is not remapped.
369
+ if (byokKey !== undefined &&
370
+ !signal.aborted &&
371
+ isProviderAuthError(error)) {
372
+ throw new RemoteReviewError("byok_rejected", "The AI provider rejected your own key.");
373
+ }
374
+ throw error;
375
+ }
274
376
  finally {
377
+ dropByokKey(job.id);
275
378
  if (!completed) {
379
+ // Reached on failure and on the aborted early return above.
276
380
  if (signal.aborted) {
277
381
  setReviewStatus(reviewId, "aborted");
278
382
  }
383
+ else {
384
+ setReviewStatus(reviewId, "failed");
385
+ }
279
386
  deleteRemoteReviewInput(job.id);
280
387
  closeRemoteSession(job.id);
281
388
  }
@@ -1,3 +1,7 @@
1
1
  /** Queued remote reviews never enter the handler; sync the review row when the
2
- * job is canceled from the dashboard or token deactivation. */
2
+ * job is canceled from the dashboard or token deactivation. The BYOK key and
3
+ * the input row are dropped here too: a queued job canceled before it ever
4
+ * runs must not leave the client's key in memory, and a later retry with the
5
+ * same request id must be free to start over instead of finding a dead row
6
+ * whose key is gone. */
3
7
  export declare function abortQueuedRemoteReviewRow(jobId: string): void;
@@ -1,7 +1,15 @@
1
1
  import { getReviewByJobId, setReviewStatus } from "../store/reviews.js";
2
+ import { deleteRemoteReviewInput } from "../store/remote_review_inputs.js";
3
+ import { dropByokKey } from "../remote/server/byok_keys.js";
2
4
  /** Queued remote reviews never enter the handler; sync the review row when the
3
- * job is canceled from the dashboard or token deactivation. */
5
+ * job is canceled from the dashboard or token deactivation. The BYOK key and
6
+ * the input row are dropped here too: a queued job canceled before it ever
7
+ * runs must not leave the client's key in memory, and a later retry with the
8
+ * same request id must be free to start over instead of finding a dead row
9
+ * whose key is gone. */
4
10
  export function abortQueuedRemoteReviewRow(jobId) {
11
+ dropByokKey(jobId);
12
+ deleteRemoteReviewInput(jobId);
5
13
  const review = getReviewByJobId(jobId);
6
14
  if (!review || review.kind !== "remote")
7
15
  return;
@@ -14,7 +14,7 @@ export declare function splitInline(findings: ParsedFinding[], files: Json[]): {
14
14
  leftover: ParsedFinding[];
15
15
  };
16
16
  export declare function reviewEvent(findingsCount: number): "REQUEST_CHANGES" | "COMMENT";
17
- /** The GitHub review event for a set of stored findings (CORE-41).
17
+ /** The GitHub review event for a set of stored findings.
18
18
  *
19
19
  * `model` keeps the 0.4.13 behavior: any finding requests changes, because the
20
20
  * model's label already decided. `severity` ignores that label and requests
@@ -10,6 +10,7 @@ import { stripSuggestion, suggestionAnchor, withoutSuggestionLine, } from "../pr
10
10
  import { githubPullRequestToRevision } from "../pr/github_revision.js";
11
11
  import { anchorTextFromPatch, buildCarryPromptSection, classifyCarryItems, guideRebuiltSince, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
12
12
  import { runReviewEngine } from "../review/engine.js";
13
+ import { addResponseCost, costColumns, emptyTally, settle, } from "../util/cost.js";
13
14
  import { loadGuides } from "../review/guides.js";
14
15
  import { reviewBlockingFrom, isBlocking, DEFAULT_REVIEW_BLOCKING, } from "../review/blocking.js";
15
16
  import { matchRepeat } from "../pr/rounds.js";
@@ -26,7 +27,7 @@ export function humanCopy(text) {
26
27
  // The semicolon is prose and always goes. The em dash is left alone here:
27
28
  // this function also sanitizes a finding title, and a title posted before
28
29
  // 0.5.0 uses ` — ` as its path/symbol separator, which the legacy heading
29
- // reader still needs (CORE-83 keeps that one allowlisted exception).
30
+ // reader still needs (the copy guard keeps that one allowlisted exception).
30
31
  return outsideCode(redact(text), (prose) => prose.replaceAll(";", "."));
31
32
  }
32
33
  function hasForbiddenCopy(text) {
@@ -66,7 +67,7 @@ export function splitInline(findings, files) {
66
67
  export function reviewEvent(findingsCount) {
67
68
  return findingsCount > 0 ? "REQUEST_CHANGES" : "COMMENT";
68
69
  }
69
- /** The GitHub review event for a set of stored findings (CORE-41).
70
+ /** The GitHub review event for a set of stored findings.
70
71
  *
71
72
  * `model` keeps the 0.4.13 behavior: any finding requests changes, because the
72
73
  * model's label already decided. `severity` ignores that label and requests
@@ -139,6 +140,8 @@ export function reviewOptions(repo, prNumber) {
139
140
  ghConcurrent: 1,
140
141
  aiConcurrent: 1,
141
142
  auth: "gh",
143
+ // With no provider saved the App review stays on OpenRouter, the same
144
+ // fallback the CLI review uses.
142
145
  ai: config.ai && config.ai !== "none" ? config.ai : "openrouter",
143
146
  aiToken: config.token,
144
147
  highModel: config.highModel,
@@ -457,6 +460,16 @@ async function runReviewJobCore(job, log, client, ai) {
457
460
  }
458
461
  const options = reviewOptions(job.repo, job.pr_number);
459
462
  const args = JSON.parse(job.args || "{}");
463
+ // A maintainer asked for this review by label or comment: acknowledge it.
464
+ // Best effort, a missing reaction must never fail the review itself.
465
+ if (args.requestReaction && !existing) {
466
+ try {
467
+ await post(github, args.requestReaction, { content: "eyes" });
468
+ }
469
+ catch (error) {
470
+ log("info", `could not react to the review request: ${String(error)}`);
471
+ }
472
+ }
460
473
  const pr = await github.request(`repos/${job.repo}/pulls/${job.pr_number}`);
461
474
  const headSha = String(pr.head?.sha ?? "");
462
475
  const mergeBase = String(pr.base?.sha ?? "");
@@ -546,7 +559,7 @@ async function runReviewJobCore(job, log, client, ai) {
546
559
  let carryPrevious = null;
547
560
  const extras = {};
548
561
  // A guide rebuilt after the previous review invalidates that review's
549
- // verdicts and its "unchanged" suppression (CORE-42 / F03). Treat the run as
562
+ // verdicts and its "unchanged" suppression. Treat the run as
550
563
  // if it had started fresh: carry-over off, every changed file back in scope,
551
564
  // so a stale finding can never mask a new one.
552
565
  const guideRebuilt = subjectRevision !== undefined &&
@@ -575,7 +588,8 @@ async function runReviewJobCore(job, log, client, ai) {
575
588
  carryItems = classifyCarryItems(carryPrevious, revision, carryPrevious.visiblePaths, guidesBefore.guideBuiltAt);
576
589
  extras.carryPrompt = buildCarryPromptSection(carryItems, revision);
577
590
  }
578
- const response = await runReviewEngine(github, options, undefined, snapshot, ai ?? aiFor(options), (message) => log("info", message), extras);
591
+ const costTally = emptyTally();
592
+ const response = await runReviewEngine(github, options, async (call) => addResponseCost(costTally, call), snapshot, ai ?? aiFor(options), (message) => log("info", message), extras);
579
593
  guideBuiltAt = response.guideBuiltAt;
580
594
  const visiblePaths = new Set(response.visiblePaths);
581
595
  if (carryPrevious) {
@@ -650,7 +664,7 @@ async function runReviewJobCore(job, log, client, ai) {
650
664
  closed_count: closedCount,
651
665
  tokens_in: response.tokensIn,
652
666
  tokens_out: response.tokensOut,
653
- cost: response.cost,
667
+ ...costColumns(settle(costTally)),
654
668
  });
655
669
  saveSubjectRevision({
656
670
  subjectId: subject.id,
@@ -0,0 +1,116 @@
1
+ /** The review policy engine.
2
+ *
3
+ * A policy is an ordered list of rules. The first rule whose conditions all
4
+ * hold decides what happens to a pull request, and when none holds the policy's
5
+ * default action does. This file is pure: no database, no GitHub, no clock, so
6
+ * every branch of it is a table test. `webhook.ts` gathers the facts and acts on
7
+ * the answer. */
8
+ export declare const ASSOCIATIONS: readonly ["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE"];
9
+ export type Association = (typeof ASSOCIATIONS)[number];
10
+ export declare const ACTIONS: readonly ["review", "on-request", "skip"];
11
+ export type PolicyAction = (typeof ACTIONS)[number];
12
+ export type RuleWhen = {
13
+ association?: Association[];
14
+ fork?: boolean;
15
+ draft?: boolean;
16
+ bot?: boolean;
17
+ /** The pull request has at least one of these labels. */
18
+ labels?: string[];
19
+ targetBranch?: string[];
20
+ changedLines?: {
21
+ min?: number;
22
+ max?: number;
23
+ };
24
+ };
25
+ export type PolicyRule = {
26
+ name?: string;
27
+ when: RuleWhen;
28
+ action: PolicyAction;
29
+ /** A fixed skip reason. Only the rules derived from the old per repository
30
+ * switches set it, so their skips read exactly as they always did. */
31
+ reason?: string;
32
+ };
33
+ export type ReviewPolicy = {
34
+ rules: PolicyRule[];
35
+ default: PolicyAction;
36
+ defaultReason?: string;
37
+ requestLabel: string;
38
+ requestCommand: string;
39
+ requesters: Association[];
40
+ /** `head`: a request covers the commit it was made on. `pull-request`: the
41
+ * first request covers every later push. */
42
+ approvalScope: "head" | "pull-request";
43
+ /** Reviews a pull request may get from the webhook. Unset means no limit. */
44
+ maxRounds?: number;
45
+ };
46
+ export type PolicyInput = Partial<ReviewPolicy>;
47
+ /** What is known about a pull request. A fact GitHub did not send is left out,
48
+ * and a rule that asks about it does not match. */
49
+ export type PolicyFacts = {
50
+ association?: string;
51
+ fork?: boolean;
52
+ draft: boolean;
53
+ bot: boolean;
54
+ labels?: string[];
55
+ targetBranch?: string;
56
+ changedLines?: number;
57
+ };
58
+ export type PolicyDecision = {
59
+ action: PolicyAction;
60
+ /** 1 based position of the rule that decided, `null` for the default. */
61
+ rule: number | null;
62
+ reason: string;
63
+ };
64
+ export declare const DEFAULT_POLICY_SETTINGS: {
65
+ readonly requestLabel: "co-maintainer:review";
66
+ readonly requestCommand: "/co-maintainer review";
67
+ readonly requesters: ("OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "FIRST_TIMER" | "NONE")[];
68
+ readonly approvalScope: "head";
69
+ };
70
+ export declare const TEMPLATES: Record<string, PolicyInput>;
71
+ export declare const TEMPLATE_NAMES: string[];
72
+ export declare function withDefaults(input: PolicyInput): ReviewPolicy;
73
+ /** The policy the three old per repository switches amount to, so a repository
74
+ * nobody has given a policy keeps behaving exactly as before. Switched off
75
+ * means everything is skipped, and the drafts and bots switches only matter
76
+ * while it is on. */
77
+ export declare function legacyPolicy(settings: {
78
+ auto_review: number;
79
+ skip_drafts: number;
80
+ skip_bots: number;
81
+ }): ReviewPolicy;
82
+ export declare function evaluatePolicy(policy: ReviewPolicy, facts: PolicyFacts): PolicyDecision;
83
+ /** Whether a pull request that a policy leaves waiting for a request may be
84
+ * reviewed without a new one. Only the `pull-request` scope carries an earlier
85
+ * request forward, and only once something was actually reviewed or queued. */
86
+ export declare function carriesEarlierRequest(policy: ReviewPolicy, hasEarlierReview: boolean): boolean;
87
+ /** Whether `body` starts with the request command as a whole word. */
88
+ export declare function isRequestCommand(body: string, command: string): boolean;
89
+ type Check<T> = {
90
+ ok: true;
91
+ value: T;
92
+ } | {
93
+ ok: false;
94
+ problem: string;
95
+ };
96
+ /** Turns stored or user supplied policy data into a policy, or says what is
97
+ * wrong with it. A string names a template, an object is a full policy where
98
+ * every setting but the rules can be left out. */
99
+ export declare function parsePolicy(value: unknown): Check<ReviewPolicy>;
100
+ /** What the dashboard shows next to each template, one plain sentence each. */
101
+ export declare const TEMPLATE_INFO: {
102
+ name: string;
103
+ label: string;
104
+ description: string;
105
+ }[];
106
+ /** The policy as short sentences a maintainer can read before saving it. */
107
+ export declare function describePolicy(policy: ReviewPolicy): string[];
108
+ /** What to store for a policy: the template's name when the policy is exactly
109
+ * that template with the default settings, the whole policy otherwise. */
110
+ export declare function storedPolicyValue(policy: ReviewPolicy): string | ReviewPolicy;
111
+ /** What a form or API caller sent, as the value to store. `null` clears the
112
+ * choice, and `"legacy"` (the simple switches) is only for one repository. */
113
+ export declare function policyToStore(value: unknown, options: {
114
+ allowLegacy: boolean;
115
+ }): Check<string | null>;
116
+ export {};