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
@@ -5,7 +5,10 @@ import { deactivateRepo, deactivateReposForInstallation, getRepo, setInstallatio
5
5
  import { findFindingByPostedComment } from "../store/findings.js";
6
6
  import { createReplyRequestAndJob, findReplyByPostedComment, } from "../store/replies.js";
7
7
  import { latestPostedReview } from "../store/reviews.js";
8
+ import { getQueuedJob } from "../store/jobs.js";
8
9
  import { markInstallationRemoved, markInstallationSuspended, upsertInstallation, } from "../store/installations.js";
10
+ import { readConfig } from "../config.js";
11
+ import { carriesEarlierRequest, evaluatePolicy, isRequestCommand, legacyPolicy, parsePolicy, } from "./review_policy.js";
9
12
  export const REVIEW_DEBOUNCE_MS = 20_000;
10
13
  const PR_ACTIONS = new Set([
11
14
  "opened",
@@ -13,6 +16,34 @@ const PR_ACTIONS = new Set([
13
16
  "synchronize",
14
17
  "ready_for_review",
15
18
  ]);
19
+ /** The policy that governs a repository. A repository pinned to `"legacy"`, and
20
+ * one with nothing stored and no server default, follow the three old
21
+ * switches. A stored value that no longer parses does too, so a bad edit
22
+ * degrades to today's behavior rather than to a silent stop. */
23
+ export function policySource(row) {
24
+ const switches = { policy: legacyPolicy(row), legacy: true };
25
+ let value;
26
+ if (row.review_policy_json !== null) {
27
+ try {
28
+ value = JSON.parse(row.review_policy_json);
29
+ }
30
+ catch {
31
+ return switches;
32
+ }
33
+ if (value === "legacy")
34
+ return switches;
35
+ }
36
+ else {
37
+ value = readConfig().reviewPolicy;
38
+ if (value === undefined)
39
+ return switches;
40
+ }
41
+ const parsed = parsePolicy(value);
42
+ return parsed.ok ? { policy: parsed.value, legacy: false } : switches;
43
+ }
44
+ export function policyForRepo(row) {
45
+ return policySource(row).policy;
46
+ }
16
47
  function asRecord(value) {
17
48
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
18
49
  return undefined;
@@ -49,6 +80,25 @@ function isBot(user) {
49
80
  const login = asString(user.login) ?? "";
50
81
  return login.endsWith("[bot]");
51
82
  }
83
+ function labelNames(value) {
84
+ if (!Array.isArray(value))
85
+ return undefined;
86
+ return value
87
+ .map((label) => asString(asRecord(label)?.name))
88
+ .filter((name) => Boolean(name));
89
+ }
90
+ /** The policy facts a pull request object carries. `fork` is true when the head
91
+ * lives in another repository, or in none because the fork was deleted. */
92
+ function pullRequestFacts(pr, repo) {
93
+ const head = asRecord(pr?.head);
94
+ const headRepo = asString(asRecord(head?.repo)?.full_name);
95
+ return {
96
+ association: asString(pr?.author_association),
97
+ fork: head ? headRepo?.toLowerCase() !== repo.toLowerCase() : undefined,
98
+ labels: labelNames(pr?.labels),
99
+ targetBranch: asString(asRecord(pr?.base)?.ref),
100
+ };
101
+ }
52
102
  export function maybeEnqueueReview(input) {
53
103
  const row = getRepo(input.repo);
54
104
  if (!row || row.active !== 1) {
@@ -59,29 +109,31 @@ export function maybeEnqueueReview(input) {
59
109
  prNumber: input.prNumber,
60
110
  };
61
111
  }
62
- if (row.auto_review !== 1) {
63
- return {
64
- outcome: "skipped",
65
- reason: "auto-review-off",
66
- repo: input.repo,
67
- prNumber: input.prNumber,
68
- };
69
- }
70
- if (row.skip_drafts === 1 && input.draft) {
71
- return {
72
- outcome: "skipped",
73
- reason: "draft",
74
- repo: input.repo,
75
- prNumber: input.prNumber,
76
- };
77
- }
78
- if (row.skip_bots === 1 && input.bot) {
79
- return {
80
- outcome: "skipped",
81
- reason: "bot-author",
82
- repo: input.repo,
83
- prNumber: input.prNumber,
84
- };
112
+ const policy = policyForRepo(row);
113
+ const decision = evaluatePolicy(policy, {
114
+ association: input.association,
115
+ fork: input.fork,
116
+ draft: input.draft,
117
+ bot: input.bot,
118
+ labels: input.labels,
119
+ targetBranch: input.targetBranch,
120
+ changedLines: input.changedFiles >= 0 ? input.additions + input.deletions : undefined,
121
+ });
122
+ const skipped = (reason) => ({
123
+ outcome: "skipped",
124
+ reason,
125
+ repo: input.repo,
126
+ prNumber: input.prNumber,
127
+ });
128
+ const earlier = latestPostedReview(input.repo, input.prNumber);
129
+ if (decision.action === "skip")
130
+ return skipped(decision.reason);
131
+ if (decision.action === "on-request" && !input.request) {
132
+ const asked = earlier !== undefined ||
133
+ getQueuedJob(input.repo, input.prNumber) !== undefined;
134
+ if (!carriesEarlierRequest(policy, asked)) {
135
+ return skipped(decision.reason);
136
+ }
85
137
  }
86
138
  if (!row.knowledge_built_at) {
87
139
  return {
@@ -110,17 +162,16 @@ export function maybeEnqueueReview(input) {
110
162
  }
111
163
  if ((input.trigger === "review" || input.trigger === "review-comment") &&
112
164
  input.headSha) {
113
- const last = latestPostedReview(input.repo, input.prNumber);
114
- if (last && last.head_sha === input.headSha) {
115
- return {
116
- outcome: "skipped",
117
- reason: "nothing-new-since-last-round",
118
- repo: input.repo,
119
- prNumber: input.prNumber,
120
- };
165
+ if (earlier && earlier.head_sha === input.headSha) {
166
+ return skipped("nothing-new-since-last-round");
121
167
  }
122
168
  }
123
- const last = latestPostedReview(input.repo, input.prNumber);
169
+ if (policy.maxRounds !== undefined &&
170
+ earlier &&
171
+ earlier.round >= policy.maxRounds) {
172
+ return skipped(`Round limit reached: ${earlier.round} of ${policy.maxRounds} reviews.`);
173
+ }
174
+ const last = earlier;
124
175
  const incremental = row.review_scope === "incremental" && Boolean(last?.head_sha);
125
176
  const { id } = enqueue({
126
177
  type: "review",
@@ -133,6 +184,7 @@ export function maybeEnqueueReview(input) {
133
184
  scope: incremental ? "incremental" : "whole-pr",
134
185
  sinceCommit: incremental ? last.head_sha : undefined,
135
186
  round: last ? last.round + 1 : 1,
187
+ requestReaction: input.request?.reaction,
136
188
  },
137
189
  });
138
190
  return {
@@ -208,9 +260,23 @@ function pullRequestEvent(payload, deliveryId, maxDiffLines) {
208
260
  const pr = asRecord(payload.pull_request);
209
261
  const repo = asString(asRecord(payload.repository)?.full_name);
210
262
  const prNumber = asNumber(pr?.number) ?? asNumber(payload.number);
211
- if (!PR_ACTIONS.has(action) || !repo || prNumber === undefined) {
263
+ const labeled = action === "labeled";
264
+ if ((!PR_ACTIONS.has(action) && !labeled) ||
265
+ !repo ||
266
+ prNumber === undefined) {
212
267
  return { outcome: "ignored", repo, prNumber };
213
268
  }
269
+ // A label only means something when it is the repository's request label.
270
+ // Adding it already takes triage rights, so no further check is made.
271
+ if (labeled) {
272
+ const row = getRepo(repo);
273
+ const added = asString(asRecord(payload.label)?.name);
274
+ if (!row ||
275
+ !added ||
276
+ added.toLowerCase() !== policyForRepo(row).requestLabel.toLowerCase()) {
277
+ return { outcome: "ignored", repo, prNumber };
278
+ }
279
+ }
214
280
  const user = asRecord(pr?.user);
215
281
  const installationId = asNumber(asRecord(payload.installation)?.id);
216
282
  if (installationId !== undefined)
@@ -224,9 +290,13 @@ function pullRequestEvent(payload, deliveryId, maxDiffLines) {
224
290
  deletions: asNumber(pr?.deletions) ?? 0,
225
291
  changedFiles: asNumber(pr?.changed_files) ?? -1,
226
292
  deliveryId,
227
- trigger: action,
293
+ trigger: labeled ? "request-label" : action,
228
294
  headSha: asString(asRecord(pr?.head)?.sha),
229
295
  maxDiffLines,
296
+ ...pullRequestFacts(pr, repo),
297
+ request: labeled
298
+ ? { reaction: `repos/${repo}/issues/${prNumber}/reactions` }
299
+ : undefined,
230
300
  });
231
301
  }
232
302
  function reviewWakeEvent(event, payload, deliveryId, maxDiffLines) {
@@ -266,6 +336,7 @@ function reviewWakeEvent(event, payload, deliveryId, maxDiffLines) {
266
336
  trigger: event === "pull_request_review" ? "review" : "review-comment",
267
337
  headSha: asString(asRecord(pr?.head)?.sha),
268
338
  maxDiffLines,
339
+ ...pullRequestFacts(pr, repo),
269
340
  });
270
341
  }
271
342
  function replyTarget(repo, prNumber, parentCommentId) {
@@ -370,6 +441,42 @@ function issueCommentEvent(payload, deliveryId) {
370
441
  prNumber,
371
442
  };
372
443
  }
444
+ const row = getRepo(repo);
445
+ if (row?.active === 1) {
446
+ const policy = policyForRepo(row);
447
+ if (isRequestCommand(body, policy.requestCommand)) {
448
+ const commenter = asString(comment?.author_association) ?? "NONE";
449
+ if (!policy.requesters.includes(commenter)) {
450
+ return {
451
+ outcome: "skipped",
452
+ reason: `Not allowed to request a review: ${commenter} is not one of ${policy.requesters.join(", ")}.`,
453
+ repo,
454
+ prNumber,
455
+ };
456
+ }
457
+ const installationId = asNumber(asRecord(payload.installation)?.id);
458
+ if (installationId !== undefined)
459
+ setInstallationId(repo, installationId);
460
+ // An issue payload carries no head, base or diff size, so rules about
461
+ // the fork, the target branch and the size do not match here.
462
+ return maybeEnqueueReview({
463
+ repo,
464
+ prNumber,
465
+ draft: asBool(issue?.draft),
466
+ bot: isBot(asRecord(issue?.user)),
467
+ additions: 0,
468
+ deletions: 0,
469
+ changedFiles: -1,
470
+ deliveryId,
471
+ trigger: "request-comment",
472
+ association: asString(issue?.author_association),
473
+ labels: labelNames(issue?.labels),
474
+ request: {
475
+ reaction: `repos/${repo}/issues/comments/${String(sourceId)}/reactions`,
476
+ },
477
+ });
478
+ }
479
+ }
373
480
  if (!hasAppMention(body, appSlug(payload))) {
374
481
  return {
375
482
  outcome: "ignored",
@@ -1,7 +1,12 @@
1
1
  import { Database } from "./sqlite.ts";
2
+ import { type BackupPaths } from "./backup.ts";
2
3
  export declare function appDbDir(): string;
3
4
  /** `CM_APP_DB` overrides the path — tests point it at a temp file. */
4
5
  export declare function appDbPath(): string;
6
+ /** Where the backup taken before a migration lives, and what it covers. */
7
+ export declare function backupPaths(): BackupPaths;
8
+ /** The pid holding the database lock, or `undefined` when nothing live does. */
9
+ export declare function liveLockPid(): Promise<number | undefined>;
5
10
  export declare function releaseLock(): Promise<void>;
6
11
  export declare function isAppDbOpen(): boolean;
7
12
  /** Opens (creating and migrating if needed) the single shared `app.db`
@@ -1,6 +1,10 @@
1
+ import { dirname } from "node:path";
1
2
  import { Database } from "./sqlite.js";
2
- import { getCacheDir } from "../config.js";
3
- import { CliError, EXIT_USAGE } from "../cli/error.js";
3
+ import { cacheDbPath, configPath, getCacheDir } from "../config.js";
4
+ import { VERSION } from "../version.js";
5
+ import { log } from "../util/log.js";
6
+ import { takeBackup } from "./backup.js";
7
+ import { CliError, EXIT_RUNTIME, EXIT_USAGE } from "../cli/error.js";
4
8
  import { migrations } from "./migrations.js";
5
9
  import { getEnv, isNotFound, isProcessAlive, mkdir, readTextFile, remove, writeTextFile, } from "../util/runtime.js";
6
10
  export function appDbDir() {
@@ -13,23 +17,53 @@ export function appDbPath() {
13
17
  function lockPath() {
14
18
  return `${appDbPath()}.lock`;
15
19
  }
16
- /** A PID file next to the database is the lock: a live PID inside it
17
- * refuses a second start, a dead one is taken over silently. */
18
- async function acquireLock() {
19
- const path = lockPath();
20
- let existingPid;
20
+ /** Where the backup taken before a migration lives, and what it covers. */
21
+ export function backupPaths() {
22
+ return {
23
+ appDb: appDbPath(),
24
+ cacheDb: cacheDbPath(),
25
+ config: configPath(),
26
+ version: versionPath(),
27
+ root: `${dirname(appDbPath())}/backups`,
28
+ };
29
+ }
30
+ /** The version that last opened this database, written after every open so
31
+ * the next upgrade can say which version to go back to. */
32
+ function versionPath() {
33
+ return `${appDbPath()}.version`;
34
+ }
35
+ async function lastVersion() {
36
+ try {
37
+ return (await readTextFile(versionPath())).trim() || null;
38
+ }
39
+ catch (error) {
40
+ if (isNotFound(error))
41
+ return null;
42
+ throw error;
43
+ }
44
+ }
45
+ /** The pid holding the database lock, or `undefined` when nothing live does. */
46
+ export async function liveLockPid() {
47
+ let pid;
21
48
  try {
22
- existingPid = Number((await readTextFile(path)).trim());
49
+ pid = Number((await readTextFile(lockPath())).trim());
23
50
  }
24
51
  catch (error) {
25
52
  if (!isNotFound(error))
26
53
  throw error;
27
54
  }
28
- if (existingPid !== undefined &&
29
- Number.isInteger(existingPid) &&
30
- isProcessAlive(existingPid)) {
55
+ return pid !== undefined && Number.isInteger(pid) && isProcessAlive(pid)
56
+ ? pid
57
+ : undefined;
58
+ }
59
+ /** A PID file next to the database is the lock: a live PID inside it
60
+ * refuses a second start, a dead one is taken over silently. */
61
+ async function acquireLock() {
62
+ const path = lockPath();
63
+ const existingPid = await liveLockPid();
64
+ if (existingPid !== undefined) {
31
65
  // Says what is holding the database, what to do about it, and offers the
32
- // path that does not need another process. CORE-12.
66
+ // path that does not need another process.
33
67
  throw new CliError("serve_already_running", `A co-maintainer serve process (pid ${existingPid}) is using this data directory.`, "Stop it, or run sync from its dashboard.", EXIT_USAGE);
34
68
  }
35
69
  await writeTextFile(path, String(process.pid));
@@ -45,7 +79,8 @@ function migrate(database) {
45
79
  ?.user_version ?? 0;
46
80
  if (current > migrations.length) {
47
81
  throw new Error(`app.db is at migration ${current}, but this build only knows ${migrations.length}. ` +
48
- `Installing an older release after a newer one already migrated the database is unsupported.`);
82
+ `Installing an older release after a newer one already migrated the database is unsupported. ` +
83
+ `Run co-maintainer rollback with the newer version installed, or restore the backup in ${backupPaths().root}/previous.`);
49
84
  }
50
85
  for (let index = current; index < migrations.length; index++) {
51
86
  database.exec("BEGIN");
@@ -65,6 +100,23 @@ let db;
65
100
  export function isAppDbOpen() {
66
101
  return db !== undefined;
67
102
  }
103
+ function userVersion(database) {
104
+ return (database.prepare("PRAGMA user_version").get()
105
+ ?.user_version ?? 0);
106
+ }
107
+ /** A database with something to migrate is backed up first. A fresh one has
108
+ * nothing to lose, and without a complete backup the migration does not run. */
109
+ async function backupBeforeMigration(database) {
110
+ const current = userVersion(database);
111
+ if (current === 0 || current >= migrations.length)
112
+ return;
113
+ try {
114
+ await takeBackup(backupPaths(), { from: await lastVersion(), to: VERSION });
115
+ }
116
+ catch (error) {
117
+ throw new CliError("backup_failed", `Could not back up the data before upgrading it: ${error instanceof Error ? error.message : String(error)}`, "Free some disk space or check the permissions of the data directory, then try again.", EXIT_RUNTIME);
118
+ }
119
+ }
68
120
  /** Opens (creating and migrating if needed) the single shared `app.db`
69
121
  * connection every store module reuses. `serve` opens it for the process.
70
122
  * `init` and `remake` open it for the run when it is not already open. */
@@ -76,7 +128,11 @@ export async function openAppDb() {
76
128
  const database = new Database(appDbPath());
77
129
  database.exec("PRAGMA journal_mode = WAL");
78
130
  database.exec("PRAGMA foreign_keys = ON");
131
+ await backupBeforeMigration(database);
79
132
  migrate(database);
133
+ // The marker only names the version to go back to. Failing to write it must
134
+ // not stop the server, but it should not vanish silently either.
135
+ await writeTextFile(versionPath(), VERSION).catch((error) => log("backup", `could not record the running version: ${error instanceof Error ? error.message : String(error)}`));
80
136
  db = database;
81
137
  return database;
82
138
  }
@@ -0,0 +1,31 @@
1
+ export type BackupPaths = {
2
+ appDb: string;
3
+ cacheDb: string;
4
+ config: string;
5
+ /** The file naming the version that last opened `app.db`. */
6
+ version: string;
7
+ /** Holds `previous/` and, after a rollback, `rolled-back/`. */
8
+ root: string;
9
+ };
10
+ export type BackupManifest = {
11
+ /** The version that ran before the upgrade, `null` when it was not recorded. */
12
+ fromVersion: string | null;
13
+ toVersion: string;
14
+ createdAt: string;
15
+ files: string[];
16
+ };
17
+ export declare const rolledBackDir: (paths: BackupPaths) => string;
18
+ /** Copies the files into `previous/`, replacing the last backup only once the
19
+ * new one is complete, so a failure here leaves the old backup untouched. */
20
+ export declare function takeBackup(paths: BackupPaths, versions: {
21
+ from: string | null;
22
+ to: string;
23
+ }): Promise<void>;
24
+ /** The previous backup, or `undefined` when there is none or it is incomplete. */
25
+ export declare function readBackup(paths: BackupPaths): Promise<BackupManifest | undefined>;
26
+ /** Throws the usage error a rollback refuses with, or returns the manifest. */
27
+ export declare function checkRollback(paths: BackupPaths, runningVersion: string): Promise<BackupManifest>;
28
+ /** Moves the current files to `rolled-back/` (so a wrong rollback can be
29
+ * undone by hand), then copies the backup into place. The WAL and shared
30
+ * memory files go too: a stale one next to a restored database corrupts it. */
31
+ export declare function restoreBackup(paths: BackupPaths): Promise<void>;
@@ -0,0 +1,158 @@
1
+ /** The safety net for an upgrade that migrates `app.db`.
2
+ *
3
+ * A release refuses an `app.db` newer than it knows, so going back after a
4
+ * migration cannot mean opening the new database with the old code. Instead
5
+ * the databases and config are copied just before the migration runs, and
6
+ * `co-maintainer rollback` puts that copy back. One slot, the previous
7
+ * version only. Data written after the upgrade is lost by design. */
8
+ import { Database } from "./sqlite.js";
9
+ import { CliError, EXIT_USAGE } from "../cli/error.js";
10
+ import { isNotFound, mkdir, readFile, readTextFile, remove, rename, stat, writeFile, writeTextFile, } from "../util/runtime.js";
11
+ const slot = (paths) => `${paths.root}/previous`;
12
+ export const rolledBackDir = (paths) => `${paths.root}/rolled-back`;
13
+ async function exists(path) {
14
+ try {
15
+ await stat(path);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ if (isNotFound(error))
20
+ return false;
21
+ throw error;
22
+ }
23
+ }
24
+ /** `VACUUM INTO` writes a consistent copy even while the database is open. */
25
+ function copyDatabase(from, to) {
26
+ const source = new Database(from);
27
+ try {
28
+ source.exec(`VACUUM INTO '${to.replaceAll("'", "''")}'`);
29
+ }
30
+ finally {
31
+ source.close();
32
+ }
33
+ }
34
+ /** Copies the files into `previous/`, replacing the last backup only once the
35
+ * new one is complete, so a failure here leaves the old backup untouched. */
36
+ export async function takeBackup(paths, versions) {
37
+ const temp = `${slot(paths)}.tmp`;
38
+ await remove(temp, { recursive: true }).catch(() => { });
39
+ await mkdir(temp, { recursive: true });
40
+ const files = ["app.db"];
41
+ copyDatabase(paths.appDb, `${temp}/app.db`);
42
+ if (await exists(paths.cacheDb)) {
43
+ copyDatabase(paths.cacheDb, `${temp}/cache.db`);
44
+ files.push("cache.db");
45
+ }
46
+ if (await exists(paths.config)) {
47
+ await writeFile(`${temp}/config.json`, await readFile(paths.config));
48
+ files.push("config.json");
49
+ }
50
+ const manifest = {
51
+ fromVersion: versions.from,
52
+ toVersion: versions.to,
53
+ createdAt: new Date().toISOString(),
54
+ files,
55
+ };
56
+ // The manifest goes last: a directory without one is not a backup.
57
+ await writeTextFile(`${temp}/manifest.json`, JSON.stringify(manifest, null, 2));
58
+ // Swap through `.old` so the last backup survives a failed promotion.
59
+ const old = `${slot(paths)}.old`;
60
+ await remove(old, { recursive: true }).catch(() => { });
61
+ const hadPrevious = await exists(slot(paths));
62
+ if (hadPrevious)
63
+ await rename(slot(paths), old);
64
+ try {
65
+ await rename(temp, slot(paths));
66
+ }
67
+ catch (error) {
68
+ if (hadPrevious)
69
+ await rename(old, slot(paths)).catch(() => { });
70
+ throw error;
71
+ }
72
+ await remove(old, { recursive: true }).catch(() => { });
73
+ }
74
+ const KNOWN_FILES = ["app.db", "cache.db", "config.json"];
75
+ /** A manifest that names the database and only files this module writes. */
76
+ function isManifest(value) {
77
+ const manifest = value;
78
+ return (typeof manifest === "object" &&
79
+ manifest !== null &&
80
+ (manifest.fromVersion === null ||
81
+ typeof manifest.fromVersion === "string") &&
82
+ typeof manifest.toVersion === "string" &&
83
+ typeof manifest.createdAt === "string" &&
84
+ Array.isArray(manifest.files) &&
85
+ manifest.files.includes("app.db") &&
86
+ manifest.files.every((file) => KNOWN_FILES.includes(file)));
87
+ }
88
+ /** The previous backup, or `undefined` when there is none or it is incomplete. */
89
+ export async function readBackup(paths) {
90
+ let manifest;
91
+ try {
92
+ manifest = JSON.parse(await readTextFile(`${slot(paths)}/manifest.json`));
93
+ }
94
+ catch (error) {
95
+ if (isNotFound(error) || error instanceof SyntaxError)
96
+ return undefined;
97
+ throw error;
98
+ }
99
+ if (!isManifest(manifest))
100
+ return undefined;
101
+ for (const file of manifest.files) {
102
+ if (!(await exists(`${slot(paths)}/${file}`)))
103
+ return undefined;
104
+ }
105
+ return manifest;
106
+ }
107
+ /** Throws the usage error a rollback refuses with, or returns the manifest. */
108
+ export async function checkRollback(paths, runningVersion) {
109
+ const manifest = await readBackup(paths);
110
+ if (!manifest) {
111
+ throw new CliError("no_backup", "There is no backup to roll back to.", "A backup is taken when an upgrade migrates the database.", EXIT_USAGE);
112
+ }
113
+ if (manifest.toVersion !== runningVersion) {
114
+ throw new CliError("backup_version_mismatch", `The backup was taken when upgrading to ${manifest.toVersion}, but this is ${runningVersion}.`, `Run rollback with co-maintainer ${manifest.toVersion} installed.`, EXIT_USAGE);
115
+ }
116
+ return manifest;
117
+ }
118
+ /** Moves the current files to `rolled-back/` (so a wrong rollback can be
119
+ * undone by hand), then copies the backup into place. The WAL and shared
120
+ * memory files go too: a stale one next to a restored database corrupts it. */
121
+ export async function restoreBackup(paths) {
122
+ const manifest = await readBackup(paths);
123
+ if (!manifest)
124
+ throw new Error("backup vanished before it was restored");
125
+ const aside = rolledBackDir(paths);
126
+ await remove(aside, { recursive: true }).catch(() => { });
127
+ await mkdir(aside, { recursive: true });
128
+ const targets = [
129
+ [paths.appDb, "app.db"],
130
+ [`${paths.appDb}-wal`, "app.db-wal"],
131
+ [`${paths.appDb}-shm`, "app.db-shm"],
132
+ [paths.cacheDb, "cache.db"],
133
+ [`${paths.cacheDb}-wal`, "cache.db-wal"],
134
+ [`${paths.cacheDb}-shm`, "cache.db-shm"],
135
+ [paths.config, "config.json"],
136
+ ];
137
+ for (const [path, name] of targets) {
138
+ if (await exists(path))
139
+ await rename(path, `${aside}/${name}`);
140
+ }
141
+ const destinations = {
142
+ "app.db": paths.appDb,
143
+ "cache.db": paths.cacheDb,
144
+ "config.json": paths.config,
145
+ };
146
+ for (const file of manifest.files) {
147
+ await writeFile(destinations[file], await readFile(`${slot(paths)}/${file}`));
148
+ }
149
+ // The version marker must match the data again, or the next upgrade would
150
+ // record the wrong version to go back to.
151
+ if (manifest.fromVersion) {
152
+ await writeTextFile(paths.version, manifest.fromVersion);
153
+ }
154
+ else {
155
+ await remove(paths.version).catch(() => { });
156
+ }
157
+ await remove(slot(paths), { recursive: true });
158
+ }
@@ -1,4 +1,4 @@
1
- /** Numbered, forward-only migrations — PLAN.md Section 5. Each entry is a
1
+ /** Numbered, forward-only migrations. Each entry is a
2
2
  * list of individual statements (not one multi-statement blob) run in a
3
3
  * transaction; `PRAGMA user_version` tracks how many have applied. Never
4
4
  * edit an already-released migration — add a new one instead. */
@@ -1,4 +1,4 @@
1
- /** Numbered, forward-only migrations — PLAN.md Section 5. Each entry is a
1
+ /** Numbered, forward-only migrations. Each entry is a
2
2
  * list of individual statements (not one multi-statement blob) run in a
3
3
  * transaction; `PRAGMA user_version` tracks how many have applied. Never
4
4
  * edit an already-released migration — add a new one instead. */
@@ -255,4 +255,24 @@ export const migrations = [
255
255
  `CREATE UNIQUE INDEX idx_remote_inputs_request
256
256
  ON remote_review_inputs(token_id, request_id)`,
257
257
  ],
258
+ // 9 — a missing cost is unknown, not zero. Older rows are filled in place
259
+ [
260
+ `ALTER TABLE reviews ADD COLUMN cost_status TEXT`,
261
+ `ALTER TABLE reviews ADD COLUMN cost_note TEXT`,
262
+ `ALTER TABLE reviews ADD COLUMN billed_to TEXT`,
263
+ `UPDATE reviews SET cost_status = 'known' WHERE cost IS NOT NULL`,
264
+ `UPDATE reviews SET cost_status = 'unknown', cost_note = 'recorded_before_0_5_1'
265
+ WHERE cost IS NULL`,
266
+ `UPDATE reviews SET billed_to = 'server'`,
267
+ ],
268
+ // 10 — BYOK: persist only whether a review used a client key, never the key
269
+ [`ALTER TABLE remote_review_inputs ADD COLUMN billed_to TEXT`],
270
+ // 11 — a per repository review policy. Existing repositories are
271
+ // pinned to the policy their three old switches amount to, so they keep
272
+ // behaving exactly as before and the switches keep working. A new repository
273
+ // starts empty and takes the server default.
274
+ [
275
+ `ALTER TABLE repos ADD COLUMN review_policy_json TEXT`,
276
+ `UPDATE repos SET review_policy_json = '"legacy"'`,
277
+ ],
258
278
  ];
@@ -5,6 +5,9 @@ export declare function insertRemoteReviewInput(input: {
5
5
  capabilitiesJson: string;
6
6
  requestId: string;
7
7
  tokenId: string;
8
+ /** `'byok'` when the client submitted its own key, else `'server'`. The key
9
+ * itself is never written here. */
10
+ billedTo: "server" | "byok";
8
11
  }): void;
9
12
  export declare function findRemoteReviewInputByRequest(tokenId: string, requestId: string): RemoteReviewInputRow | undefined;
10
13
  export declare function getRemoteReviewInput(jobId: string): RemoteReviewInputRow | undefined;
@@ -3,9 +3,9 @@ import { nowIso } from "../util/time.js";
3
3
  export function insertRemoteReviewInput(input) {
4
4
  getAppDb()
5
5
  .prepare(`INSERT INTO remote_review_inputs
6
- (job_id, revision_json, capabilities_json, request_id, token_id, created_at)
7
- VALUES (?, ?, ?, ?, ?, ?)`)
8
- .run(input.jobId, input.revisionJson, input.capabilitiesJson, input.requestId, input.tokenId, nowIso());
6
+ (job_id, revision_json, capabilities_json, request_id, token_id, created_at, billed_to)
7
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
8
+ .run(input.jobId, input.revisionJson, input.capabilitiesJson, input.requestId, input.tokenId, nowIso(), input.billedTo);
9
9
  }
10
10
  export function findRemoteReviewInputByRequest(tokenId, requestId) {
11
11
  return getAppDb()
@@ -6,5 +6,5 @@ export declare function findRemoteTokenByName(name: string): RemoteTokenRow | un
6
6
  export declare function listRemoteTokens(): RemoteTokenRow[];
7
7
  export declare function setRemoteTokenActive(id: string, active: boolean): void;
8
8
  export declare function deleteRemoteToken(id: string): void;
9
- /** At most one write per minute per token (plan §14.2). */
9
+ /** At most one write per minute per token. */
10
10
  export declare function touchRemoteToken(id: string): void;
@@ -34,7 +34,7 @@ export function setRemoteTokenActive(id, active) {
34
34
  export function deleteRemoteToken(id) {
35
35
  getAppDb().prepare(`DELETE FROM remote_tokens WHERE id = ?`).run(id);
36
36
  }
37
- /** At most one write per minute per token (plan §14.2). */
37
+ /** At most one write per minute per token. */
38
38
  export function touchRemoteToken(id) {
39
39
  const now = nowIso();
40
40
  const minuteAgo = new Date(Date.now() - 60_000).toISOString();