@fullstackhouse/cerber 0.34.0 → 0.35.1

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.
@@ -9,13 +9,15 @@ import { randomUUID } from "node:crypto";
9
9
  import { isPureStub } from "./daemon.js";
10
10
  import { ArtifactStatusSchema, SCHEMA_VERSION, SeveritySchema, VerdictSchema, artifactId, artifactKey, } from "../core/artifact.js";
11
11
  import { toMarkdown } from "../core/export.js";
12
- import { currentLogin, fetchAssignableUsers, fetchPrDiff, fetchPrInfo, handOffReview, parsePrRef, postIssueComment, submitReview, } from "../core/gh.js";
12
+ import { currentLogin, fetchAssignableUsers, fetchPrDiff, fetchPrInfo, handOffReview, parsePrRef, postIssueComment, postReplies, submitReview, } from "../core/gh.js";
13
13
  import { z } from "zod";
14
14
  import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
15
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
- import { buildReviewPayload, computeCalibration } from "../core/send.js";
18
+ import { buildReviewPayload, computeCalibration, describeReplyFailures, recordReplies, } from "../core/send.js";
19
+ import { raisedChanged, startRaisedCheck } from "../core/raised.js";
20
+ import { checkAlreadyRaised, claimRaisedCheck, finishRaisedCheck, isRaisedCheckRunning, prepareRaisedCheck, releaseRaisedCheck, } from "../runner/raised.js";
19
21
  import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
20
22
  import { isReviewRunning } from "../runner/inflight.js";
21
23
  import { reviewPr } from "../runner/review.js";
@@ -29,6 +31,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
31
  * own decision about a PR. Everything else is a fact some other path owns.
30
32
  */
31
33
  const SETTLEABLE = ["reviewed", "skipped"];
34
+ /** What the user may decide about a finding somebody else already raised. */
35
+ const RaisedDecisionSchema = z.enum(["send", "reply"]).nullable();
32
36
  /**
33
37
  * What a handoff needs: one person, a note to post, and a confirmation.
34
38
  *
@@ -65,6 +69,22 @@ const HandoffSchema = z.object({
65
69
  * that has not written yet.
66
70
  */
67
71
  const inFlight = (a) => a.status === "running" || isReviewRunning(a.id);
72
+ /**
73
+ * What to say when the in-flight claim is why a request was refused. The check
74
+ * for findings others already raised holds the claim too, for up to a minute,
75
+ * and "a review is running" would send the user looking for a run that isn't.
76
+ */
77
+ const busy = (a, otherwise) => isRaisedCheckRunning(a.id)
78
+ ? "cerber is checking which findings other reviewers already raised — give it a minute"
79
+ : otherwise;
80
+ /** Run a check for already-raised findings detached; its failures land on the artifact. */
81
+ function checkInBackground(artifact) {
82
+ return checkAlreadyRaised(artifact, {
83
+ log: (m) => console.log(`[raised ${artifact.id}] ${m}`),
84
+ }).catch((err) => {
85
+ console.error(`[raised ${artifact.id}] ${err instanceof Error ? err.message : String(err)}`);
86
+ });
87
+ }
68
88
  export async function buildApp(opts) {
69
89
  const app = new Hono();
70
90
  // Whatever any route writes to an artifact is stamped with the request that
@@ -111,7 +131,8 @@ export async function buildApp(opts) {
111
131
  }
112
132
  });
113
133
  // The inbox knobs. The daemon re-reads the config every poll, so a toggle
