@fullstackhouse/cerber 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/cli/index.js +18 -1
- package/dist/core/artifact.js +23 -0
- package/dist/core/gh.js +52 -0
- package/dist/core/refresh.js +44 -13
- package/dist/core/state.js +11 -1
- package/dist/runner/review.js +47 -29
- package/dist/server/daemon.js +117 -8
- package/dist/server/index.js +49 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -384,6 +384,13 @@ shadow mode for a while: every would-send decision lands in
|
|
|
384
384
|
verdicts and comments survive your review — enable auto-send when its 90%
|
|
385
385
|
actually means 90%.
|
|
386
386
|
|
|
387
|
+
## How it works
|
|
388
|
+
|
|
389
|
+
[**docs/lifecycle.md**](docs/lifecycle.md) is the reference behind everything
|
|
390
|
+
above: the seven statuses and what sets each, which tab a PR lands in and why,
|
|
391
|
+
when the poll files or archives a row on its own, when a re-review happens and
|
|
392
|
+
what it will not overwrite, and what gets written to disk at each step.
|
|
393
|
+
|
|
387
394
|
## Development
|
|
388
395
|
|
|
389
396
|
```bash
|
package/dist/cli/index.js
CHANGED
|
@@ -8,8 +8,9 @@ import { configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
|
8
8
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
9
9
|
import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
10
10
|
import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
|
|
11
|
-
import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, updateArtifactByKey, } from "../core/state.js";
|
|
11
|
+
import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
|
|
12
12
|
import { pool, reviewPr } from "../runner/review.js";
|
|
13
|
+
import { isReviewRunning } from "../runner/inflight.js";
|
|
13
14
|
import { startDaemon } from "../server/daemon.js";
|
|
14
15
|
import { startServer } from "../server/index.js";
|
|
15
16
|
const program = new Command();
|
|
@@ -135,6 +136,15 @@ program
|
|
|
135
136
|
console.error(`Already sent at ${artifact.sent.at}${artifact.sent.url ? ` — ${artifact.sent.url}` : ""}`);
|
|
136
137
|
process.exit(1);
|
|
137
138
|
}
|
|
139
|
+
// The persisted status is the only signal here: a run started by `serve` or
|
|
140
|
+
// by another terminal is invisible to this process. Sending mid-run vouches
|
|
141
|
+
// for a draft that is being replaced as you read it.
|
|
142
|
+
if (artifact.status === "running") {
|
|
143
|
+
console.error(`A review of ${id} is running — wait for it to finish, then send. ` +
|
|
144
|
+
`If nothing is actually running, the last run died mid-flight: re-run it, or restart \`cerber serve\`, ` +
|
|
145
|
+
`either of which clears the stale status.`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
138
148
|
const event = (opts.event ??
|
|
139
149
|
(artifact.verdict ? eventForRecommendation(artifact.verdict.recommendation) : "COMMENT")).toUpperCase();
|
|
140
150
|
if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(event)) {
|
|
@@ -352,6 +362,13 @@ program
|
|
|
352
362
|
`matching PRs, with nobody watching. Pass --no-trust to keep every review read-only, ` +
|
|
353
363
|
`or --no-auto-review to only review on click.`);
|
|
354
364
|
}
|
|
365
|
+
// Before anything polls. Reconciliation is what makes "nothing is running"
|
|
366
|
+
// true after an unclean shutdown, and the daemon's first tick fires the
|
|
367
|
+
// moment it is constructed — so starting it first put both of them on the
|
|
368
|
+
// same leftover artifact with no ordering between them.
|
|
369
|
+
const cleared = await reconcileRunning({ inUse: isReviewRunning });
|
|
370
|
+
if (cleared > 0)
|
|
371
|
+
console.log(`Cleared ${cleared} interrupted run(s) left by a previous process.`);
|
|
355
372
|
const daemon = opts.poll
|
|
356
373
|
? startDaemon({
|
|
357
374
|
repos: opts.repo.length > 0 ? opts.repo : d.repos,
|
package/dist/core/artifact.js
CHANGED
|
@@ -97,6 +97,17 @@ export const RunInfoSchema = z.object({
|
|
|
97
97
|
* be filed away automatically and one that must not be.
|
|
98
98
|
*/
|
|
99
99
|
trigger: z.enum(["daemon", "user"]).nullable().default(null),
|
|
100
|
+
/**
|
|
101
|
+
* The head commit this run actually read, set when it finishes.
|
|
102
|
+
*
|
|
103
|
+
* `pr.headSha` cannot answer that question: a refresh moves it forward to
|
|
104
|
+
* keep the comments anchored to current code, without anybody re-reading
|
|
105
|
+
* anything. The freshness guard compares against *this* instead, so merely
|
|
106
|
+
* opening a draft after a push no longer convinces the poll that the draft
|
|
107
|
+
* is up to date. Null on runs recorded before this existed, and on one still
|
|
108
|
+
* in flight — both fall back to the old comparison.
|
|
109
|
+
*/
|
|
110
|
+
reviewedSha: z.string().nullable().default(null),
|
|
100
111
|
});
|
|
101
112
|
export const SentInfoSchema = z.object({
|
|
102
113
|
at: z.string(),
|
|
@@ -267,6 +278,18 @@ export const ArtifactSchema = z.object({
|
|
|
267
278
|
sent: SentInfoSchema.nullable().default(null),
|
|
268
279
|
/** Set when cerber filed this draft away itself, and why. Never on a sent one. */
|
|
269
280
|
filed: FiledInfoSchema.nullable().default(null),
|
|
281
|
+
/**
|
|
282
|
+
* When this review was settled — marked `reviewed` or `skipped`, by you or by
|
|
283
|
+
* cerber filing it. Null on anything not settled.
|
|
284
|
+
*
|
|
285
|
+
* A settle answers the request that was open at the time. It cannot answer
|
|
286
|
+
* one that came afterwards, so the poll needs to know which side of it a
|
|
287
|
+
* review request falls on before deciding a settled row stays settled
|
|
288
|
+
* (`askedAgainAfterSettling` in `src/server/daemon.ts`). `updatedAt` cannot
|
|
289
|
+
* stand in for this: opening a settled review refreshes it, which moves that
|
|
290
|
+
* field forward long after the decision it is meant to date.
|
|
291
|
+
*/
|
|
292
|
+
settledAt: z.string().nullable().default(null),
|
|
270
293
|
/** Last time this review was pulled forward onto a newer head commit. */
|
|
271
294
|
refresh: RefreshInfoSchema.nullable().default(null),
|
|
272
295
|
calibration: CalibrationSchema.nullable().default(null),
|
package/dist/core/gh.js
CHANGED
|
@@ -254,6 +254,58 @@ export async function fetchReviewRequests(ref) {
|
|
|
254
254
|
export function stillRequested(requests, login) {
|
|
255
255
|
return requests.teams.length > 0 || requests.users.includes(login);
|
|
256
256
|
}
|
|
257
|
+
export function lastRequestOf(events, login) {
|
|
258
|
+
const mine = events.filter((e) => e.requestedReviewer?.__typename === "User" && e.requestedReviewer.login === login);
|
|
259
|
+
if (mine.length === 0)
|
|
260
|
+
return null;
|
|
261
|
+
return mine.reduce((a, b) => (b.createdAt > a.createdAt ? b : a)).createdAt;
|
|
262
|
+
}
|
|
263
|
+
// 100 is the page maximum, and it costs exactly what a smaller window would:
|
|
264
|
+
// one call. It wants to be big because the recent events are not the useful
|
|
265
|
+
// ones — a PR that cycled through a dozen reviewers can push the request that
|
|
266
|
+
// named *you* past a short window, and the answer would come back "nobody
|
|
267
|
+
// asked", silently restoring the bug this exists to fix.
|
|
268
|
+
//
|
|
269
|
+
// It is still a window, not a guarantee: a PR carrying more than 100 review
|
|
270
|
+
// requests would lose the oldest, and that is accepted rather than paginated.
|
|
271
|
+
// Paginating would cost one call per extra page on every settled row on every
|
|
272
|
+
// poll, to cover a PR that does not realistically exist — and the failure mode
|
|
273
|
+
// is the conservative one, a row left settled rather than one wrongly reopened.
|
|
274
|
+
const LAST_REQUEST_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
|
|
275
|
+
repository(owner:$owner,name:$repo){
|
|
276
|
+
pullRequest(number:$number){
|
|
277
|
+
timelineItems(last:100,itemTypes:[REVIEW_REQUESTED_EVENT]){
|
|
278
|
+
nodes{... on ReviewRequestedEvent{createdAt requestedReviewer{__typename ... on User{login}}}}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}`;
|
|
283
|
+
/**
|
|
284
|
+
* Ask GitHub when your review was last requested on a PR.
|
|
285
|
+
*
|
|
286
|
+
* GraphQL rather than the REST timeline on purpose: this runs on rows the queue
|
|
287
|
+
* already holds, poll after poll, and the REST timeline of a busy PR is hundreds
|
|
288
|
+
* of events across several pages — every one of them fetched to find the handful
|
|
289
|
+
* that are review requests. Filtering server-side to the one event type turns
|
|
290
|
+
* that into a single call; the window it reads is bounded, as the query above
|
|
291
|
+
* explains.
|
|
292
|
+
*/
|
|
293
|
+
export async function fetchLastReviewRequest(ref, login) {
|
|
294
|
+
const out = await gh([
|
|
295
|
+
"api",
|
|
296
|
+
"graphql",
|
|
297
|
+
"-f",
|
|
298
|
+
`query=${LAST_REQUEST_QUERY}`,
|
|
299
|
+
"-F",
|
|
300
|
+
`owner=${ref.owner}`,
|
|
301
|
+
"-F",
|
|
302
|
+
`repo=${ref.repo}`,
|
|
303
|
+
"-F",
|
|
304
|
+
`number=${ref.number}`,
|
|
305
|
+
]);
|
|
306
|
+
const raw = JSON.parse(out);
|
|
307
|
+
return lastRequestOf(raw.data?.repository?.pullRequest?.timelineItems?.nodes ?? [], login);
|
|
308
|
+
}
|
|
257
309
|
export function searchAwaitingArgs(repoFilter, limit = 50) {
|
|
258
310
|
const args = [
|
|
259
311
|
"search",
|
package/dist/core/refresh.js
CHANGED
|
@@ -34,21 +34,52 @@ export function refreshArtifact(artifact, pr, diff) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Is this artifact's status the user's to keep, rather than the run's to set?
|
|
38
|
+
*
|
|
39
|
+
* A send or a settle is a decision about the PR; a run finishing is a fact
|
|
40
|
+
* about the code. Whichever way the run went — a draft or an error — it does
|
|
41
|
+
* not get to reopen one, which is the same rule `SETTLED_BY_YOU` applies to
|
|
42
|
+
* starting a run in the first place.
|
|
40
43
|
*/
|
|
41
|
-
export function
|
|
42
|
-
return
|
|
44
|
+
export function userOwnsStatus(a) {
|
|
45
|
+
return a.sent !== null || a.status === "reviewed" || a.status === "skipped";
|
|
43
46
|
}
|
|
44
47
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
48
|
+
* Fold a finished review run onto whatever the artifact says now.
|
|
49
|
+
*
|
|
50
|
+
* A run takes minutes and the cockpit stays live throughout, so by the time one
|
|
51
|
+
* lands the artifact may have moved under it. Saving the result wholesale
|
|
52
|
+
* silently undid whatever had happened in between — the same mistake
|
|
53
|
+
* `mergeConcurrentEdits` exists to stop a chat turn making.
|
|
54
|
+
*
|
|
55
|
+
* `fresh` is what the run produced; `current` is what is on disk now. The run
|
|
56
|
+
* owns the draft — summary, chapters, verdict, comments — and that includes
|
|
57
|
+
* replacing comments the user wrote: a re-review starts fresh by design, and
|
|
58
|
+
* `docs/lifecycle.md` says so plainly rather than leaving anyone to count on
|
|
59
|
+
* work surviving one. What it does *not* own are the decisions:
|
|
60
|
+
*
|
|
61
|
+
* - **a send** stands, and takes the status with it. Without this a run
|
|
62
|
+
* finishing after a send wrote `sent: null` back over the record, and the
|
|
63
|
+
* "already sent" guard would then wave a second submission through.
|
|
64
|
+
* - **a settle** stands too: `reviewed` and `skipped` are decisions about the
|
|
65
|
+
* PR, not facts about the code, so a run completing does not reopen one.
|
|
66
|
+
* The fresh draft still lands underneath, which is what the row shows if
|
|
67
|
+
* the user changes their mind.
|
|
68
|
+
* - **the conversation** is the user's writing, snapshot and all.
|
|
47
69
|
*/
|
|
48
|
-
export function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
70
|
+
export function mergeRunResult(fresh, current) {
|
|
71
|
+
return {
|
|
72
|
+
...fresh,
|
|
73
|
+
status: userOwnsStatus(current) ? current.status : fresh.status,
|
|
74
|
+
// Travels with the status it dates. A settle that landed while the run
|
|
75
|
+
// worked keeps both halves of itself, and a run that reopens the row takes
|
|
76
|
+
// the stamp away with the status.
|
|
77
|
+
settledAt: userOwnsStatus(current) ? current.settledAt : fresh.settledAt,
|
|
78
|
+
sent: current.sent,
|
|
79
|
+
calibration: current.calibration,
|
|
80
|
+
filed: current.filed,
|
|
81
|
+
chat: current.chat,
|
|
82
|
+
preChat: current.preChat,
|
|
83
|
+
pendingChat: current.pendingChat,
|
|
84
|
+
};
|
|
54
85
|
}
|
package/dist/core/state.js
CHANGED
|
@@ -98,11 +98,21 @@ export async function updateArtifactByKey(key, mutate) {
|
|
|
98
98
|
* this it stays wedged forever, with no way to retry it from the cockpit. A
|
|
99
99
|
* `cerber review` running in another terminal is the one false positive; it
|
|
100
100
|
* overwrites the artifact when it finishes anyway.
|
|
101
|
+
*
|
|
102
|
+
* `inUse` names the runs this process genuinely owns, and they are left alone.
|
|
103
|
+
* Callers should still reconcile before starting anything that polls — but
|
|
104
|
+
* ordering alone is a promise about wiring, and this is the guard that holds
|
|
105
|
+
* whatever order they are started in: without it, reconciliation racing a
|
|
106
|
+
* daemon's first tick could stamp "interrupted" over a review that had just
|
|
107
|
+
* legitimately begun.
|
|
101
108
|
*/
|
|
102
|
-
export async function reconcileRunning() {
|
|
109
|
+
export async function reconcileRunning(opts = {}) {
|
|
110
|
+
const inUse = opts.inUse ?? (() => false);
|
|
103
111
|
const artifacts = await listArtifacts();
|
|
104
112
|
let cleared = 0;
|
|
105
113
|
for (const artifact of artifacts) {
|
|
114
|
+
if (inUse(artifact.id))
|
|
115
|
+
continue;
|
|
106
116
|
const stuckRun = artifact.status === "running";
|
|
107
117
|
const stuckChat = artifact.pendingChat && artifact.pendingChat.error == null ? artifact.pendingChat : null;
|
|
108
118
|
if (!stuckRun && !stuckChat)
|
package/dist/runner/review.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { AiReviewSchema, SCHEMA_VERSION, artifactId, } from "../core/artifact.js";
|
|
2
|
+
import { AiReviewSchema, SCHEMA_VERSION, artifactId, artifactKey, } from "../core/artifact.js";
|
|
3
3
|
import { createRunDir, evictOldCheckouts, prepareCheckout, removeRunDir } from "../core/checkout.js";
|
|
4
4
|
import { fetchPrDiff, fetchPrInfo, isOrgMember, isTeamMember } from "../core/gh.js";
|
|
5
|
-
import {
|
|
6
|
-
import { loadArtifact, saveArtifact } from "../core/state.js";
|
|
5
|
+
import { mergeRunResult, userOwnsStatus } from "../core/refresh.js";
|
|
6
|
+
import { loadArtifact, saveArtifact, updateArtifactByKey } from "../core/state.js";
|
|
7
7
|
import { loadConfig } from "../core/config.js";
|
|
8
8
|
import { decideTrust, membershipQueries, parseTrustRules } from "../core/trust.js";
|
|
9
9
|
import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
|
|
@@ -32,7 +32,7 @@ const HEAD_SENSITIVE = new Set(["ready", "sent"]);
|
|
|
32
32
|
* as cerber overruling you — and on a busy PR it happens every few minutes.
|
|
33
33
|
* The way back in is the way you got out: press re-review, which forces.
|
|
34
34
|
*/
|
|
35
|
-
const SETTLED_BY_YOU = new Set(["reviewed", "skipped"]);
|
|
35
|
+
export const SETTLED_BY_YOU = new Set(["reviewed", "skipped"]);
|
|
36
36
|
/**
|
|
37
37
|
* Fetch a PR, run the AI review, persist the artifact at each stage.
|
|
38
38
|
* Everything stays local — nothing is ever written to GitHub.
|
|
@@ -91,10 +91,15 @@ async function runReview(ref, opts) {
|
|
|
91
91
|
log(`You marked this ${existing.status} — leaving it alone. Use --force to re-review.`);
|
|
92
92
|
return { artifact: existing, skipped: true };
|
|
93
93
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
// The sha the AI *read*, not the one the artifact happens to mention.
|
|
95
|
+
// `pr.headSha` is moved forward by the refresh that runs whenever a review
|
|
96
|
+
// is opened, so comparing against it meant that merely looking at a draft
|
|
97
|
+
// after a push convinced this guard the draft was current, and the poll
|
|
98
|
+
// never re-reviewed it again. Older artifacts have no `reviewedSha` and
|
|
99
|
+
// fall back to the old comparison.
|
|
100
|
+
const reviewedSha = existing.run?.reviewedSha ?? existing.pr.headSha;
|
|
101
|
+
if (HEAD_SENSITIVE.has(existing.status) && reviewedSha !== "" && reviewedSha === pr.headSha) {
|
|
102
|
+
log(`Up to date (reviewed at ${reviewedSha.slice(0, 7)}, status ${existing.status}) — skipping. Use --force to re-review.`);
|
|
98
103
|
return { artifact: existing, skipped: true };
|
|
99
104
|
}
|
|
100
105
|
}
|
|
@@ -120,6 +125,14 @@ async function runReview(ref, opts) {
|
|
|
120
125
|
`reviewing from the diff alone.`);
|
|
121
126
|
}
|
|
122
127
|
}
|
|
128
|
+
// A re-review regenerates the draft, comments and all — deliberately, and
|
|
129
|
+
// including any the user wrote. Carrying them across is a feature this tool
|
|
130
|
+
// has decided not to have, so nothing here tries to half-keep them: they go
|
|
131
|
+
// when the run starts, whichever way it ends. `docs/lifecycle.md` says so
|
|
132
|
+
// where a reader would otherwise assume otherwise.
|
|
133
|
+
if (existing && existing.comments.length > 0) {
|
|
134
|
+
log(`Replacing the previous draft's ${existing.comments.length} comment(s) — a re-review starts fresh.`);
|
|
135
|
+
}
|
|
123
136
|
let artifact = {
|
|
124
137
|
schemaVersion: SCHEMA_VERSION,
|
|
125
138
|
id: artifactId(pr),
|
|
@@ -142,10 +155,12 @@ async function runReview(ref, opts) {
|
|
|
142
155
|
trusted: trusted && source !== null,
|
|
143
156
|
sessionId: null,
|
|
144
157
|
trigger: opts.trigger ?? "user",
|
|
158
|
+
reviewedSha: null,
|
|
145
159
|
},
|
|
146
160
|
sent: null,
|
|
147
161
|
refresh: null,
|
|
148
162
|
filed: null,
|
|
163
|
+
settledAt: null,
|
|
149
164
|
calibration: null,
|
|
150
165
|
// The conversation is the user's writing, so a re-review keeps it — the
|
|
151
166
|
// chat prompt replays the transcript, which is what makes it survive the
|
|
@@ -204,34 +219,37 @@ async function runReview(ref, opts) {
|
|
|
204
219
|
// whose working directory was empty has nothing a chat turn could read.
|
|
205
220
|
sessionId: source ? review.sessionId : null,
|
|
206
221
|
trigger: artifact.run.trigger,
|
|
222
|
+
// What this run actually read — the thing the freshness guard needs
|
|
223
|
+
// and `pr.headSha` cannot say, because a refresh moves that forward
|
|
224
|
+
// without anybody re-reading the code.
|
|
225
|
+
reviewedSha: pr.headSha,
|
|
207
226
|
},
|
|
208
227
|
};
|
|
209
|
-
// A re-review regenerates the AI's comments, but the human's own comments
|
|
210
|
-
// and their rewrites of AI ones are work the run must not throw away.
|
|
211
|
-
if (existing) {
|
|
212
|
-
const { comments, carried, drifted } = carryOverComments(existing, artifact);
|
|
213
|
-
if (carried > 0) {
|
|
214
|
-
artifact = { ...artifact, comments };
|
|
215
|
-
log(`Carried over ${carried} comment(s) you wrote or edited` +
|
|
216
|
-
(drifted > 0 ? `; ${drifted} no longer match the diff and will post in the body.` : "."));
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
228
|
}
|
|
220
229
|
catch (err) {
|
|
221
|
-
|
|
222
|
-
...artifact,
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
run: {
|
|
226
|
-
...artifact.run,
|
|
227
|
-
finishedAt: now(),
|
|
228
|
-
error: err instanceof Error ? err.message : String(err),
|
|
229
|
-
},
|
|
230
|
+
const run = {
|
|
231
|
+
...artifact.run,
|
|
232
|
+
finishedAt: now(),
|
|
233
|
+
error: err instanceof Error ? err.message : String(err),
|
|
230
234
|
};
|
|
231
|
-
|
|
235
|
+
// Onto what is on disk, not over it: the artifact this run built holds none
|
|
236
|
+
// of the comments or marks that may have landed while it ran, and a failure
|
|
237
|
+
// is no reason to lose them — nor to reopen a decision the user made.
|
|
238
|
+
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
|
|
239
|
+
...a,
|
|
240
|
+
status: userOwnsStatus(a) ? a.status : "failed",
|
|
241
|
+
run,
|
|
242
|
+
}));
|
|
243
|
+
artifact = saved ?? { ...artifact, status: "failed", run, updatedAt: now() };
|
|
244
|
+
if (!saved)
|
|
245
|
+
await saveArtifact(artifact);
|
|
232
246
|
throw err;
|
|
233
247
|
}
|
|
234
|
-
await
|
|
248
|
+
const merged = await updateArtifactByKey(artifactKey(artifact.id), (current) => mergeRunResult(artifact, current));
|
|
249
|
+
if (merged)
|
|
250
|
+
artifact = merged;
|
|
251
|
+
else
|
|
252
|
+
await saveArtifact(artifact);
|
|
235
253
|
return { artifact, skipped: false };
|
|
236
254
|
}
|
|
237
255
|
/** Run tasks with bounded concurrency, preserving order of results. */
|
package/dist/server/daemon.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
|
|
2
2
|
import { evaluateAutoSend } from "../core/autosend.js";
|
|
3
3
|
import { loadConfig } from "../core/config.js";
|
|
4
|
-
import { classifyReply, currentLogin, fetchConversation, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
4
|
+
import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
5
5
|
import { notice, notify } from "../core/notify.js";
|
|
6
6
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
7
7
|
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
8
8
|
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
9
|
-
import { pool, reviewPr } from "../runner/review.js";
|
|
9
|
+
import { SETTLED_BY_YOU, pool, reviewPr } from "../runner/review.js";
|
|
10
10
|
/**
|
|
11
11
|
* A pure discovery stub: enough artifact for the queue to show an awaiting row.
|
|
12
12
|
* The AI run that follows (automatic or clicked) replaces it wholesale.
|
|
@@ -45,6 +45,7 @@ export function stubArtifact(ref) {
|
|
|
45
45
|
sent: null,
|
|
46
46
|
refresh: null,
|
|
47
47
|
filed: null,
|
|
48
|
+
settledAt: null,
|
|
48
49
|
calibration: null,
|
|
49
50
|
chat: [],
|
|
50
51
|
preChat: null,
|
|
@@ -122,7 +123,58 @@ export function filedByWithdrawnRequest(artifact) {
|
|
|
122
123
|
return false;
|
|
123
124
|
return artifact.run?.trigger === "daemon";
|
|
124
125
|
}
|
|
125
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* When a settled row was settled, as well as cerber can date it — or null if it
|
|
128
|
+
* isn't settled at all, or a send already spoke for it.
|
|
129
|
+
*
|
|
130
|
+
* `settledAt` is the record and the only exact answer. Rows settled before that
|
|
131
|
+
* field existed fall back to the latest moment cerber can prove the decision
|
|
132
|
+
* came after: cerber filed it (`filed.at`), or you settled a draft, which you
|
|
133
|
+
* cannot have done before the draft existed (`run.finishedAt`). Both are lower
|
|
134
|
+
* bounds, so a stale one can only make this read a request as newer than it
|
|
135
|
+
* really was — one row coming back into the inbox once, against leaving every
|
|
136
|
+
* pre-existing settled row permanently unreachable, which is the bug.
|
|
137
|
+
*
|
|
138
|
+
* Not `updatedAt`: opening a settled review refreshes it onto the new head,
|
|
139
|
+
* which moves that field forward long after the decision it would be dating.
|
|
140
|
+
*/
|
|
141
|
+
export function settledAtOf(a) {
|
|
142
|
+
if (!SETTLED_BY_YOU.has(a.status) || a.sent)
|
|
143
|
+
return null;
|
|
144
|
+
return a.settledAt ?? a.filed?.at ?? a.run?.finishedAt ?? null;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Whether a review request is a *new* ask rather than the one you settled.
|
|
148
|
+
*
|
|
149
|
+
* A skip says "I am done with this PR", and a push does not undo that — the
|
|
150
|
+
* whole point of `SETTLED_BY_YOU`. Somebody asking you again is a different
|
|
151
|
+
* event: your skip answered the request that was open when you made it, and it
|
|
152
|
+
* cannot have answered one that came after. Withdrawn-then-re-added is the
|
|
153
|
+
* shape this catches, and it is not exotic — an author who pulls the request
|
|
154
|
+
* while you are mid-look and puts it back with the next push leaves the inbox
|
|
155
|
+
* silent about a review someone is now actively waiting on.
|
|
156
|
+
*
|
|
157
|
+
* Parsed, not compared as text, for the reason `filedByYourAct` gives: our
|
|
158
|
+
* timestamps carry milliseconds and GitHub's do not, and "…:00.500Z" sorts
|
|
159
|
+
* before "…:00Z".
|
|
160
|
+
*/
|
|
161
|
+
export function askedAgainAfterSettling(a, requestedAt) {
|
|
162
|
+
if (requestedAt === null)
|
|
163
|
+
return false;
|
|
164
|
+
const settled = settledAtOf(a);
|
|
165
|
+
return settled !== null && Date.parse(requestedAt) > Date.parse(settled);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* What a reopened row goes back to being: the draft it has, or a row waiting
|
|
169
|
+
* for one. A run that failed left no draft to show, so it reopens as `awaiting`
|
|
170
|
+
* and the poll drafts it again.
|
|
171
|
+
*/
|
|
172
|
+
export function reopenedStatus(a) {
|
|
173
|
+
return a.run?.finishedAt && !a.run.error ? "ready" : "awaiting";
|
|
174
|
+
}
|
|
175
|
+
/** How many artifacts each of the poll's GitHub re-checks — the open-PR state
|
|
176
|
+
* refresh, and the settled rows it asks about again — may spend per poll, and
|
|
177
|
+
* how often any one artifact is re-asked about. */
|
|
126
178
|
const STATE_REFRESH_CAP = 10;
|
|
127
179
|
const STATE_REFRESH_MIN_MS = 30 * 60_000;
|
|
128
180
|
/**
|
|
@@ -162,6 +214,9 @@ export function startDaemon(opts) {
|
|
|
162
214
|
/** When GitHub last confirmed an artifact's PR state, by artifact id. In-memory:
|
|
163
215
|
* a restart just re-checks sooner, which is harmless. */
|
|
164
216
|
const stateCheckedAt = new Map();
|
|
217
|
+
/** When GitHub was last asked whether a settled row has been re-requested, by
|
|
218
|
+
* artifact id. In-memory for the same reason: a restart just asks sooner. */
|
|
219
|
+
const requestCheckedAt = new Map();
|
|
165
220
|
async function discover() {
|
|
166
221
|
const filters = opts.repos.length > 0 ? opts.repos : [undefined];
|
|
167
222
|
const seen = new Set();
|
|
@@ -183,9 +238,67 @@ export function startDaemon(opts) {
|
|
|
183
238
|
*/
|
|
184
239
|
async function syncQueue(refs) {
|
|
185
240
|
const discovered = [];
|
|
241
|
+
/** Reopen checks spent this poll — the same cap the state refresh below obeys. */
|
|
242
|
+
let reopenChecks = 0;
|
|
243
|
+
// Asked once per pass, and only if a row actually reaches a check that
|
|
244
|
+
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
245
|
+
// attributes nothing to you rather than guessing.
|
|
246
|
+
let login;
|
|
247
|
+
/**
|
|
248
|
+
* Put a settled row back in the inbox when somebody has asked you again.
|
|
249
|
+
*
|
|
250
|
+
* The mirror of `fileIfSettledElsewhere`: that one files rows away once
|
|
251
|
+
* GitHub has moved past them, this one brings one back when GitHub has
|
|
252
|
+
* moved *to* it. Both exist because the queue's picture of who is waiting
|
|
253
|
+
* on what is local, and the thing it is a picture of lives on github.com.
|
|
254
|
+
*
|
|
255
|
+
* Only rows GitHub is asking about right now get here at all — the caller
|
|
256
|
+
* is looping over the awaiting search — so the extra read is one call for
|
|
257
|
+
* one question: was that request made after you settled?
|
|
258
|
+
*/
|
|
259
|
+
async function reopenIfAskedAgain(artifact) {
|
|
260
|
+
if (settledAtOf(artifact) === null)
|
|
261
|
+
return;
|
|
262
|
+
const last = requestCheckedAt.get(artifact.id) ?? 0;
|
|
263
|
+
if (reopenChecks >= STATE_REFRESH_CAP || Date.now() - last < STATE_REFRESH_MIN_MS)
|
|
264
|
+
return;
|
|
265
|
+
reopenChecks++;
|
|
266
|
+
requestCheckedAt.set(artifact.id, Date.now());
|
|
267
|
+
if (login === undefined)
|
|
268
|
+
login = await currentLogin().catch(() => null);
|
|
269
|
+
if (login === null)
|
|
270
|
+
return;
|
|
271
|
+
let requestedAt;
|
|
272
|
+
try {
|
|
273
|
+
requestedAt = await fetchLastReviewRequest(artifact.pr, login);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// A read that failed is not evidence of anything, so nothing happens to
|
|
277
|
+
// the row. It is not re-asked about until the leash above expires: the
|
|
278
|
+
// stamp is spent before the call on purpose, so gh being down cannot
|
|
279
|
+
// turn every settled row into a retry on every poll.
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (requestedAt === null || !askedAgainAfterSettling(artifact, requestedAt))
|
|
283
|
+
return;
|
|
284
|
+
const at = requestedAt;
|
|
285
|
+
// Re-read from disk: a re-review or a send may have started in the time
|
|
286
|
+
// that call took, and neither wants its status rewritten underneath it.
|
|
287
|
+
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
|
|
288
|
+
? // `filed` goes with the status it explained: cerber's account of why
|
|
289
|
+
// this row was settled is not the story of a row that is back.
|
|
290
|
+
{ ...a, status: reopenedStatus(a), settledAt: null, filed: null }
|
|
291
|
+
: a);
|
|
292
|
+
// The status is what says the write landed. `settledAt` cannot: a legacy
|
|
293
|
+
// row never had one, so a mutation that declined would read as a reopen.
|
|
294
|
+
if (saved && !SETTLED_BY_YOU.has(saved.status)) {
|
|
295
|
+
log(`[${artifact.id}] your review was requested again on ${at.slice(0, 10)} — back in the inbox`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
186
298
|
for (const ref of refs) {
|
|
187
299
|
const existing = await loadArtifact(artifactId(ref));
|
|
188
300
|
if (existing) {
|
|
301
|
+
await reopenIfAskedAgain(existing);
|
|
189
302
|
// The search result is what the PR is right now; the artifact can
|
|
190
303
|
// predate a draft→ready flip. Refresh from it — it costs nothing, it's
|
|
191
304
|
// already in hand — or the queue keeps calling a ready PR a draft until
|
|
@@ -201,10 +314,6 @@ export function startDaemon(opts) {
|
|
|
201
314
|
await saveArtifact(stubArtifact(ref));
|
|
202
315
|
discovered.push(ref);
|
|
203
316
|
}
|
|
204
|
-
// Asked once per pass, and only if a row actually reaches the check that
|
|
205
|
-
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
206
|
-
// attributes nothing to you rather than guessing.
|
|
207
|
-
let login;
|
|
208
317
|
/**
|
|
209
318
|
* File a draft away once GitHub has moved past it without cerber's help.
|
|
210
319
|
*
|
|
@@ -240,7 +349,7 @@ export function startDaemon(opts) {
|
|
|
240
349
|
/** Re-reads from disk: a run or a send may have started in the seconds
|
|
241
350
|
* these reads took, and neither wants filing out from under it. */
|
|
242
351
|
async function file(filed, note, stillFilable) {
|
|
243
|
-
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed } : a);
|
|
352
|
+
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed, settledAt: filed.at } : a);
|
|
244
353
|
if (saved?.filed)
|
|
245
354
|
log(`[${artifact.id}] ${note} — filed as reviewed`);
|
|
246
355
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/
|
|
|
15
15
|
import { refreshArtifact } from "../core/refresh.js";
|
|
16
16
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
17
17
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
18
|
-
import { listArtifacts, loadArtifact, loadArtifactByKey,
|
|
18
|
+
import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
19
19
|
import { isReviewRunning } from "../runner/inflight.js";
|
|
20
20
|
import { reviewPr } from "../runner/review.js";
|
|
21
21
|
import { runChatTurn } from "../runner/chat.js";
|
|
@@ -23,6 +23,20 @@ import { mergeConcurrentEdits, restoreReview } from "../core/revise.js";
|
|
|
23
23
|
import { progressWriter } from "./progress.js";
|
|
24
24
|
import { ChatTurnSchema } from "../core/artifact.js";
|
|
25
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
/**
|
|
27
|
+
* The statuses `PATCH /api/reviews/:key` will set: the two that are the user's
|
|
28
|
+
* own decision about a PR. Everything else is a fact some other path owns.
|
|
29
|
+
*/
|
|
30
|
+
const SETTLEABLE = ["reviewed", "skipped"];
|
|
31
|
+
/**
|
|
32
|
+
* Is an AI run rewriting this draft right now?
|
|
33
|
+
*
|
|
34
|
+
* Two questions, because neither alone is enough: `isReviewRunning` is an
|
|
35
|
+
* in-memory claim and cannot see a `cerber review` going in another terminal,
|
|
36
|
+
* while the persisted status cannot see a run this process started moments ago
|
|
37
|
+
* that has not written yet.
|
|
38
|
+
*/
|
|
39
|
+
const inFlight = (a) => a.status === "running" || isReviewRunning(a.id);
|
|
26
40
|
export async function buildApp(opts) {
|
|
27
41
|
const app = new Hono();
|
|
28
42
|
if (opts.token) {
|
|
@@ -251,10 +265,12 @@ export async function buildApp(opts) {
|
|
|
251
265
|
trusted: false,
|
|
252
266
|
sessionId: null,
|
|
253
267
|
trigger: "user",
|
|
268
|
+
reviewedSha: null,
|
|
254
269
|
},
|
|
255
270
|
sent: null,
|
|
256
271
|
refresh: null,
|
|
257
272
|
filed: null,
|
|
273
|
+
settledAt: null,
|
|
258
274
|
calibration: null,
|
|
259
275
|
chat: [],
|
|
260
276
|
preChat: null,
|
|
@@ -284,10 +300,28 @@ export async function buildApp(opts) {
|
|
|
284
300
|
// ---- Mutations (all local — artifact edits only) ----
|
|
285
301
|
app.patch("/api/reviews/:key", async (c) => {
|
|
286
302
|
const body = await c.req.json();
|
|
303
|
+
// Only the two statuses that are the user's own decision. The schema
|
|
304
|
+
// permits all seven, and taking them all meant this endpoint would happily
|
|
305
|
+
// write `status: "sent"` with no `sent` record and nothing submitted —
|
|
306
|
+
// a row that claims a review reached GitHub, which no honest path produces.
|
|
307
|
+
// `running`/`ready`/`failed` belong to the runner, `sent` to the send path.
|
|
308
|
+
if (body.status !== undefined && !SETTLEABLE.includes(body.status)) {
|
|
309
|
+
return c.json({ error: `status must be one of ${SETTLEABLE.join(", ")} — got ${body.status}` }, 400);
|
|
310
|
+
}
|
|
287
311
|
const updated = await updateArtifactByKey(c.req.param("key"), (a) => {
|
|
288
312
|
const next = { ...a };
|
|
289
313
|
if (body.status !== undefined) {
|
|
290
314
|
next.status = ArtifactStatusSchema.parse(body.status);
|
|
315
|
+
// Dated on the way in, because nothing else can date it later: the poll
|
|
316
|
+
// asks whether a review request came before or after this decision, and
|
|
317
|
+
// `updatedAt` moves every time the review is opened and refreshed.
|
|
318
|
+
next.settledAt = new Date().toISOString();
|
|
319
|
+
// And cerber's own account of why this row was settled goes with the
|
|
320
|
+
// status it explained. `filed` outranks the status wherever the queue
|
|
321
|
+
// tags a row (`rowTag`, `requestTag`), on the grounds that "reviewed"
|
|
322
|
+
// would otherwise read as a click nobody made — which stops being true
|
|
323
|
+
// the moment you click. Same rule the poll's reopen follows.
|
|
324
|
+
next.filed = null;
|
|
291
325
|
}
|
|
292
326
|
if (body.verdictRecommendation !== undefined && next.verdict) {
|
|
293
327
|
next.verdict = {
|
|
@@ -429,6 +463,7 @@ export async function buildApp(opts) {
|
|
|
429
463
|
trusted: false,
|
|
430
464
|
sessionId: null,
|
|
431
465
|
trigger: "user",
|
|
466
|
+
reviewedSha: null,
|
|
432
467
|
},
|
|
433
468
|
}));
|
|
434
469
|
// Minutes-long: run it detached and let the cockpit poll the artifact.
|
|
@@ -589,6 +624,13 @@ export async function buildApp(opts) {
|
|
|
589
624
|
if (artifact.sent) {
|
|
590
625
|
return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
|
|
591
626
|
}
|
|
627
|
+
// Not while a run is rewriting this draft. Vouching for a review that is
|
|
628
|
+
// being replaced under you is reason enough on its own; on top of that the
|
|
629
|
+
// run used to save `sent: null` back over the record when it landed, so the
|
|
630
|
+
// guard above would wave a *second* submission through afterwards.
|
|
631
|
+
if (inFlight(artifact)) {
|
|
632
|
+
return c.json({ error: "a review of this PR is running — wait for it to finish" }, 409);
|
|
633
|
+
}
|
|
592
634
|
const payload = buildReviewPayload(artifact, event);
|
|
593
635
|
try {
|
|
594
636
|
const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments, commitId: payload.commitId });
|
|
@@ -628,11 +670,13 @@ export async function buildApp(opts) {
|
|
|
628
670
|
}
|
|
629
671
|
return app;
|
|
630
672
|
}
|
|
673
|
+
/**
|
|
674
|
+
* Serve the cockpit. `reconcileRunning` is deliberately *not* called here: the
|
|
675
|
+
* caller must have done it before starting the daemon, because the daemon polls
|
|
676
|
+
* as soon as it is constructed. Doing it here as well would have marked that
|
|
677
|
+
* poll's own fresh run as an interrupted leftover.
|
|
678
|
+
*/
|
|
631
679
|
export async function startServer(opts) {
|
|
632
|
-
const cleared = await reconcileRunning();
|
|
633
|
-
if (cleared > 0) {
|
|
634
|
-
console.log(`Cleared ${cleared} AI run(s) left in flight by a previous process — start them again from the cockpit.`);
|
|
635
|
-
}
|
|
636
680
|
const app = await buildApp(opts);
|
|
637
681
|
serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
|
|
638
682
|
const tokenHint = opts.token ? `/?token=${opts.token}` : "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Full Stack House (https://fullstack.house)",
|