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.
- package/LICENSE +21 -21
- package/README.md +5 -3
- package/dist/package.json +1 -1
- package/dist/src/ai/anthropic.d.ts +55 -0
- package/dist/src/ai/anthropic.js +262 -0
- package/dist/src/ai/batch.d.ts +2 -2
- package/dist/src/ai/batch.js +1 -1
- package/dist/src/ai/estimate.d.ts +4 -0
- package/dist/src/ai/estimate.js +17 -2
- package/dist/src/ai/mermaid_loop.js +7 -4
- package/dist/src/ai/openai.d.ts +45 -0
- package/dist/src/ai/openai.js +206 -0
- package/dist/src/ai/opencode.d.ts +10 -0
- package/dist/src/ai/opencode.js +76 -0
- package/dist/src/ai/openrouter.d.ts +23 -7
- package/dist/src/ai/openrouter.js +39 -23
- package/dist/src/ai/pricing.js +1 -1
- package/dist/src/ai/provider.d.ts +9 -0
- package/dist/src/ai/provider.js +42 -3
- package/dist/src/ai/verify.d.ts +9 -1
- package/dist/src/ai/verify.js +81 -3
- package/dist/src/cli/args.d.ts +1 -2
- package/dist/src/cli/args.js +38 -26
- package/dist/src/cli/commands/config.d.ts +1 -1
- package/dist/src/cli/commands/config.js +10 -4
- package/dist/src/cli/commands/probe.js +3 -11
- package/dist/src/cli/commands/registry.d.ts +1 -9
- package/dist/src/cli/commands/registry.js +75 -10
- package/dist/src/cli/commands/review.js +18 -13
- package/dist/src/cli/commands/rollback.d.ts +1 -0
- package/dist/src/cli/commands/rollback.js +42 -0
- package/dist/src/cli/commands/serve.js +2 -3
- package/dist/src/cli/commands/set.js +71 -7
- package/dist/src/cli/commands/view.js +10 -9
- package/dist/src/cli/error.d.ts +4 -4
- package/dist/src/cli/error.js +4 -4
- package/dist/src/cli/main.d.ts +4 -4
- package/dist/src/cli/main.js +13 -8
- package/dist/src/cli/prompt.js +1 -1
- package/dist/src/cli/review_args.d.ts +9 -3
- package/dist/src/cli/review_args.js +28 -5
- package/dist/src/cli/review_github.d.ts +31 -0
- package/dist/src/cli/review_github.js +128 -0
- package/dist/src/cli/review_result.d.ts +27 -16
- package/dist/src/cli/review_result.js +7 -7
- package/dist/src/config.d.ts +28 -6
- package/dist/src/config.js +15 -1
- package/dist/src/git/default_branch.d.ts +1 -1
- package/dist/src/git/default_branch.js +1 -1
- package/dist/src/github/app.js +2 -2
- package/dist/src/github/app_manifest.d.ts +1 -7
- package/dist/src/github/app_manifest.js +2 -7
- package/dist/src/github/client.d.ts +4 -1
- package/dist/src/github/client.js +14 -2
- package/dist/src/github/gh.js +10 -7
- package/dist/src/github/pat.js +4 -4
- package/dist/src/github/permissions.d.ts +36 -0
- package/dist/src/github/permissions.js +334 -0
- package/dist/src/knowledge/facts.js +1 -1
- package/dist/src/knowledge/guide.d.ts +3 -3
- package/dist/src/knowledge/guide.js +5 -5
- package/dist/src/knowledge/probe.js +1 -2
- package/dist/src/knowledge/sections.d.ts +3 -3
- package/dist/src/knowledge/sections.js +3 -3
- package/dist/src/knowledge/skill.d.ts +1 -1
- package/dist/src/knowledge/skill.js +4 -4
- package/dist/src/knowledge/synthesis.d.ts +1 -1
- package/dist/src/knowledge/synthesis.js +5 -5
- package/dist/src/knowledge/types.d.ts +1 -1
- package/dist/src/local/codegraph_exclude.d.ts +1 -1
- package/dist/src/local/codegraph_exclude.js +1 -1
- package/dist/src/local/codegraph_prepare.js +1 -1
- package/dist/src/local/git_ops.d.ts +1 -1
- package/dist/src/local/git_ops.js +1 -1
- package/dist/src/local/git_parse.d.ts +2 -2
- package/dist/src/local/git_parse.js +2 -2
- package/dist/src/local/git_revision.d.ts +1 -1
- package/dist/src/local/git_revision.js +1 -1
- package/dist/src/local/git_untracked.d.ts +1 -1
- package/dist/src/local/git_untracked.js +1 -1
- package/dist/src/local/review_local.js +17 -14
- package/dist/src/local/review_lock.d.ts +1 -1
- package/dist/src/local/review_lock.js +1 -1
- package/dist/src/pr/checkout.js +2 -2
- package/dist/src/pr/findings.js +1 -1
- package/dist/src/pr/findings_json.d.ts +2 -2
- package/dist/src/pr/findings_json.js +1 -1
- package/dist/src/pr/github_revision.js +1 -1
- package/dist/src/pr/review_copy.d.ts +2 -2
- package/dist/src/pr/review_copy.js +2 -2
- package/dist/src/pr/reviewer.d.ts +6 -0
- package/dist/src/pr/reviewer.js +49 -39
- package/dist/src/remote/client.d.ts +4 -0
- package/dist/src/remote/client.js +84 -23
- package/dist/src/remote/fixtures.js +1 -1
- package/dist/src/remote/http.d.ts +10 -1
- package/dist/src/remote/http.js +15 -3
- package/dist/src/remote/schema.d.ts +3 -3
- package/dist/src/remote/schema.js +3 -3
- package/dist/src/remote/server/body.d.ts +1 -1
- package/dist/src/remote/server/body.js +1 -1
- package/dist/src/remote/server/byok_keys.d.ts +15 -0
- package/dist/src/remote/server/byok_keys.js +40 -0
- package/dist/src/remote/server/guides.js +2 -2
- package/dist/src/remote/server/routes.js +8 -1
- package/dist/src/remote/validate.js +15 -1
- package/dist/src/review/blocking.d.ts +2 -2
- package/dist/src/review/blocking.js +2 -2
- package/dist/src/review/carry_over.d.ts +2 -2
- package/dist/src/review/carry_over.js +2 -2
- package/dist/src/review/guides.js +1 -1
- package/dist/src/server/api/remote_tokens.js +2 -0
- package/dist/src/server/api/repos.js +51 -0
- package/dist/src/server/api/settings.js +10 -0
- package/dist/src/server/errors.d.ts +1 -1
- package/dist/src/server/http.js +1 -1
- package/dist/src/server/pages/activity.d.ts +2 -0
- package/dist/src/server/pages/activity.js +18 -4
- package/dist/src/server/pages/add_repo.js +61 -1
- package/dist/src/server/pages/analytics.js +18 -8
- package/dist/src/server/pages/cost.d.ts +21 -0
- package/dist/src/server/pages/cost.js +72 -0
- package/dist/src/server/pages/pr.js +5 -4
- package/dist/src/server/pages/repo.js +3 -2
- package/dist/src/server/pages/repo_prs.js +3 -2
- package/dist/src/server/pages/repo_remote.js +3 -2
- package/dist/src/server/pages/repo_settings.js +326 -12
- package/dist/src/server/pages/router.js +3 -2
- package/dist/src/server/pages/settings.js +50 -6
- package/dist/src/server/pages/styles.d.ts +1 -1
- package/dist/src/server/pages/styles.js +1 -1
- package/dist/src/services/credentials.js +8 -11
- package/dist/src/services/dashboard.d.ts +22 -9
- package/dist/src/services/dashboard.js +16 -2
- package/dist/src/services/probe.js +1 -1
- package/dist/src/services/remote_review.js +142 -35
- package/dist/src/services/remote_review_abort.d.ts +5 -1
- package/dist/src/services/remote_review_abort.js +9 -1
- package/dist/src/services/review.d.ts +1 -1
- package/dist/src/services/review.js +19 -5
- package/dist/src/services/review_policy.d.ts +116 -0
- package/dist/src/services/review_policy.js +489 -0
- package/dist/src/services/setup.d.ts +4 -3
- package/dist/src/services/setup.js +18 -16
- package/dist/src/services/setup_checklist.js +2 -2
- package/dist/src/services/webhook.d.ts +23 -0
- package/dist/src/services/webhook.js +141 -34
- package/dist/src/store/app_db.d.ts +5 -0
- package/dist/src/store/app_db.js +69 -13
- package/dist/src/store/backup.d.ts +31 -0
- package/dist/src/store/backup.js +158 -0
- package/dist/src/store/migrations.d.ts +1 -1
- package/dist/src/store/migrations.js +21 -1
- package/dist/src/store/remote_review_inputs.d.ts +3 -0
- package/dist/src/store/remote_review_inputs.js +3 -3
- package/dist/src/store/remote_tokens.d.ts +1 -1
- package/dist/src/store/remote_tokens.js +1 -1
- package/dist/src/store/repos.d.ts +2 -2
- package/dist/src/store/repos.js +1 -1
- package/dist/src/store/reviews.d.ts +21 -13
- package/dist/src/store/reviews.js +60 -15
- package/dist/src/store/rows.d.ts +10 -0
- package/dist/src/tools/codegraph.d.ts +4 -4
- package/dist/src/tools/codegraph.js +5 -5
- package/dist/src/tools/codegraph_exec.d.ts +1 -1
- package/dist/src/tools/codegraph_exec.js +1 -1
- package/dist/src/types.d.ts +12 -6
- package/dist/src/util/cost.d.ts +52 -0
- package/dist/src/util/cost.js +70 -0
- package/dist/src/util/log.d.ts +1 -1
- package/dist/src/util/log.js +2 -2
- package/dist/src/util/redact.js +2 -0
- package/dist/src/util/run_summary.d.ts +7 -2
- package/dist/src/util/run_summary.js +28 -2
- package/dist/src/util/time.d.ts +1 -1
- package/dist/src/util/time.js +1 -1
- 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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
package/dist/src/store/app_db.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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();
|