114
- // here applies on the next tick without restarting serve.
134
+ // here applies on the next tick without restarting serve — and what the
135
+ // status reports about them applies at once, since the cockpit reads it.
115
136
  app.post("/api/config/daemon", async (c) => {
116
137
  let body;
117
138
  try {
@@ -124,6 +145,7 @@ export async function buildApp(opts) {
124
145
  const config = await loadConfig();
125
146
  const daemon = DaemonConfigSchema.parse({ ...config.daemon, ...body });
126
147
  await saveConfig({ ...config, daemon });
148
+ opts.daemon?.reconfigure({ ...config, daemon });
127
149
  return c.json({ path: configPath(), trust: trustView(config.trust), daemon });
128
150
  }
129
151
  catch (err) {
@@ -166,6 +188,7 @@ export async function buildApp(opts) {
166
188
  });
167
189
  const trust = remove === true ? without : [...without, canonical];
168
190
  await saveConfig({ ...config, trust });
191
+ opts.daemon?.reconfigure({ ...config, trust });
169
192
  // Full ConfigView — the cockpit replaces its config state with this
170
193
  // wholesale, so omitting daemon would crash the Settings toggles.
171
194
  return c.json({ path: configPath(), trust: trustView(trust), daemon: config.daemon });
@@ -321,6 +344,7 @@ export async function buildApp(opts) {
321
344
  refresh: null,
322
345
  filed: null,
323
346
  handoff: null,
347
+ raisedCheck: null,
324
348
  settledAt: null,
325
349
  calibration: null,
326
350
  chat: [],
@@ -414,11 +438,35 @@ export async function buildApp(opts) {
414
438
  app.patch("/api/reviews/:key/comments/:id", async (c) => {
415
439
  const body = await c.req.json();
416
440
  const id = c.req.param("id");
441
+ // What Send does with a finding somebody else already raised: post it
442
+ // anyway, answer in their thread, or — null — the default for where they
443
+ // raised it. Refused rather than coerced when it cannot mean anything: a
444
+ // comment nobody else raised has no thread to answer, and neither does a
445
+ // review body.
446
+ const decision = RaisedDecisionSchema.safeParse(body.raisedDecision);
447
+ if (body.raisedDecision !== undefined) {
448
+ if (!decision.success) {
449
+ return c.json({ error: "raisedDecision must be \"send\", \"reply\", or null for the default" }, 400);
450
+ }
451
+ const artifact = await loadArtifactByKey(c.req.param("key"));
452
+ const target = artifact?.comments.find((cm) => cm.id === id);
453
+ if (!artifact || !target)
454
+ return c.json({ error: "not found" }, 404);
455
+ if (!target.alreadyRaised) {
456
+ return c.json({ error: "nobody else raised this comment, so there is nothing to decide" }, 409);
457
+ }
458
+ if (decision.data === "reply" && target.alreadyRaised.where.kind !== "thread") {
459
+ return c.json({ error: `@${target.alreadyRaised.by} raised it in a review body, which has no thread to reply in` }, 409);
460
+ }
461
+ }
417
462
  const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
418
463
  ...a,
419
464
  comments: a.comments.map((cm) => cm.id === id
420
465
  ? {
421
466
  ...cm,
467
+ ...(decision.success && body.raisedDecision !== undefined && cm.alreadyRaised
468
+ ? { alreadyRaised: { ...cm.alreadyRaised, decision: decision.data } }
469
+ : {}),
422
470
  ...(body.body !== undefined ? { body: String(body.body) } : {}),
423
471
  ...(body.severity !== undefined
424
472
  ? { severity: SeveritySchema.nullable().parse(body.severity) }
@@ -454,6 +502,7 @@ export async function buildApp(opts) {
454
502
  editedByUser: false,
455
503
  originalLine: null,
456
504
  drifted: false,
505
+ alreadyRaised: null,
457
506
  },
458
507
  ],
459
508
  }));
@@ -536,7 +585,7 @@ export async function buildApp(opts) {
536
585
  if (artifact.sent)
537
586
  return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
538
587
  if (isReviewRunning(artifact.id)) {
539
- return c.json({ error: "a review of this PR is already running" }, 409);
588
+ return c.json({ error: busy(artifact, "a review of this PR is already running") }, 409);
540
589
  }
541
590
  const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
542
591
  // Source-backed unless the server was started with --no-source; ?source=1
@@ -600,7 +649,7 @@ export async function buildApp(opts) {
600
649
  if (artifact.sent)
601
650
  return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
602
651
  if (isReviewRunning(artifact.id) || turnInFlight(artifact)) {
603
- return c.json({ error: "a run for this PR is already in flight" }, 409);
652
+ return c.json({ error: busy(artifact, "a run for this PR is already in flight") }, 409);
604
653
  }
605
654
  let request;
606
655
  try {
@@ -644,10 +693,16 @@ export async function buildApp(opts) {
644
693
  // The cockpit stays live throughout, so fold the result onto whatever
645
694
  // is on disk now rather than overwriting it — an edit the user made
646
695
  // while waiting must not vanish when the answer lands.
647
- await updateArtifactByKey(key, (current) => ({
648
- ...mergeConcurrentEdits(artifact, result.artifact, current),
649
- pendingChat: null,
650
- }));
696
+ // A finding the turn added is compared with what others said, like
697
+ // the review's own were. Marked in the same write that clears the
698
+ // turn, so the cockpit's poll has no gap in which nothing is pending.
699
+ const added = result.artifact.comments.some((cm) => !artifact.comments.some((before) => before.id === cm.id));
700
+ const landed = await updateArtifactByKey(key, (current) => {
701
+ const merged = { ...mergeConcurrentEdits(artifact, result.artifact, current), pendingChat: null };
702
+ return added ? startRaisedCheck(merged, new Date().toISOString()) : merged;
703
+ });
704
+ if (added && landed)
705
+ void checkInBackground(landed);
651
706
  })
652
707
  .catch(async (err) => {
653
708
  const message = err instanceof Error ? err.message : String(err);
@@ -724,22 +779,86 @@ export async function buildApp(opts) {
724
779
  // run used to save `sent: null` back over the record when it landed, so the
725
780
  // guard above would wave a *second* submission through afterwards.
726
781
  if (inFlight(artifact)) {
727
- return c.json({ error: "a review of this PR is running — wait for it to finish" }, 409);
782
+ return c.json({ error: busy(artifact, "a review of this PR is running — wait for it to finish") }, 409);
728
783
  }
729
784
  const payload = buildReviewPayload(artifact, event);
785
+ const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
786
+ let url;
730
787
  try {
731
- 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 });
732
- const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
733
- ...a,
734
- status: "sent",
735
- sent: { at: new Date().toISOString(), event: payload.event, url, auto: false },
736
- calibration: computeCalibration(a, payload.event),
788
+ ({ url } = await submitReview(ref, {
789
+ event: payload.event,
790
+ body: payload.body,
791
+ comments: payload.comments,
792
+ commitId: payload.commitId,
737
793
  }));
738
- return c.json(updated);
739
794
  }
740
795
  catch (err) {
741
796
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
742
797
  }
798
+ // Recorded before the replies go: the review is up, and a reply failing
799
+ // must not leave the row claiming it isn't — the "already sent" guard is
800
+ // what keeps a retry from posting the review twice.
801
+ await updateArtifactByKey(c.req.param("key"), (a) => ({
802
+ ...a,
803
+ status: "sent",
804
+ sent: { at: new Date().toISOString(), event: payload.event, url, auto: false },
805
+ calibration: computeCalibration(a, payload.event),
806
+ }));
807
+ const outcomes = await postReplies(ref, payload.replies);
808
+ const updated = await updateArtifactByKey(c.req.param("key"), (a) => recordReplies(a, outcomes));
809
+ const replyError = describeReplyFailures(outcomes);
810
+ if (replyError)
811
+ console.error(`[send ${artifact.id}] ${replyError}`);
812
+ return c.json({ ...updated, replyError });
813
+ });
814
+ // ---- Already raised: compare the draft with what others said on the PR ----
815
+ // Read-only against GitHub. Detached, like a chat turn: the cockpit polls
816
+ // `raisedCheck.checkingSince` until it clears.
817
+ app.post("/api/reviews/:key/raised", async (c) => {
818
+ const key = c.req.param("key");
819
+ const artifact = await loadArtifactByKey(key);
820
+ if (!artifact)
821
+ return c.json({ error: "not found" }, 404);
822
+ // A sent review is history, and a running one checks for itself when it lands.
823
+ if (artifact.sent)
824
+ return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
825
+ // Before the in-flight guard, which a running check also trips: a second
826
+ // open while one is out is answered with what is on disk, not refused.
827
+ if (isRaisedCheckRunning(artifact.id))
828
+ return c.json(artifact, 202);
829
+ if (inFlight(artifact) || turnInFlight(artifact) || !claimRaisedCheck(artifact.id)) {
830
+ return c.json({ error: "a run for this PR is in flight — it checks when it lands" }, 409);
831
+ }
832
+ // The claim is held from here, across the GitHub read as well as any model
833
+ // call: Send waits on it, and a Send landing mid-read would post the
834
+ // duplicate the read was about to find. It is in memory, so a check that
835
+ // finds nothing new still writes nothing.
836
+ let detached = false;
837
+ try {
838
+ const step = await prepareRaisedCheck(artifact, { log: (m) => console.log(`[raised ${artifact.id}] ${m}`) });
839
+ if (!step.needsModel) {
840
+ const current = await loadArtifactByKey(key);
841
+ if (!current)
842
+ return c.json({ error: "not found" }, 404);
843
+ // Nothing new is no write: `updatedAt` is what the queue sorts and ages
844
+ // rows by, and merely opening a review must not move it.
845
+ if (!raisedChanged(current, step.fold(current)))
846
+ return c.json(current);
847
+ return c.json(await updateArtifactByKey(key, step.fold));
848
+ }
849
+ const marked = await updateArtifactByKey(key, (a) => startRaisedCheck(a, new Date().toISOString()));
850
+ if (!marked)
851
+ return c.json({ error: "not found" }, 404);
852
+ detached = true;
853
+ void finishRaisedCheck(marked, step).catch((err) => {
854
+ console.error(`[raised ${artifact.id}] ${err instanceof Error ? err.message : String(err)}`);
855
+ });
856
+ return c.json(marked, 202);
857
+ }
858
+ finally {
859
+ if (!detached)
860
+ releaseRaisedCheck(artifact.id);
861
+ }
743
862
  });
744
863
  // Preview what send would post, without posting anything.
745
864
  app.get("/api/reviews/:key/send-preview", async (c) => {
@@ -812,7 +931,7 @@ export async function buildApp(opts) {
812
931
  // settling one out from under a run that is about to report on it is the
813
932
  // same race the send path refuses.
814
933
  if (inFlight(artifact)) {
815
- return c.json({ error: "a review of this PR is running — wait for it to finish" }, 409);
934
+ return c.json({ error: busy(artifact, "a review of this PR is running — wait for it to finish") }, 409);
816
935
  }
817
936
  // Nor while a chat turn is being answered. A turn's result is folded onto
818
937
  // whatever the artifact says when it lands, and the fold is written for a
@@ -889,6 +1008,12 @@ export async function buildApp(opts) {
889
1008
  console.error(`[handoff ${artifact.id}] the review did not post: ${sendError}`);
890
1009
  }
891
1010
  }
1011
+ // The draft carries its replies too, when it went: they are part of the
1012
+ // composition the box sends, and only ever ones the user chose to make.
1013
+ const replies = sent && payload ? await postReplies(ref, payload.replies) : [];
1014
+ const replyError = describeReplyFailures(replies);
1015
+ if (replyError)
1016
+ console.error(`[handoff ${artifact.id}] ${replyError}`);
892
1017
  let noteError = null;
893
1018
  let posted = null;
894
1019
  if (note) {
@@ -909,7 +1034,7 @@ export async function buildApp(opts) {
909
1034
  // deciding not to review this one, which is a skip.
910
1035
  const settles = !sent && !a.sent;
911
1036
  return {
912
- ...a,
1037
+ ...recordReplies(a, replies),
913
1038
  status: sent ? "sent" : a.sent ? a.status : "skipped",
914
1039
  settledAt: settles ? at : a.settledAt,
915
1040
  // Cerber's account of why a row is filed away cannot stand next to your
@@ -930,7 +1055,7 @@ export async function buildApp(opts) {
930
1055
  });
931
1056
  if (!updated)
932
1057
  return c.json({ error: "not found" }, 404);
933
- return c.json({ artifact: updated, noteError, sendError });
1058
+ return c.json({ artifact: updated, noteError, sendError, replyError });
934
1059
  });
935
1060
  // Static cockpit build. In the published package web/dist ships alongside dist/.
936
1061
  // __dirname is src/server (dev via tsx) or dist/server (built) — both are two levels below the repo root.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.34.0",
3
+ "version": "0.35.1",
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
  "keywords": [
@@ -41,7 +41,7 @@
41
41
  "build": "tsc -p tsconfig.build.json && vite build --config web/vite.config.ts",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit",
43
43
  "test": "vitest run",
44
- "prepublishOnly": "pnpm build"
44
+ "prepublishOnly": "node scripts/refuse-sentinel-publish.mjs && pnpm build"
45
45
  },
46
46
  "dependencies": {
47
47
  "@hono/node-server": "^1.13.7",
@@ -51,7 +51,6 @@
51
51
  "zod": "^3.24.1"
52
52
  },
53
53
  "devDependencies": {
54
- "@semantic-release/git": "^11.0.1",
55
54
  "@types/node": "^22.10.5",
56
55
  "@types/react": "^19.0.4",
57
56
  "@types/react-dom": "^19.0.2",
@@ -65,6 +64,7 @@
65
64
  "tsx": "^4.19.2",
66
65
  "typescript": "^5.7.3",
67
66
  "vite": "^6.0.7",
68
- "vitest": "^2.1.8"
67
+ "vitest": "^2.1.8",
68
+ "yaml": "^2.9.1"
69
69
  }
70
70
  }