@fullstackhouse/cerber 0.24.1 → 0.26.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 +25 -1
- package/dist/cli/index.js +39 -1
- package/dist/core/artifact.js +23 -0
- package/dist/core/gh.js +52 -0
- package/dist/core/history.js +226 -0
- package/dist/core/refresh.js +4 -0
- package/dist/core/state.js +80 -2
- package/dist/runner/review.js +27 -3
- package/dist/server/daemon.js +134 -12
- package/dist/server/index.js +17 -0
- package/package.json +1 -1
- package/web/dist/assets/index-B8yOhacn.js +236 -0
- package/web/dist/assets/{index-VLT71mrB.css → index-BAwkRYWm.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DFhsdnD2.js +0 -236
package/dist/server/daemon.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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
|
+
import { withWriter } from "../core/history.js";
|
|
5
6
|
import { notice, notify } from "../core/notify.js";
|
|
6
7
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
7
|
-
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
8
|
+
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, noteHistory, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
8
9
|
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
9
|
-
import { pool, reviewPr } from "../runner/review.js";
|
|
10
|
+
import { SETTLED_BY_YOU, pool, reviewPr } from "../runner/review.js";
|
|
10
11
|
/**
|
|
11
12
|
* A pure discovery stub: enough artifact for the queue to show an awaiting row.
|
|
12
13
|
* The AI run that follows (automatic or clicked) replaces it wholesale.
|
|
@@ -45,6 +46,7 @@ export function stubArtifact(ref) {
|
|
|
45
46
|
sent: null,
|
|
46
47
|
refresh: null,
|
|
47
48
|
filed: null,
|
|
49
|
+
settledAt: null,
|
|
48
50
|
calibration: null,
|
|
49
51
|
chat: [],
|
|
50
52
|
preChat: null,
|
|
@@ -122,7 +124,58 @@ export function filedByWithdrawnRequest(artifact) {
|
|
|
122
124
|
return false;
|
|
123
125
|
return artifact.run?.trigger === "daemon";
|
|
124
126
|
}
|
|
125
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* When a settled row was settled, as well as cerber can date it — or null if it
|
|
129
|
+
* isn't settled at all, or a send already spoke for it.
|
|
130
|
+
*
|
|
131
|
+
* `settledAt` is the record and the only exact answer. Rows settled before that
|
|
132
|
+
* field existed fall back to the latest moment cerber can prove the decision
|
|
133
|
+
* came after: cerber filed it (`filed.at`), or you settled a draft, which you
|
|
134
|
+
* cannot have done before the draft existed (`run.finishedAt`). Both are lower
|
|
135
|
+
* bounds, so a stale one can only make this read a request as newer than it
|
|
136
|
+
* really was — one row coming back into the inbox once, against leaving every
|
|
137
|
+
* pre-existing settled row permanently unreachable, which is the bug.
|
|
138
|
+
*
|
|
139
|
+
* Not `updatedAt`: opening a settled review refreshes it onto the new head,
|
|
140
|
+
* which moves that field forward long after the decision it would be dating.
|
|
141
|
+
*/
|
|
142
|
+
export function settledAtOf(a) {
|
|
143
|
+
if (!SETTLED_BY_YOU.has(a.status) || a.sent)
|
|
144
|
+
return null;
|
|
145
|
+
return a.settledAt ?? a.filed?.at ?? a.run?.finishedAt ?? null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether a review request is a *new* ask rather than the one you settled.
|
|
149
|
+
*
|
|
150
|
+
* A skip says "I am done with this PR", and a push does not undo that — the
|
|
151
|
+
* whole point of `SETTLED_BY_YOU`. Somebody asking you again is a different
|
|
152
|
+
* event: your skip answered the request that was open when you made it, and it
|
|
153
|
+
* cannot have answered one that came after. Withdrawn-then-re-added is the
|
|
154
|
+
* shape this catches, and it is not exotic — an author who pulls the request
|
|
155
|
+
* while you are mid-look and puts it back with the next push leaves the inbox
|
|
156
|
+
* silent about a review someone is now actively waiting on.
|
|
157
|
+
*
|
|
158
|
+
* Parsed, not compared as text, for the reason `filedByYourAct` gives: our
|
|
159
|
+
* timestamps carry milliseconds and GitHub's do not, and "…:00.500Z" sorts
|
|
160
|
+
* before "…:00Z".
|
|
161
|
+
*/
|
|
162
|
+
export function askedAgainAfterSettling(a, requestedAt) {
|
|
163
|
+
if (requestedAt === null)
|
|
164
|
+
return false;
|
|
165
|
+
const settled = settledAtOf(a);
|
|
166
|
+
return settled !== null && Date.parse(requestedAt) > Date.parse(settled);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* What a reopened row goes back to being: the draft it has, or a row waiting
|
|
170
|
+
* for one. A run that failed left no draft to show, so it reopens as `awaiting`
|
|
171
|
+
* and the poll drafts it again.
|
|
172
|
+
*/
|
|
173
|
+
export function reopenedStatus(a) {
|
|
174
|
+
return a.run?.finishedAt && !a.run.error ? "ready" : "awaiting";
|
|
175
|
+
}
|
|
176
|
+
/** How many artifacts each of the poll's GitHub re-checks — the open-PR state
|
|
177
|
+
* refresh, and the settled rows it asks about again — may spend per poll, and
|
|
178
|
+
* how often any one artifact is re-asked about. */
|
|
126
179
|
const STATE_REFRESH_CAP = 10;
|
|
127
180
|
const STATE_REFRESH_MIN_MS = 30 * 60_000;
|
|
128
181
|
/**
|
|
@@ -162,6 +215,9 @@ export function startDaemon(opts) {
|
|
|
162
215
|
/** When GitHub last confirmed an artifact's PR state, by artifact id. In-memory:
|
|
163
216
|
* a restart just re-checks sooner, which is harmless. */
|
|
164
217
|
const stateCheckedAt = new Map();
|
|
218
|
+
/** When GitHub was last asked whether a settled row has been re-requested, by
|
|
219
|
+
* artifact id. In-memory for the same reason: a restart just asks sooner. */
|
|
220
|
+
const requestCheckedAt = new Map();
|
|
165
221
|
async function discover() {
|
|
166
222
|
const filters = opts.repos.length > 0 ? opts.repos : [undefined];
|
|
167
223
|
const seen = new Set();
|
|
@@ -183,9 +239,70 @@ export function startDaemon(opts) {
|
|
|
183
239
|
*/
|
|
184
240
|
async function syncQueue(refs) {
|
|
185
241
|
const discovered = [];
|
|
242
|
+
/** Reopen checks spent this poll — the same cap the state refresh below obeys. */
|
|
243
|
+
let reopenChecks = 0;
|
|
244
|
+
// Asked once per pass, and only if a row actually reaches a check that
|
|
245
|
+
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
246
|
+
// attributes nothing to you rather than guessing.
|
|
247
|
+
let login;
|
|
248
|
+
/**
|
|
249
|
+
* Put a settled row back in the inbox when somebody has asked you again.
|
|
250
|
+
*
|
|
251
|
+
* The mirror of `fileIfSettledElsewhere`: that one files rows away once
|
|
252
|
+
* GitHub has moved past them, this one brings one back when GitHub has
|
|
253
|
+
* moved *to* it. Both exist because the queue's picture of who is waiting
|
|
254
|
+
* on what is local, and the thing it is a picture of lives on github.com.
|
|
255
|
+
*
|
|
256
|
+
* Only rows GitHub is asking about right now get here at all — the caller
|
|
257
|
+
* is looping over the awaiting search — so the extra read is one call for
|
|
258
|
+
* one question: was that request made after you settled?
|
|
259
|
+
*/
|
|
260
|
+
async function reopenIfAskedAgain(artifact) {
|
|
261
|
+
if (settledAtOf(artifact) === null)
|
|
262
|
+
return;
|
|
263
|
+
const last = requestCheckedAt.get(artifact.id) ?? 0;
|
|
264
|
+
if (reopenChecks >= STATE_REFRESH_CAP || Date.now() - last < STATE_REFRESH_MIN_MS)
|
|
265
|
+
return;
|
|
266
|
+
reopenChecks++;
|
|
267
|
+
requestCheckedAt.set(artifact.id, Date.now());
|
|
268
|
+
if (login === undefined)
|
|
269
|
+
login = await currentLogin().catch(() => null);
|
|
270
|
+
if (login === null)
|
|
271
|
+
return;
|
|
272
|
+
let requestedAt;
|
|
273
|
+
try {
|
|
274
|
+
requestedAt = await fetchLastReviewRequest(artifact.pr, login);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// A read that failed is not evidence of anything, so nothing happens to
|
|
278
|
+
// the row. It is not re-asked about until the leash above expires: the
|
|
279
|
+
// stamp is spent before the call on purpose, so gh being down cannot
|
|
280
|
+
// turn every settled row into a retry on every poll.
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (requestedAt === null || !askedAgainAfterSettling(artifact, requestedAt))
|
|
284
|
+
return;
|
|
285
|
+
const at = requestedAt;
|
|
286
|
+
// Re-read from disk: a re-review or a send may have started in the time
|
|
287
|
+
// that call took, and neither wants its status rewritten underneath it.
|
|
288
|
+
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
|
|
289
|
+
? // `filed` goes with the status it explained: cerber's account of why
|
|
290
|
+
// this row was settled is not the story of a row that is back.
|
|
291
|
+
{ ...a, status: reopenedStatus(a), settledAt: null, filed: null }
|
|
292
|
+
: a);
|
|
293
|
+
// The status is what says the write landed. `settledAt` cannot: a legacy
|
|
294
|
+
// row never had one, so a mutation that declined would read as a reopen.
|
|
295
|
+
if (saved && !SETTLED_BY_YOU.has(saved.status)) {
|
|
296
|
+
log(`[${artifact.id}] your review was requested again on ${at.slice(0, 10)} — back in the inbox`);
|
|
297
|
+
// The status change alone says a row moved; this says why it moved,
|
|
298
|
+
// which is the whole reason a settle is allowed to be undone at all.
|
|
299
|
+
await noteHistory(artifact.id, `back in the inbox: your review was requested again on ${at.slice(0, 10)}, after you settled it`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
186
302
|
for (const ref of refs) {
|
|
187
303
|
const existing = await loadArtifact(artifactId(ref));
|
|
188
304
|
if (existing) {
|
|
305
|
+
await reopenIfAskedAgain(existing);
|
|
189
306
|
// The search result is what the PR is right now; the artifact can
|
|
190
307
|
// predate a draft→ready flip. Refresh from it — it costs nothing, it's
|
|
191
308
|
// already in hand — or the queue keeps calling a ready PR a draft until
|
|
@@ -201,10 +318,6 @@ export function startDaemon(opts) {
|
|
|
201
318
|
await saveArtifact(stubArtifact(ref));
|
|
202
319
|
discovered.push(ref);
|
|
203
320
|
}
|
|
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
321
|
/**
|
|
209
322
|
* File a draft away once GitHub has moved past it without cerber's help.
|
|
210
323
|
*
|
|
@@ -240,7 +353,7 @@ export function startDaemon(opts) {
|
|
|
240
353
|
/** Re-reads from disk: a run or a send may have started in the seconds
|
|
241
354
|
* these reads took, and neither wants filing out from under it. */
|
|
242
355
|
async function file(filed, note, stillFilable) {
|
|
243
|
-
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed } : a);
|
|
356
|
+
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed, settledAt: filed.at } : a);
|
|
244
357
|
if (saved?.filed)
|
|
245
358
|
log(`[${artifact.id}] ${note} — filed as reviewed`);
|
|
246
359
|
}
|
|
@@ -265,15 +378,21 @@ export function startDaemon(opts) {
|
|
|
265
378
|
}
|
|
266
379
|
// Anything but silence from you leaves the row alone: `them` is somebody
|
|
267
380
|
// answering a comment of yours, which is the opposite of settled.
|
|
268
|
-
if (classifyReply(conversation, me) !== "none")
|
|
381
|
+
if (classifyReply(conversation, me) !== "none") {
|
|
382
|
+
await noteHistory(artifact.id, "someone has answered you on the PR — the draft stays out for you");
|
|
269
383
|
return;
|
|
384
|
+
}
|
|
270
385
|
if (!filedByWithdrawnRequest(artifact))
|
|
271
386
|
return;
|
|
272
387
|
// The awaiting search says nobody is asking, and here that is the entire
|
|
273
388
|
// case — so confirm it against the PR itself before acting on it. The
|
|
274
389
|
// other two reasons stand on a fact of their own and need no such check.
|
|
275
|
-
if (stillRequested(await fetchReviewRequests(artifact.pr), me))
|
|
390
|
+
if (stillRequested(await fetchReviewRequests(artifact.pr), me)) {
|
|
391
|
+
// The one fact GitHub cannot be asked for later: what its search index
|
|
392
|
+
// said at this minute, and that the PR itself disagreed with it.
|
|
393
|
+
await noteHistory(artifact.id, "gone from the awaiting search, but GitHub still lists you as a requested reviewer — left alone");
|
|
276
394
|
return;
|
|
395
|
+
}
|
|
277
396
|
await file({ at: new Date().toISOString(), reason: "request-withdrawn", review: null, reply: null }, "nobody is asking for this review any more", filedByWithdrawnRequest);
|
|
278
397
|
}
|
|
279
398
|
const awaitingIds = new Set(refs.map((r) => artifactId(r)));
|
|
@@ -434,7 +553,10 @@ export function startDaemon(opts) {
|
|
|
434
553
|
});
|
|
435
554
|
return { reviewed, skipped, failed };
|
|
436
555
|
}
|
|
437
|
-
|
|
556
|
+
/** Everything a poll writes is the poll's — including the reviews it starts,
|
|
557
|
+
* which stamp themselves as runs from inside this. */
|
|
558
|
+
const poll = () => withWriter({ by: "daemon", cause: "poll" }, runPoll);
|
|
559
|
+
async function runPoll() {
|
|
438
560
|
if (status.polling)
|
|
439
561
|
return;
|
|
440
562
|
status.polling = true;
|
package/dist/server/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { fetchPrDiff, fetchPrInfo, parsePrRef, submitReview } from "../core/gh.j
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
15
15
|
import { refreshArtifact } from "../core/refresh.js";
|
|
16
|
+
import { withWriter } from "../core/history.js";
|
|
16
17
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
17
18
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
18
19
|
import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
@@ -39,6 +40,11 @@ const SETTLEABLE = ["reviewed", "skipped"];
|
|
|
39
40
|
const inFlight = (a) => a.status === "running" || isReviewRunning(a.id);
|
|
40
41
|
export async function buildApp(opts) {
|
|
41
42
|
const app = new Hono();
|
|
43
|
+
// Whatever any route writes to an artifact is stamped with the request that
|
|
44
|
+
// caused it — once, here, rather than route by route, so a route added later
|
|
45
|
+
// is labelled without knowing that history exists. A detached run started by
|
|
46
|
+
// a request re-labels its own writes for itself.
|
|
47
|
+
app.use("*", (c, next) => withWriter({ by: "cockpit", cause: `${c.req.method} ${new URL(c.req.url).pathname}` }, next));
|
|
42
48
|
if (opts.token) {
|
|
43
49
|
const token = opts.token;
|
|
44
50
|
app.use("*", async (c, next) => {
|
|
@@ -270,6 +276,7 @@ export async function buildApp(opts) {
|
|
|
270
276
|
sent: null,
|
|
271
277
|
refresh: null,
|
|
272
278
|
filed: null,
|
|
279
|
+
settledAt: null,
|
|
273
280
|
calibration: null,
|
|
274
281
|
chat: [],
|
|
275
282
|
preChat: null,
|
|
@@ -311,6 +318,16 @@ export async function buildApp(opts) {
|
|
|
311
318
|
const next = { ...a };
|
|
312
319
|
if (body.status !== undefined) {
|
|
313
320
|
next.status = ArtifactStatusSchema.parse(body.status);
|
|
321
|
+
// Dated on the way in, because nothing else can date it later: the poll
|
|
322
|
+
// asks whether a review request came before or after this decision, and
|
|
323
|
+
// `updatedAt` moves every time the review is opened and refreshed.
|
|
324
|
+
next.settledAt = new Date().toISOString();
|
|
325
|
+
// And cerber's own account of why this row was settled goes with the
|
|
326
|
+
// status it explained. `filed` outranks the status wherever the queue
|
|
327
|
+
// tags a row (`rowTag`, `requestTag`), on the grounds that "reviewed"
|
|
328
|
+
// would otherwise read as a click nobody made — which stops being true
|
|
329
|
+
// the moment you click. Same rule the poll's reopen follows.
|
|
330
|
+
next.filed = null;
|
|
314
331
|
}
|
|
315
332
|
if (body.verdictRecommendation !== undefined && next.verdict) {
|
|
316
333
|
next.verdict = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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)",
|