@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.
- package/README.md +145 -80
- package/dist/cli/index.js +57 -2
- package/dist/core/artifact.js +26 -0
- package/dist/core/config.js +4 -3
- package/dist/core/gh.js +7 -2
- package/dist/core/notify.js +104 -15
- package/dist/core/preflight.js +82 -0
- package/dist/core/refresh.js +2 -0
- package/dist/core/revise.js +3 -0
- package/dist/runner/review.js +31 -1
- package/dist/server/daemon.js +101 -22
- package/dist/server/index.js +49 -6
- package/package.json +13 -1
- package/web/dist/assets/{index-D0STDydg.js → index-LX8NvM54.js} +64 -64
- package/web/dist/index.html +1 -1
|
@@ -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
|
+
}
|
package/dist/core/refresh.js
CHANGED
|
@@ -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,
|
package/dist/core/revise.js
CHANGED
|
@@ -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 = {}) {
|
package/dist/runner/review.js
CHANGED
|
@@ -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
|
-
|
|
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.");
|
package/dist/server/daemon.js
CHANGED
|
@@ -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
|
|
559
|
+
* Tap this machine about every row that has become worth walking back to,
|
|
560
|
+
* once each.
|
|
557
561
|
*
|
|
558
|
-
*
|
|
559
|
-
* first
|
|
560
|
-
* that arrived while `serve` was down is
|
|
561
|
-
*
|
|
562
|
-
*
|
|
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(
|
|
565
|
-
const
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|