@fullstackhouse/cerber 0.29.1 → 0.31.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.
@@ -0,0 +1,82 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ export function isBlocking(check) {
5
+ return check.status !== "ok";
6
+ }
7
+ async function run(bin, args) {
8
+ try {
9
+ const { stdout, stderr } = await execFileAsync(bin, args);
10
+ return { ok: true, out: (stdout || stderr).trim() };
11
+ }
12
+ catch (err) {
13
+ const e = err;
14
+ // ENOENT is "not installed"; a non-zero exit is the tool answering "no".
15
+ if (e.code === "ENOENT")
16
+ return { ok: false, out: "ENOENT" };
17
+ return { ok: false, out: (e.stderr || e.stdout || e.message || "").trim() };
18
+ }
19
+ }
20
+ export async function checkGh() {
21
+ const version = await run("gh", ["--version"]);
22
+ if (!version.ok && version.out === "ENOENT") {
23
+ return {
24
+ name: "gh (GitHub CLI)",
25
+ status: "missing",
26
+ fix: "Install it: https://cli.github.com — then run `gh auth login`.",
27
+ };
28
+ }
29
+ // `gh auth status` exits non-zero when no account is logged in, which is the
30
+ // case that otherwise surfaces as an empty cockpit.
31
+ const auth = await run("gh", ["auth", "status"]);
32
+ if (!auth.ok) {
33
+ return {
34
+ name: "gh (GitHub CLI)",
35
+ status: "unauthenticated",
36
+ fix: "Run `gh auth login` — cerber reads PRs as you, and sends as you.",
37
+ };
38
+ }
39
+ return { name: "gh (GitHub CLI)", status: "ok", detail: version.out.split("\n")[0] };
40
+ }
41
+ export async function checkClaude() {
42
+ const version = await run("claude", ["--version"]);
43
+ if (!version.ok && version.out === "ENOENT") {
44
+ return {
45
+ name: "claude (Claude Code)",
46
+ status: "missing",
47
+ fix: "Install it: https://claude.com/claude-code — cerber rides your login, there is no API-key path.",
48
+ };
49
+ }
50
+ if (!version.ok) {
51
+ return {
52
+ name: "claude (Claude Code)",
53
+ status: "unauthenticated",
54
+ fix: "Run `claude` once and log in — a review is drafted through your Claude Code session.",
55
+ };
56
+ }
57
+ return { name: "claude (Claude Code)", status: "ok", detail: version.out.split("\n")[0] };
58
+ }
59
+ export async function checkGit() {
60
+ const version = await run("git", ["--version"]);
61
+ if (!version.ok) {
62
+ return {
63
+ name: "git",
64
+ status: "missing",
65
+ fix: "Install git — cerber fetches each PR head into ~/.cerber/src to review the code, not just the diff.",
66
+ };
67
+ }
68
+ return { name: "git", status: "ok", detail: version.out.split("\n")[0] };
69
+ }
70
+ /** Everything cerber shells out to, checked in parallel. */
71
+ export async function preflight() {
72
+ return Promise.all([checkGh(), checkClaude(), checkGit()]);
73
+ }
74
+ /** The blocking checks, formatted as the message printed instead of failing later. */
75
+ export function formatBlockers(checks) {
76
+ const bad = checks.filter(isBlocking);
77
+ const lines = bad.map((c) => {
78
+ const what = c.status === "missing" ? "not installed" : "not logged in";
79
+ return ` ✗ ${c.name} — ${what}\n ${c.fix ?? ""}`.trimEnd();
80
+ });
81
+ return `cerber needs these before it can do anything:\n\n${lines.join("\n")}\n\nRun \`cerber doctor\` to re-check.`;
82
+ }
@@ -76,6 +76,8 @@ export function mergeRunResult(fresh, current) {
76
76
  // the stamp away with the status.
77
77
  settledAt: userOwnsStatus(current) ? current.settledAt : fresh.settledAt,
78
78
  sent: current.sent,
79
+ // Disk wins: the ledger is the poll's, and the run knows nothing about it.
80
+ notified: current.notified,
79
81
  calibration: current.calibration,
80
82
  filed: current.filed,
81
83
  chat: current.chat,
@@ -63,6 +63,9 @@ export function mergeConcurrentEdits(before, after, current) {
63
63
  // A turn never writes a send body, so one written while it ran is the
64
64
  // user's and stands — as does clearing one.
65
65
  bodyOverride: current.bodyOverride,
66
+ // Nor does a turn announce anything: the ledger is the poll's, and a turn
67
+ // takes minutes, which is long enough for a poll to have stamped it.
68
+ notified: current.notified,
66
69
  };
67
70
  }
