@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.
@@ -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
- /** How many open-PR artifacts to re-check against GitHub per poll, and how often each. */
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
- async function poll() {
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;
@@ -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.24.1",
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)",