@fullstackhouse/cerber 0.23.0 → 0.24.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.
@@ -15,7 +15,7 @@ import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/
15
15
  import { refreshArtifact } from "../core/refresh.js";
16
16
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
17
17
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
18
- import { listArtifacts, loadArtifact, loadArtifactByKey, reconcileRunning, saveArtifact, updateArtifactByKey, } from "../core/state.js";
18
+ import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
19
19
  import { isReviewRunning } from "../runner/inflight.js";
20
20
  import { reviewPr } from "../runner/review.js";
21
21
  import { runChatTurn } from "../runner/chat.js";
@@ -23,6 +23,20 @@ import { mergeConcurrentEdits, restoreReview } from "../core/revise.js";
23
23
  import { progressWriter } from "./progress.js";
24
24
  import { ChatTurnSchema } from "../core/artifact.js";
25
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ /**
27
+ * The statuses `PATCH /api/reviews/:key` will set: the two that are the user's
28
+ * own decision about a PR. Everything else is a fact some other path owns.
29
+ */
30
+ const SETTLEABLE = ["reviewed", "skipped"];
31
+ /**
32
+ * Is an AI run rewriting this draft right now?
33
+ *
34
+ * Two questions, because neither alone is enough: `isReviewRunning` is an
35
+ * in-memory claim and cannot see a `cerber review` going in another terminal,
36
+ * while the persisted status cannot see a run this process started moments ago
37
+ * that has not written yet.
38
+ */
39
+ const inFlight = (a) => a.status === "running" || isReviewRunning(a.id);
26
40
  export async function buildApp(opts) {
27
41
  const app = new Hono();
28
42
  if (opts.token) {
@@ -251,6 +265,7 @@ export async function buildApp(opts) {
251
265
  trusted: false,
252
266
  sessionId: null,
253
267
  trigger: "user",
268
+ reviewedSha: null,
254
269
  },
255
270
  sent: null,
256
271
  refresh: null,
@@ -284,6 +299,14 @@ export async function buildApp(opts) {
284
299
  // ---- Mutations (all local — artifact edits only) ----
285
300
  app.patch("/api/reviews/:key", async (c) => {
286
301
  const body = await c.req.json();
302
+ // Only the two statuses that are the user's own decision. The schema
303
+ // permits all seven, and taking them all meant this endpoint would happily
304
+ // write `status: "sent"` with no `sent` record and nothing submitted —
305
+ // a row that claims a review reached GitHub, which no honest path produces.
306
+ // `running`/`ready`/`failed` belong to the runner, `sent` to the send path.
307
+ if (body.status !== undefined && !SETTLEABLE.includes(body.status)) {
308
+ return c.json({ error: `status must be one of ${SETTLEABLE.join(", ")} — got ${body.status}` }, 400);
309
+ }
287
310
  const updated = await updateArtifactByKey(c.req.param("key"), (a) => {
288
311
  const next = { ...a };
289
312
  if (body.status !== undefined) {
@@ -429,6 +452,7 @@ export async function buildApp(opts) {
429
452
  trusted: false,
430
453
  sessionId: null,
431
454
  trigger: "user",
455
+ reviewedSha: null,
432
456
  },
433
457
  }));
434
458
  // Minutes-long: run it detached and let the cockpit poll the artifact.
@@ -589,6 +613,13 @@ export async function buildApp(opts) {
589
613
  if (artifact.sent) {
590
614
  return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
591
615
  }
616
+ // Not while a run is rewriting this draft. Vouching for a review that is
617
+ // being replaced under you is reason enough on its own; on top of that the
618
+ // run used to save `sent: null` back over the record when it landed, so the
619
+ // guard above would wave a *second* submission through afterwards.
620
+ if (inFlight(artifact)) {
621
+ return c.json({ error: "a review of this PR is running — wait for it to finish" }, 409);
622
+ }
592
623
  const payload = buildReviewPayload(artifact, event);
593
624
  try {
594
625
  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 });
@@ -628,11 +659,13 @@ export async function buildApp(opts) {
628
659
  }
629
660
  return app;
630
661
  }
662
+ /**
663
+ * Serve the cockpit. `reconcileRunning` is deliberately *not* called here: the
664
+ * caller must have done it before starting the daemon, because the daemon polls
665
+ * as soon as it is constructed. Doing it here as well would have marked that
666
+ * poll's own fresh run as an interrupted leftover.
667
+ */
631
668
  export async function startServer(opts) {
632
- const cleared = await reconcileRunning();
633
- if (cleared > 0) {
634
- console.log(`Cleared ${cleared} AI run(s) left in flight by a previous process — start them again from the cockpit.`);
635
- }
636
669
  const app = await buildApp(opts);
637
670
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
638
671
  const tokenHint = opts.token ? `/?token=${opts.token}` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.23.0",
3
+ "version": "0.24.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
  "author": "Full Stack House (https://fullstack.house)",