68
71
  export function applyRevisions(artifact, revisions, opts = {}) {
@@ -162,6 +162,13 @@ async function performReview(ref, pr, existing, opts, log) {
162
162
  status: "running",
163
163
  createdAt: existing?.createdAt ?? now(),
164
164
  updatedAt: now(),
165
+ // The announcement ledger belongs to the row, not to the run: a PR the poll
166
+ // found and has not told you about yet is still owed that tap when this
167
+ // draft lands (`notified` in artifact.ts). Seeded from the row this run
168
+ // starts on, and re-read from disk on the way out of both writes below —
169
+ // `existing` was loaded minutes ago, and a poll may have announced the row
170
+ // since.
171
+ notified: existing?.notified,
165
172
  pr,
166
173
  diff,
167
174
  summary: "",
@@ -196,7 +203,30 @@ async function performReview(ref, pr, existing, opts, log) {
196
203
  preChat: null,
197
204
  pendingChat: null,
198
205
  };
199
- await saveArtifact(artifact);
206
+ // Not a plain save. The fetch and checkout above take minutes, and the row on
207
+ // disk may have moved twice over in that window:
208
+ //
209
+ // - a poll may have announced it, writing a ledger entry `existing` cannot
210
+ // know about. Writing the stale one back would spend the tap twice.
211
+ // - the user may have settled or sent it, which is a decision this run does
212
+ // not get to overwrite by marking the row `running`. The run goes on and
213
+ // folds its draft underneath at the end (`mergeRunResult`) — the same
214
+ // line that function already holds, now held by the claim as well.
215
+ //
216
+ // Which settle it is matters, and `existing` is what tells them apart: a row
217
+ // already settled when this run *started* is one you took back deliberately
218
+ // (the guard above only lets a forced re-review that far), and claiming it is
219
+ // how the reopen happens at all. A settle that appeared since is the one this
220
+ // must not touch.
221
+ const decided = existing ? userOwnsStatus(existing) : false;
222
+ const claimed = await updateArtifactByKey(artifactKey(artifact.id), (current) => !decided && userOwnsStatus(current) ? current : { ...artifact, notified: current.notified });
223
+ const held = claimed !== null && !decided && userOwnsStatus(claimed);
224
+ if (!held) {
225
+ if (claimed)
226
+ artifact = claimed;
227
+ else
228
+ await saveArtifact(artifact);
229
+ }
200
230
  const { prompt, truncated } = buildReviewPrompt(pr, diff, { source: source !== null, trusted });
201
231
  if (truncated)
202
232
  log("Warning: diff exceeds the context budget and was truncated.");
@@ -3,7 +3,7 @@ import { evaluateAutoSend } from "../core/autosend.js";
3
3
  import { loadConfig } from "../core/config.js";
4
4
  import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastMentionOfYou, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
5
5
  import { withWriter } from "../core/history.js";
6
- import { notice, notify } from "../core/notify.js";
6
+ import { pendingNews, notice, notify } from "../core/notify.js";
7
7
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
8
8
  import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, noteHistory, saveArtifact, updateArtifactByKey, } from "../core/state.js";
9
9
  import { ReviewInProgressError } from "../runner/inflight.js";
@@ -48,6 +48,9 @@ export function stubArtifact(ref) {
48
48
  refresh: null,
49
49
  filed: null,
50
50
  settledAt: null,
51
+ // Null, not absent: the poll found this PR, so it owes the machine a tap
52
+ // for it — once there is something to tap you about (§9.8).
53
+ notified: null,
51
54
  calibration: null,
52
55
  chat: [],
53
56
  preChat: null,
@@ -553,20 +556,48 @@ export function startDaemon(opts) {
553
556
  */
554
557
  let notifierWorks = null;
555
558
  /**
556
- * Tap this machine when a PR lands in the queue.
559
+ * Tap this machine about every row that has become worth walking back to,
560
+ * once each.
557
561
  *
558
- * An artifact is the ledger this rides on: a PR is announced on the poll that
559
- * first writes a stub for it, so a restart re-announces nothing, and a PR
560
- * that arrived while `serve` was down is still news the next time it runs.
561
- * The cockpit's bell announces the same arrival, off the same stub — which is
562
- * why it stands down when this is on and both are on one machine.
562
+ * `notified` on the artifact is the ledger this rides on: the poll writes it
563
+ * null when it first stubs a PR ("owed a tap"), and stamps it here. So a
564
+ * restart re-announces nothing, a PR that arrived while `serve` was down is
565
+ * still news the next time it runs — and, unlike announcing straight off the
566
+ * discovery, a tap the daemon is holding back survives the wait. The cockpit's
567
+ * bell works off the same rule (`isNews`), which is why it stands down when
568
+ * this is on and both are on one machine.
569
+ *
570
+ * Stamped whether or not the notifier worked, and whether or not it was even
571
+ * asked (`tell` is the notify toggle). A machine with no notifier would
572
+ * otherwise re-try every row every poll, and a toggle switched on after a
573
+ * quiet week would announce that whole week at once — the same reason the
574
+ * browser bell records what it stays quiet about.
563
575
  */
564
- async function announce(arrivals) {
565
- const n = notice(arrivals, cockpitUrl);
576
+ async function announce(autoReview, tell) {
577
+ const owed = (await listArtifacts()).flatMap((a) => {
578
+ const news = pendingNews(a, autoReview);
579
+ return news ? [{ id: a.id, news }] : [];
580
+ });
581
+ // `cockpitUrl` is where a click lands, and it is read here rather than
582
+ // captured: `serve` hands it over once its port is bound, which can be
583
+ // after the first poll has already started.
584
+ const n = notice(owed.map((o) => o.news), cockpitUrl);
566
585
  if (!n)
567
586
  return;
568
- notifierWorks = await notify(n);
569
- if (notifierWorks)
587
+ if (tell)
588
+ notifierWorks = await notify(n);
589
+ const at = new Date().toISOString();
590
+ // Stamped after the tap, not before: a crash in between costs a repeated
591
+ // notification, and the other order costs the only one there was going
592
+ // to be. What was said is stamped along with it, because "told you it
593
+ // failed" and "told you about the draft" are different news.
594
+ for (const { id, news } of owed) {
595
+ await updateArtifactByKey(artifactKey(id), (current) =>
596
+ // Absent means the row was replaced mid-poll by one nobody meant to
597
+ // announce (a hand-pulled review) — don't re-open a ledger for it.
598
+ current.notified === undefined ? current : { ...current, notified: { at, drafted: Boolean(news.draft) } });
599
+ }
600
+ if (!tell || notifierWorks)
570
601
  return;
571
602
  if (notifierMissing)
572
603
  return;
@@ -601,6 +632,49 @@ export function startDaemon(opts) {
601
632
  }
602
633
  });
603
634
  }
635
+ /**
636
+ * Write a run's failure onto the row when the runner never got far enough to
637
+ * do it itself.
638
+ *
639
+ * A review that falls over before it owns the artifact — the PR read, the
640
+ * diff fetch — leaves the row sitting at `awaiting`, which reads as "a draft
641
+ * is coming" to everything downstream: the tap waits for one that will never
642
+ * be written, and the cockpit's own bell, which has only the row to go on,
643
+ * waits forever. Recording it is what keeps the two halves telling one story;
644
+ * the alternative, a set of ids the poll passes to its own notifier, is a
645
+ * fact the daemon knows and nobody else can see.
646
+ *
647
+ * Only a row still sitting at `awaiting` with nothing in flight on it is
648
+ * rewritten. A settle that landed while the run worked is a decision, and a
649
+ * failure is no reason to overrule it — the same line `mergeRunResult` holds.
650
+ * The run test is "not in flight" rather than "absent" because a row that was
651
+ * settled and then reopened keeps the run it had (`reopenedStatus`): reading
652
+ * that leftover as a live run would refuse to record the new failure, and the
653
+ * row would sit at `awaiting` forever waiting on a draft nobody is writing —
654
+ * the exact silence this function exists to break.
655
+ */
656
+ async function recordEarlyFailure(id, error) {
657
+ await updateArtifactByKey(artifactKey(id), (a) => a.status === "awaiting" && (a.run === null || a.run.finishedAt !== null)
658
+ ? {
659
+ ...a,
660
+ status: "failed",
661
+ run: {
662
+ model: opts.model ?? null,
663
+ startedAt: new Date().toISOString(),
664
+ finishedAt: new Date().toISOString(),
665
+ costUsd: null,
666
+ error,
667
+ withSource: opts.withSource !== false,
668
+ trusted: false,
669
+ sessionId: null,
670
+ trigger: "daemon",
671
+ reviewedSha: null,
672
+ },
673
+ }
674
+ : a).catch(() => {
675
+ // Book-keeping: a row we could not write is one the next poll retries.
676
+ });
677
+ }
604
678
  async function reviewAll(refs) {
605
679
  let reviewed = 0;
606
680
  let skipped = 0;
@@ -630,7 +704,9 @@ export function startDaemon(opts) {
630
704
  }
631
705
  failed++;
632
706
  status.errors++;
633
- log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
707
+ const message = err instanceof Error ? err.message : String(err);
708
+ await recordEarlyFailure(label, message);
709
+ log(`[${label}] failed: ${message}`);
634
710
  }
635
711
  });
