@fullstackhouse/cerber 0.29.0 → 0.30.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.
@@ -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,
@@ -538,6 +541,8 @@ export function startDaemon(opts) {
538
541
  }
539
542
  /** Said once, not every poll: a machine with no notifier will never grow one. */
540
543
  let notifierMissing = false;
544
+ /** Null until `serve` is listening and says so — see `cockpitAt`. */
545
+ let cockpitUrl = opts.cockpitUrl ?? null;
541
546
  /**
542
547
  * What the last attempt to tap this machine actually did, or null before one
543
548
  * has been made.
@@ -551,20 +556,48 @@ export function startDaemon(opts) {
551
556
  */
552
557
  let notifierWorks = null;
553
558
  /**
554
- * 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.
555
561
  *
556
- * An artifact is the ledger this rides on: a PR is announced on the poll that
557
- * first writes a stub for it, so a restart re-announces nothing, and a PR
558
- * that arrived while `serve` was down is still news the next time it runs.
559
- * The cockpit's bell announces the same arrival, off the same stub — which is
560
- * 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.
561
575
  */
562
- async function announce(arrivals) {
563
- const n = notice(arrivals);
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);
564
585
  if (!n)
565
586
  return;
566
- notifierWorks = await notify(n);
567
- 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)
568
601
  return;
569
602
  if (notifierMissing)
570
603
  return;
@@ -599,6 +632,49 @@ export function startDaemon(opts) {
599
632
  }
600
633
  });
601
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
+ }
602
678
  async function reviewAll(refs) {
603
679
  let reviewed = 0;
604
680
  let skipped = 0;
@@ -628,7 +704,9 @@ export function startDaemon(opts) {
628
704
  }
629
705
  failed++;
630
706
  status.errors++;
631
- 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}`);
632
710
  }
633
711
  });
634
712
  return { reviewed, skipped, failed };
@@ -677,16 +755,6 @@ export function startDaemon(opts) {
677
755
  // A failed poll leaves the last good list up; lastPollError says so.
678
756
  status.awaiting = await classifyAll(refs);
679
757
  const { discovered } = await syncQueue(refs);
680
- // Before the reviews, not after: the arrival is the news, and drafting it
681
- // takes minutes. This is the same moment the cockpit's bell would ring.
682
- if (notifyOn && discovered.length > 0) {
683
- await announce(discovered);
684
- // Recomputed on the spot rather than left to the next poll: that is
685
- // minutes away, and the cockpit would spend them deferring to a tap
686
- // that never came. Recomputed rather than only cleared, so a machine
687
- // whose notifier comes back takes the job back with it.
688
- status.notify = announcing();
689
- }
690
758
  if (autoReview) {
691
759
  const { reviewed, skipped, failed } = await reviewAll(refs);
692
760
  status.lastSummary =
@@ -696,6 +764,19 @@ export function startDaemon(opts) {
696
764
  else {
697
765
  status.lastSummary = `${refs.length} awaiting${discovered.length > 0 ? `, ${discovered.length} new` : ""} (auto-review off)`;
698
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();
699
780
  log(status.lastSummary);
700
781
  }
701
782
  catch (err) {
@@ -756,6 +837,9 @@ export function startDaemon(opts) {
756
837
  void poll();
757
838
  return {
758
839
  status: () => ({ ...status }),
840
+ cockpitAt: (url) => {
841
+ cockpitUrl = url;
842
+ },
759
843
  stop: () => {
760
844
  stopped = true;
761
845
  if (timer)
@@ -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
  }
@@ -690,6 +733,39 @@ export async function buildApp(opts) {
690
733
  }
691
734
  return app;
692
735
  }
736
+ /**
737
+ * The wildcard binds, and the loopback address each one answers on.
738
+ *
739
+ * Only these two are translated. A bind to `::1`, `localhost` or a particular
740
+ * interface is already an address that answers here, and rewriting it would
741
+ * point the click somewhere nothing is listening — `serve --host ::1` serves
742
+ * IPv6 loopback alone, where `127.0.0.1` does not connect at all. `::` maps to
743
+ * IPv6 loopback rather than IPv4, because a dual-stack socket is the only
744
+ * reason `127.0.0.1` would work on one and it is not guaranteed to be one.
745
+ */
746
+ const WILDCARD = new Map([
747
+ ["0.0.0.0", "127.0.0.1"],
748
+ ["::", "::1"],
749
+ ]);
750
+ /**
751
+ * The cockpit's own address, as reached from the machine `serve` runs on — what
752
+ * a desktop notification opens when you click it.
753
+ *
754
+ * It is the bound host only when that host is a particular interface; a
755
+ * wildcard bind is every interface, and the one that always answers from here
756
+ * is loopback. The token rides along because a click has to land on the review
757
+ * rather than on a 401, and it is the same token `serve` already prints to the
758
+ * console on startup. Null where there is no address to name yet — port 0 is
759
+ * whatever the OS picks, which isn't known until it has picked it.
760
+ */
761
+ export function cockpitUrl(opts) {
762
+ if (!Number.isInteger(opts.port) || opts.port <= 0)
763
+ return null;
764
+ const host = WILDCARD.get(opts.host) ?? opts.host;
765
+ const authority = host.includes(":") ? `[${host}]` : host;
766
+ const query = opts.token ? `?token=${encodeURIComponent(opts.token)}` : "";
767
+ return `http://${authority}:${opts.port}/${query}`;
768
+ }
693
769
  /**
694
770
  * Serve the cockpit. `reconcileRunning` is deliberately *not* called here: the
695
771
  * caller must have done it before starting the daemon, because the daemon polls
@@ -701,6 +777,9 @@ export async function startServer(opts) {
701
777
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
702
778
  const tokenHint = opts.token ? `/?token=${opts.token}` : "";
703
779
  console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
780
+ // The bound port, not the asked-for one: `--port 0` is the OS's to choose,
781
+ // and this callback is the first moment anyone knows what it chose.
782
+ opts.daemon?.cockpitAt(cockpitUrl({ ...opts, port: info.port }));
704
783
  if (opts.daemon) {
705
784
  const s = opts.daemon.status();
706
785
  console.log(`inbox: polling every ${Math.round(s.intervalMs / 60_000)}m` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.29.0",
3
+ "version": "0.30.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)",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "dist",
18
18
  "web/dist",
19
+ "assets",
19
20
  "README.md",
20
21
  "LICENSE"
21
22
  ],