@fullstackhouse/cerber 0.29.1 → 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.
- package/README.md +14 -7
- package/dist/cli/index.js +1 -1
- package/dist/core/artifact.js +26 -0
- package/dist/core/config.js +4 -3
- package/dist/core/notify.js +104 -15
- 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 +1 -1
- package/web/dist/assets/{index-D0STDydg.js → index-LX8NvM54.js} +64 -64
- package/web/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -88,13 +88,20 @@ who did is the one mistake worth designing against.
|
|
|
88
88
|
|
|
89
89
|
### It taps you on the shoulder
|
|
90
90
|
|
|
91
|
-
When a PR
|
|
92
|
-
into one popup when several
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
When a PR is ready for you, you get a desktop notification naming it, folded
|
|
92
|
+
into one popup when several land at once. Ready means *there is something to
|
|
93
|
+
read*: cerber drafts the review itself, so the tap waits for the draft and says
|
|
94
|
+
what it found — "widgets#7 draft ready · requests changes · 2 blockers" — rather
|
|
95
|
+
than walking you back to a row that says "no run yet". A PR nobody is going to
|
|
96
|
+
draft (auto-review off, or a run that failed) is announced as it arrives, since
|
|
97
|
+
that is all the news there will be — and if a retry drafts it after all, that is
|
|
98
|
+
new news and you are told. Nothing else is: a PR whose draft you have been told
|
|
99
|
+
about stays quiet however its next re-review ends. The tap comes from the poll, so it reaches
|
|
100
|
+
you with the cockpit closed, in another Space, or after a browser restart —
|
|
101
|
+
`cerber serve` is the only thing that has to be running. It goes to whatever
|
|
102
|
+
this machine already has (Notification Centre on macOS, `notify-send` on Linux)
|
|
103
|
+
and stays quiet where there is neither. Off with `daemon.notify` in
|
|
104
|
+
`~/.cerber/config.json`, or the checkbox in Settings.
|
|
98
105
|
|
|
99
106
|
**On macOS, clicking it opens that review** in the cockpit. It takes a small app
|
|
100
107
|
cerber builds once under `~/.cerber` — the first tap asks you to allow
|
package/dist/cli/index.js
CHANGED
|
@@ -358,7 +358,7 @@ program
|
|
|
358
358
|
.option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
|
|
359
359
|
.option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
|
|
360
360
|
.option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
|
|
361
|
-
.option("--no-notify", "don't notify this machine when a
|
|
361
|
+
.option("--no-notify", "don't notify this machine when a review is ready for you")
|
|
362
362
|
.option("-d, --daemon", "(deprecated no-op — polling and auto-review are now the default)")
|
|
363
363
|
.option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
|
|
364
364
|
.option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
|
package/dist/core/artifact.js
CHANGED
|
@@ -304,6 +304,32 @@ export const ArtifactSchema = z.object({
|
|
|
304
304
|
* field forward long after the decision it is meant to date.
|
|
305
305
|
*/
|
|
306
306
|
settledAt: z.string().nullable().default(null),
|
|
307
|
+
/**
|
|
308
|
+
* The announcement ledger: what this machine has already told you about this
|
|
309
|
+
* row — see `pendingNews` in `src/core/notify.ts` for what is worth telling.
|
|
310
|
+
*
|
|
311
|
+
* Three states, not two. `null` is "the poll found this and owes you a tap";
|
|
312
|
+
* a record is "already told". **Absent** is neither: nobody ever meant to
|
|
313
|
+
* announce this row — a review you pulled in by hand, or one written before
|
|
314
|
+
* the ledger existed — so it is never news, and an upgrade doesn't announce
|
|
315
|
+
* a queue the user has been looking at for weeks. Only `stubArtifact` writes
|
|
316
|
+
* the null.
|
|
317
|
+
*
|
|
318
|
+
* It records *what* was said, not just that something was, because the two
|
|
319
|
+
* things cerber can say about a row are different news: "nobody is drafting
|
|
320
|
+
* this" and "here is the draft". A run that fails is announced as the first,
|
|
321
|
+
* is retried on the next poll, and may then succeed — and a ledger that only
|
|
322
|
+
* remembered "told" would swallow the draft-ready tap, leaving the user with
|
|
323
|
+
* the one notification that had nothing to read behind it.
|
|
324
|
+
*/
|
|
325
|
+
notified: z
|
|
326
|
+
.object({
|
|
327
|
+
at: z.string(),
|
|
328
|
+
/** True when what was announced was a finished draft. */
|
|
329
|
+
drafted: z.boolean(),
|
|
330
|
+
})
|
|
331
|
+
.nullable()
|
|
332
|
+
.optional(),
|
|
307
333
|
/** Last time this review was pulled forward onto a newer head commit. */
|
|
308
334
|
refresh: RefreshInfoSchema.nullable().default(null),
|
|
309
335
|
calibration: CalibrationSchema.nullable().default(null),
|
package/dist/core/config.js
CHANGED
|
@@ -23,9 +23,10 @@ export const DaemonConfigSchema = z.object({
|
|
|
23
23
|
/** Draft an AI review for whatever lands in the queue. Never sends. */
|
|
24
24
|
autoReview: z.boolean().default(true),
|
|
25
25
|
/**
|
|
26
|
-
* Tap this machine's notification centre when a PR
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Tap this machine's notification centre when a PR is worth coming back for
|
|
27
|
+
* — the draft landing, or the arrival itself when nothing is going to be
|
|
28
|
+
* drafted (`isNews` in `core/notify.ts`). The cockpit's bell needs a tab open
|
|
29
|
+
* and permitted; this one only needs `serve` running, which is the point.
|
|
29
30
|
*/
|
|
30
31
|
notify: z.boolean().default(true),
|
|
31
32
|
intervalMinutes: z.number().int().positive().default(5),
|
package/dist/core/notify.js
CHANGED
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
// one when it can ring: it names the PR and opens that review when you click
|
|
5
5
|
// it. But it is a page notification, so it needs a tab that is open, alive and
|
|
6
6
|
// permitted — which is exactly what you don't have while you're in an editor
|
|
7
|
-
// all afternoon. This half rides the poll
|
|
8
|
-
//
|
|
7
|
+
// all afternoon. This half rides the poll instead, so the tap survives a closed
|
|
8
|
+
// cockpit, a denied permission and a browser restart.
|
|
9
|
+
//
|
|
10
|
+
// It taps you once per piece of news, at the moment that news is worth walking
|
|
11
|
+
// back for. With auto-review on — the default — that is when the draft is
|
|
12
|
+
// written, not when the PR lands: a tap that leads to "no run yet, press r" is
|
|
13
|
+
// a tap that costs you the walk back and gives you nothing. `isNews` decides
|
|
14
|
+
// whether there is news at all, `pendingNews` whether it is news you have
|
|
15
|
+
// already been told. A PR has at most two: "nobody is drafting this", and
|
|
16
|
+
// "here is the draft" — and it only gets the second if it got the first.
|
|
9
17
|
//
|
|
10
18
|
// A click has to land in the same place either way, which on macOS is the whole
|
|
11
19
|
// difference between the two paths below. `osascript -e 'display notification'`
|
|
@@ -40,6 +48,22 @@ const execFileAsync = promisify(execFile);
|
|
|
40
48
|
*/
|
|
41
49
|
export const NOTIFY_TIMEOUT_MS = 5_000;
|
|
42
50
|
const slug = (a) => `${a.repo}#${a.number}`;
|
|
51
|
+
const VERDICT_WORDS = {
|
|
52
|
+
approve: "approves",
|
|
53
|
+
comment: "comments",
|
|
54
|
+
request_changes: "requests changes",
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* What a finished draft says, in the few words a popup gets: the verdict, and
|
|
58
|
+
* the count the verdict rests on. A draft that graded nothing and left no
|
|
59
|
+
* verdict still gets a line — "drafted" is the news there.
|
|
60
|
+
*/
|
|
61
|
+
function draftLine(d) {
|
|
62
|
+
const verdict = d.recommendation ? VERDICT_WORDS[d.recommendation] : "drafted";
|
|
63
|
+
if (d.blockers === 0)
|
|
64
|
+
return verdict;
|
|
65
|
+
return `${verdict} · ${d.blockers} blocker${d.blockers === 1 ? "" : "s"}`;
|
|
66
|
+
}
|
|
43
67
|
/**
|
|
44
68
|
* Text a notification body or an AppleScript literal can actually hold. A PR
|
|
45
69
|
* title is somebody else's text and can carry anything; control characters
|
|
@@ -48,7 +72,7 @@ const slug = (a) => `${a.repo}#${a.number}`;
|
|
|
48
72
|
*/
|
|
49
73
|
const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
50
74
|
/**
|
|
51
|
-
* One popup for one poll's
|
|
75
|
+
* One popup for one poll's news — a batch is one interruption, not five.
|
|
52
76
|
* Deliberately the same shape and wording the cockpit's bell uses, so the two
|
|
53
77
|
* channels never read as two different pieces of news about one PR.
|
|
54
78
|
*
|
|
@@ -57,27 +81,92 @@ const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
|
57
81
|
* origin with no fragment of its own — the deep link is a fragment, and the
|
|
58
82
|
* cockpit is a hash-routed page.
|
|
59
83
|
*/
|
|
60
|
-
export function notice(
|
|
61
|
-
if (
|
|
84
|
+
export function notice(news, cockpit) {
|
|
85
|
+
if (news.length === 0)
|
|
62
86
|
return null;
|
|
63
87
|
const base = cockpit || null;
|
|
64
|
-
if (
|
|
65
|
-
const a =
|
|
88
|
+
if (news.length === 1) {
|
|
89
|
+
const a = news[0];
|
|
66
90
|
const key = artifactKey(artifactId(a));
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
const url = base && `${base}#/r/${encodeURIComponent(key)}`;
|
|
92
|
+
// The draft is the news when there is one, so the popup leads with what it
|
|
93
|
+
// found rather than with the PR having arrived — which, by then, it did
|
|
94
|
+
// minutes ago.
|
|
95
|
+
if (a.draft)
|
|
96
|
+
return { title: `${slug(a)} draft ready`, body: `${draftLine(a.draft)} — ${a.title}`, url };
|
|
97
|
+
return { title: `${slug(a)} awaits your review`, body: `${a.title} — ${a.author}`, url };
|
|
72
98
|
}
|
|
73
|
-
const named =
|
|
74
|
-
const rest =
|
|
99
|
+
const named = news.slice(0, 3).map(slug);
|
|
100
|
+
const rest = news.length - named.length;
|
|
75
101
|
return {
|
|
76
|
-
|
|
102
|
+
// A drafted PR awaits you too, so the general wording is true of any batch;
|
|
103
|
+
// the better headline is only claimed when every one of them is drafted.
|
|
104
|
+
title: news.every((n) => n.draft) ? `${news.length} drafts ready` : `${news.length} PRs await your review`,
|
|
77
105
|
body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
|
|
78
106
|
url: base,
|
|
79
107
|
};
|
|
80
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Whether this row is news *yet*.
|
|
111
|
+
*
|
|
112
|
+
* The one rule behind the tap's timing, and the cockpit's bell mirrors it
|
|
113
|
+
* (`web/src/notify.ts`). A row cerber is about to draft, or is drafting, is not
|
|
114
|
+
* news: the tap would arrive minutes before there is anything to read, and the
|
|
115
|
+
* one after it would never come. So it waits — unless nothing is coming, which
|
|
116
|
+
* is the case when auto-review is off (nobody will draft this until you say so)
|
|
117
|
+
* or the run failed (nobody will draft it at all).
|
|
118
|
+
*
|
|
119
|
+
* A row you have settled is not news either, whatever else happened to it: you
|
|
120
|
+
* already answered it.
|
|
121
|
+
*/
|
|
122
|
+
export function isNews(a, autoReview) {
|
|
123
|
+
if (a.pr.state !== "OPEN")
|
|
124
|
+
return false;
|
|
125
|
+
if (a.status === "running")
|
|
126
|
+
return false;
|
|
127
|
+
if (a.status === "awaiting")
|
|
128
|
+
return !autoReview;
|
|
129
|
+
return a.status === "ready" || a.status === "failed";
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* What this row has to say that it has not said already, or null for silence.
|
|
133
|
+
*
|
|
134
|
+
* `isNews` answers "is there news at all"; this one also weighs it against the
|
|
135
|
+
* ledger, which is where the two things cerber can say about a row stop being
|
|
136
|
+
* interchangeable. A run that failed is announced as "nobody is drafting this",
|
|
137
|
+
* is retried on the next poll, and may then succeed — so the draft is news
|
|
138
|
+
* *again*, to someone who was told the opposite five minutes ago. The reverse
|
|
139
|
+
* is not: a row that had a draft and now has a broken re-review is a PR the
|
|
140
|
+
* user already knows about, and a second tap there is noise.
|
|
141
|
+
*/
|
|
142
|
+
export function pendingNews(a, autoReview) {
|
|
143
|
+
// Absent, not null: nobody ever meant to announce this row.
|
|
144
|
+
if (a.notified === undefined)
|
|
145
|
+
return null;
|
|
146
|
+
if (!isNews(a, autoReview))
|
|
147
|
+
return null;
|
|
148
|
+
const news = newsOf(a);
|
|
149
|
+
if (a.notified === null)
|
|
150
|
+
return news;
|
|
151
|
+
return news.draft && !a.notified.drafted ? news : null;
|
|
152
|
+
}
|
|
153
|
+
/** What that row would say, for the one notice a poll sends. */
|
|
154
|
+
export function newsOf(a) {
|
|
155
|
+
return {
|
|
156
|
+
// The click target is built from the ref, so the owner travels with it.
|
|
157
|
+
owner: a.pr.owner,
|
|
158
|
+
repo: a.pr.repo,
|
|
159
|
+
number: a.pr.number,
|
|
160
|
+
title: a.pr.title,
|
|
161
|
+
author: a.pr.author,
|
|
162
|
+
draft: a.status === "ready"
|
|
163
|
+
? {
|
|
164
|
+
recommendation: a.verdict?.recommendation ?? null,
|
|
165
|
+
blockers: a.comments.filter((c) => c.status !== "dropped" && c.severity === "blocker").length,
|
|
166
|
+
}
|
|
167
|
+
: null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
81
170
|
/**
|
|
82
171
|
* A PR title, as an AppleScript string literal.
|
|
83
172
|
*
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "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)",
|