636
712
  return { reviewed, skipped, failed };
@@ -679,16 +755,6 @@ export function startDaemon(opts) {
679
755
  // A failed poll leaves the last good list up; lastPollError says so.
680
756
  status.awaiting = await classifyAll(refs);
681
757
  const { discovered } = await syncQueue(refs);
682
- // Before the reviews, not after: the arrival is the news, and drafting it
683
- // takes minutes. This is the same moment the cockpit's bell would ring.
684
- if (notifyOn && discovered.length > 0) {
685
- await announce(discovered);
686
- // Recomputed on the spot rather than left to the next poll: that is
687
- // minutes away, and the cockpit would spend them deferring to a tap
688
- // that never came. Recomputed rather than only cleared, so a machine
689
- // whose notifier comes back takes the job back with it.
690
- status.notify = announcing();
691
- }
692
758
  if (autoReview) {
693
759
  const { reviewed, skipped, failed } = await reviewAll(refs);
694
760
  status.lastSummary =
@@ -698,6 +764,19 @@ export function startDaemon(opts) {
698
764
  else {
699
765
  status.lastSummary = `${refs.length} awaiting${discovered.length > 0 ? `, ${discovered.length} new` : ""} (auto-review off)`;
700
766
  }
767
+ // After the drafting, not before it. The arrival is not the news when
768
+ // cerber is about to write the review itself: a tap that lands on "no run
769
+ // yet" makes the walk back to the cockpit for nothing, and by the time
770
+ // there *is* something to read the PR is no longer new. So the tap waits
771
+ // for the draft — and for a PR nobody is drafting (auto-review off, or a
772
+ // run that failed) it goes out here all the same. `isNews` is the rule,
773
+ // and the cockpit's bell reads the same one.
774
+ await announce(autoReview, notifyOn);
775
+ // Recomputed on the spot rather than left to the next poll: that is
776
+ // minutes away, and the cockpit would spend them deferring to a tap that
777
+ // never came. Recomputed rather than only cleared, so a machine whose
778
+ // notifier comes back takes the job back with it.
779
+ status.notify = announcing();
701
780
  log(status.lastSummary);
702
781
  }
703
782
  catch (err) {
@@ -12,7 +12,7 @@ import { toMarkdown } from "../core/export.js";
12
12
  import { fetchPrDiff, fetchPrInfo, parsePrRef, submitReview } from "../core/gh.js";
13
13
  import { z } from "zod";
14
14
  import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
15
- import { refreshArtifact } from "../core/refresh.js";
15
+ import { refreshArtifact, userOwnsStatus } from "../core/refresh.js";
16
16
  import { withWriter } from "../core/history.js";
17
17
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
18
18
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
@@ -180,6 +180,12 @@ export async function buildApp(opts) {
180
180
  blockerCount: a.comments.filter((cm) => cm.status !== "dropped" && cm.severity === "blocker")
181
181
  .length,
182
182
  gradedCount: a.comments.filter((cm) => cm.severity != null).length,
183
+ // Whether the machine ever meant to announce this row (§9.8's ledger:
184
+ // absent means nobody did — a review pulled in by hand). The browser
185
+ // bell has only the list to go on, so without this it would announce
186
+ // rows the daemon deliberately stays silent about, and the two bells
187
+ // would stop telling one story.
188
+ announceable: a.notified !== undefined,
183
189
  costUsd: a.run?.costUsd ?? null,
184
190
  withSource: a.run?.withSource ?? null,
185
191
  trusted: a.run?.trusted ?? null,
@@ -255,6 +261,12 @@ export async function buildApp(opts) {
255
261
  status: "running",
256
262
  createdAt: now,
257
263
  updatedAt: now,
264
+ // Kept from the stub this replaces, when there is one: the poll found
265
+ // that PR and owes the machine a tap for it once the draft lands. Absent
266
+ // on a PR pasted into the cockpit — nobody asked to be told about that
267
+ // one, and it is on screen already. Re-read from disk at the write below,
268
+ // since `fetchPrInfo` gave a poll time to announce the stub.
269
+ notified: existing?.notified,
258
270
  pr,
259
271
  diff: "",
260
272
  summary: "",
@@ -283,7 +295,19 @@ export async function buildApp(opts) {
283
295
  preChat: null,
284
296
  pendingChat: null,
285
297
  };
286
- await saveArtifact(artifact);
298
+ // A settle or a send that landed while `fetchPrInfo` was in flight is a
299
+ // decision, and claiming the row for a run would erase it — the detached
300
+ // run below folds its draft under whatever the row says instead
301
+ // (`mergeRunResult`), which is the same line every other writer holds.
302
+ const claimed = await updateArtifactByKey(artifactKey(artifact.id), (current) => userOwnsStatus(current) ? current : { ...artifact, notified: current.notified });
303
+ // Declining the claim is only half the job: the run below forces, and a
304
+ // forced run reopens a settled row on purpose. So the decision ends the
305
+ // request — hand it back and start nothing.
306
+ if (claimed && userOwnsStatus(claimed)) {
307
+ return c.json({ ...claimed, key: artifactKey(claimed.id) }, 200);
308
+ }
309
+ if (!claimed)
310
+ await saveArtifact(artifact);
287
311
  void reviewPr(ref, {
288
312
  force: true,
289
313
  withSource,
@@ -438,13 +462,32 @@ export async function buildApp(opts) {
438
462
  try {
439
463
  const diff = await fetchPrDiff(ref);
440
464
  const result = refreshArtifact(artifact, pr, diff);
441
- const saved = await updateArtifactByKey(c.req.param("key"), () => result.artifact);
465
+ // `result` was built from a snapshot taken before the PR read and the
466
+ // diff fetch, so it is written back only onto a row that is still the one
467
+ // it describes. The guard above asked the same question minutes ago; a
468
+ // settle, a send or a run that started since would be undone by writing
469
+ // this over them, and the ledger is the poll's in either case.
470
+ // Taken over, in any of the three ways that matter: a decision was made,
471
+ // a run owns the row now, or it simply is not the row this refresh was
472
+ // computed from any more. The last test is what catches a run that both
473
+ // started *and* finished inside this handler's own fetches — it leaves
474
+ // `ready`, which neither of the other two tests would refuse, and writing
475
+ // the pre-fetch snapshot over it would drop a whole finished draft.
476
+ // `updatedAt` is the version: every write through the store bumps it.
477
+ const taken = (a) => userOwnsStatus(a) || a.status === "running" || a.updatedAt !== artifact.updatedAt;
478
+ // Read inside the mutation rather than from its result: the store stamps
479
+ // `updatedAt` on the way out, so the saved row always looks "moved".
480
+ let applied = false;
481
+ const saved = await updateArtifactByKey(c.req.param("key"), (current) => {
482
+ applied = !taken(current);
483
+ return applied ? { ...result.artifact, notified: current.notified } : current;
484
+ });
442
485
  return c.json({
443
486
  stale: true,
444
- changed: result.changed,
487
+ changed: applied && result.changed,
445
488
  prState: pr.state,
446
- moved: result.moved,
447
- drifted: result.drifted,
489
+ moved: applied ? result.moved : 0,
490
+ drifted: applied ? result.drifted : 0,
448
491
  artifact: saved,
449
492
  });
450
493
  }
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.29.1",
3
+ "version": "0.31.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
+ "keywords": [
7
+ "code-review",
8
+ "pull-request",
9
+ "ai",
10
+ "claude",
11
+ "claude-code",
12
+ "github",
13
+ "cli",
14
+ "local-first",
15
+ "developer-tools",
16
+ "human-in-the-loop"
17
+ ],
6
18
  "author": "Full Stack House (https://fullstack.house)",
7
19
  "repository": "github:fullstackhouse/cerber",
8
20
  "type": "module",