@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.
- package/README.md +32 -12
- package/dist/cli/index.js +20 -1
- package/dist/core/artifact.js +11 -0
- package/dist/core/config.js +6 -0
- package/dist/core/notify.js +105 -0
- package/dist/core/refresh.js +40 -13
- package/dist/core/state.js +11 -1
- package/dist/runner/review.js +45 -28
- package/dist/server/daemon.js +60 -3
- package/dist/server/index.js +38 -5
- package/package.json +1 -1
- package/web/dist/assets/{index-D4LYByLi.js → index-DFhsdnD2.js} +59 -59
- package/web/dist/assets/{index-BG0S9CJ-.css → index-VLT71mrB.css} +1 -1
- package/web/dist/index.html +2 -2
package/README.md
CHANGED
|
@@ -41,9 +41,9 @@ npx @fullstackhouse/cerber serve # → http://127.0.0.1:4820
|
|
|
41
41
|
|
|
42
42
|
`serve` does the whole loop by default: discover → draft → wait for you.
|
|
43
43
|
Sending stays a human click. Tame it with `--no-auto-review` (list awaiting
|
|
44
|
-
PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip those
|
|
45
|
+
switches in the cockpit's Settings, which persist in `~/.cerber/config.json`
|
|
46
|
+
and apply on the next poll. The queue only lists what
|
|
47
47
|
still wants you: anything you have settled moves to the filed tabs right of
|
|
48
48
|
the thin rule in the filter row — reviews you sent, ones you marked reviewed
|
|
49
49
|
or skipped, merged and closed PRs — each tab appearing only while it holds
|
|
@@ -88,15 +88,28 @@ who did is the one mistake worth designing against.
|
|
|
88
88
|
|
|
89
89
|
### It taps you on the shoulder
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
When a PR lands in the queue you get a desktop notification naming it, folded
|
|
92
|
+
into one popup when several arrive at once. The tap comes from the poll that
|
|
93
|
+
found the PR, so it reaches you with the cockpit closed, in another Space, or
|
|
94
|
+
after a browser restart — `cerber serve` is the only thing that has to be
|
|
95
|
+
running. It goes to whatever this machine already has (Notification Centre on
|
|
96
|
+
macOS, `notify-send` on Linux) and stays quiet where there is neither. Off with
|
|
97
|
+
`daemon.notify` in `~/.cerber/config.json`, or the checkbox in Settings.
|
|
98
|
+
|
|
99
|
+
The cockpit has a bell of its own, and it is the better one where it can ring:
|
|
100
|
+
its notification **opens that review when you click it**. But it is a page
|
|
101
|
+
notification — it needs a tab that is open, alive and permitted, which is
|
|
102
|
+
exactly what you don't have on the afternoons this feature is for. So it stands
|
|
103
|
+
down while the machine's own tap is on, rather than making one PR two popups,
|
|
104
|
+
and the bell in the top bar says so. Untick the machine one and this browser
|
|
105
|
+
takes over: click the bell, grant the permission, and click it again to stop.
|
|
106
|
+
The switch and the record of what has been announced are per-browser — the
|
|
107
|
+
permission they gate is the browser's, so a second browser starts its own
|
|
108
|
+
record — and it stays quiet while you're looking straight at cerber.
|
|
109
|
+
|
|
110
|
+
A cockpit served from somewhere else (`cerber serve -H 0.0.0.0 --token` on a
|
|
111
|
+
VPS) keeps its bell either way: the machine's tap would land on the VPS, where
|
|
112
|
+
nobody is looking.
|
|
100
113
|
|
|
101
114
|
The tab itself says it too, without asking anyone: while the inbox holds
|
|
102
115
|
anything, the favicon wears a red dot, and it goes away when the last review is
|
|
@@ -371,6 +384,13 @@ shadow mode for a while: every would-send decision lands in
|
|
|
371
384
|
verdicts and comments survive your review — enable auto-send when its 90%
|
|
372
385
|
actually means 90%.
|
|
373
386
|
|
|
387
|
+
## How it works
|
|
388
|
+
|
|
389
|
+
[**docs/lifecycle.md**](docs/lifecycle.md) is the reference behind everything
|
|
390
|
+
above: the seven statuses and what sets each, which tab a PR lands in and why,
|
|
391
|
+
when the poll files or archives a row on its own, when a re-review happens and
|
|
392
|
+
what it will not overwrite, and what gets written to disk at each step.
|
|
393
|
+
|
|
374
394
|
## Development
|
|
375
395
|
|
|
376
396
|
```bash
|
package/dist/cli/index.js
CHANGED
|
@@ -8,8 +8,9 @@ import { configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
|
8
8
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
9
9
|
import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
10
10
|
import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
|
|
11
|
-
import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, updateArtifactByKey, } from "../core/state.js";
|
|
11
|
+
import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
|
|
12
12
|
import { pool, reviewPr } from "../runner/review.js";
|
|
13
|
+
import { isReviewRunning } from "../runner/inflight.js";
|
|
13
14
|
import { startDaemon } from "../server/daemon.js";
|
|
14
15
|
import { startServer } from "../server/index.js";
|
|
15
16
|
const program = new Command();
|
|
@@ -135,6 +136,15 @@ program
|
|
|
135
136
|
console.error(`Already sent at ${artifact.sent.at}${artifact.sent.url ? ` — ${artifact.sent.url}` : ""}`);
|
|
136
137
|
process.exit(1);
|
|
137
138
|
}
|
|
139
|
+
// The persisted status is the only signal here: a run started by `serve` or
|
|
140
|
+
// by another terminal is invisible to this process. Sending mid-run vouches
|
|
141
|
+
// for a draft that is being replaced as you read it.
|
|
142
|
+
if (artifact.status === "running") {
|
|
143
|
+
console.error(`A review of ${id} is running — wait for it to finish, then send. ` +
|
|
144
|
+
`If nothing is actually running, the last run died mid-flight: re-run it, or restart \`cerber serve\`, ` +
|
|
145
|
+
`either of which clears the stale status.`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
138
148
|
const event = (opts.event ??
|
|
139
149
|
(artifact.verdict ? eventForRecommendation(artifact.verdict.recommendation) : "COMMENT")).toUpperCase();
|
|
140
150
|
if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(event)) {
|
|
@@ -313,6 +323,7 @@ program
|
|
|
313
323
|
.option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
|
|
314
324
|
.option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
|
|
315
325
|
.option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
|
|
326
|
+
.option("--no-notify", "don't notify this machine when a PR lands in the queue")
|
|
316
327
|
.option("-d, --daemon", "(deprecated no-op — polling and auto-review are now the default)")
|
|
317
328
|
.option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
|
|
318
329
|
.option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
|
|
@@ -351,6 +362,13 @@ program
|
|
|
351
362
|
`matching PRs, with nobody watching. Pass --no-trust to keep every review read-only, ` +
|
|
352
363
|
`or --no-auto-review to only review on click.`);
|
|
353
364
|
}
|
|
365
|
+
// Before anything polls. Reconciliation is what makes "nothing is running"
|
|
366
|
+
// true after an unclean shutdown, and the daemon's first tick fires the
|
|
367
|
+
// moment it is constructed — so starting it first put both of them on the
|
|
368
|
+
// same leftover artifact with no ordering between them.
|
|
369
|
+
const cleared = await reconcileRunning({ inUse: isReviewRunning });
|
|
370
|
+
if (cleared > 0)
|
|
371
|
+
console.log(`Cleared ${cleared} interrupted run(s) left by a previous process.`);
|
|
354
372
|
const daemon = opts.poll
|
|
355
373
|
? startDaemon({
|
|
356
374
|
repos: opts.repo.length > 0 ? opts.repo : d.repos,
|
|
@@ -361,6 +379,7 @@ program
|
|
|
361
379
|
withSource: opts.source,
|
|
362
380
|
trust: opts.trust ? undefined : false,
|
|
363
381
|
autoReview: opts.autoReview,
|
|
382
|
+
notify: opts.notify,
|
|
364
383
|
autoSend: opts.autoSend ? "on" : "shadow",
|
|
365
384
|
autoSendThreshold: threshold,
|
|
366
385
|
})
|
package/dist/core/artifact.js
CHANGED
|
@@ -97,6 +97,17 @@ export const RunInfoSchema = z.object({
|
|
|
97
97
|
* be filed away automatically and one that must not be.
|
|
98
98
|
*/
|
|
99
99
|
trigger: z.enum(["daemon", "user"]).nullable().default(null),
|
|
100
|
+
/**
|
|
101
|
+
* The head commit this run actually read, set when it finishes.
|
|
102
|
+
*
|
|
103
|
+
* `pr.headSha` cannot answer that question: a refresh moves it forward to
|
|
104
|
+
* keep the comments anchored to current code, without anybody re-reading
|
|
105
|
+
* anything. The freshness guard compares against *this* instead, so merely
|
|
106
|
+
* opening a draft after a push no longer convinces the poll that the draft
|
|
107
|
+
* is up to date. Null on runs recorded before this existed, and on one still
|
|
108
|
+
* in flight — both fall back to the old comparison.
|
|
109
|
+
*/
|
|
110
|
+
reviewedSha: z.string().nullable().default(null),
|
|
100
111
|
});
|
|
101
112
|
export const SentInfoSchema = z.object({
|
|
102
113
|
at: z.string(),
|
package/dist/core/config.js
CHANGED
|
@@ -22,6 +22,12 @@ export const DaemonConfigSchema = z.object({
|
|
|
22
22
|
poll: z.boolean().default(true),
|
|
23
23
|
/** Draft an AI review for whatever lands in the queue. Never sends. */
|
|
24
24
|
autoReview: z.boolean().default(true),
|
|
25
|
+
/**
|
|
26
|
+
* Tap this machine's notification centre when a PR lands in the queue. The
|
|
27
|
+
* cockpit's bell needs a tab open and permitted; this one only needs `serve`
|
|
28
|
+
* running, which is the point of it.
|
|
29
|
+
*/
|
|
30
|
+
notify: z.boolean().default(true),
|
|
25
31
|
intervalMinutes: z.number().int().positive().default(5),
|
|
26
32
|
parallel: z.number().int().positive().default(3),
|
|
27
33
|
/** Repos to watch (owner/repo). Empty = everything your gh account can see. */
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The machine's own shoulder-tap, for the hours the cockpit isn't open.
|
|
2
|
+
//
|
|
3
|
+
// The cockpit has a bell of its own (`web/src/notify.ts`), and it is the better
|
|
4
|
+
// one when it can ring: it names the PR and opens that review when you click
|
|
5
|
+
// it. But it is a page notification, so it needs a tab that is open, alive and
|
|
6
|
+
// permitted — which is exactly what you don't have while you're in an editor
|
|
7
|
+
// all afternoon. This half rides the poll that discovers the PR instead, so the
|
|
8
|
+
// tap survives a closed cockpit, a denied permission and a browser restart.
|
|
9
|
+
//
|
|
10
|
+
// Everything here shells out to whatever the OS already has. No dependency, no
|
|
11
|
+
// daemon of its own, and a machine with no notifier is a machine cerber stays
|
|
12
|
+
// quiet on rather than one it fails on.
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
/**
|
|
17
|
+
* How long a notifier gets before the poll stops waiting for it.
|
|
18
|
+
*
|
|
19
|
+
* A notification is instant or it is broken, and the thing on the other end of
|
|
20
|
+
* this is somebody else's daemon: a wedged Notification Centre or a DBus that
|
|
21
|
+
* never answers would otherwise hang `execFile` forever. The poll awaits this
|
|
22
|
+
* call, and the daemon refuses to start a poll while one is running — so an
|
|
23
|
+
* unbounded wait here doesn't cost one notification, it stops cerber
|
|
24
|
+
* discovering PRs at all. Timing out is caught like any other failure.
|
|
25
|
+
*/
|
|
26
|
+
export const NOTIFY_TIMEOUT_MS = 5_000;
|
|
27
|
+
const slug = (a) => `${a.repo}#${a.number}`;
|
|
28
|
+
/**
|
|
29
|
+
* One popup for one poll's arrivals — a batch is one interruption, not five.
|
|
30
|
+
* Deliberately the same shape and wording the cockpit's bell uses, so the two
|
|
31
|
+
* channels never read as two different pieces of news about one PR.
|
|
32
|
+
*/
|
|
33
|
+
export function notice(arrived) {
|
|
34
|
+
if (arrived.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
if (arrived.length === 1) {
|
|
37
|
+
const a = arrived[0];
|
|
38
|
+
return { title: `${slug(a)} awaits your review`, body: `${a.title} — ${a.author}` };
|
|
39
|
+
}
|
|
40
|
+
const named = arrived.slice(0, 3).map(slug);
|
|
41
|
+
const rest = arrived.length - named.length;
|
|
42
|
+
return {
|
|
43
|
+
title: `${arrived.length} PRs await your review`,
|
|
44
|
+
body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A PR title, as an AppleScript string literal.
|
|
49
|
+
*
|
|
50
|
+
* osascript takes a script, not an argument list, so the title has to go into
|
|
51
|
+
* the source — and a PR title is text somebody else wrote. Escaping the two
|
|
52
|
+
* characters a literal cannot hold, a backslash and a quote, is what stops a
|
|
53
|
+
* title from closing the string and being read as script. Control characters
|
|
54
|
+
* can't appear in a literal at all, so they collapse to a space.
|
|
55
|
+
*/
|
|
56
|
+
export function appleScriptLiteral(value) {
|
|
57
|
+
const flat = value.replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
58
|
+
return `"${flat.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The command that taps this machine's notification centre, or null where there
|
|
62
|
+
* is nothing to tap. macOS and the freedesktop notifiers cover what cerber runs
|
|
63
|
+
* on; anywhere else it stays quiet rather than pretending.
|
|
64
|
+
*/
|
|
65
|
+
export function notifyCommand(n, platform = process.platform) {
|
|
66
|
+
if (platform === "darwin") {
|
|
67
|
+
return {
|
|
68
|
+
file: "osascript",
|
|
69
|
+
args: [
|
|
70
|
+
"-e",
|
|
71
|
+
`display notification ${appleScriptLiteral(n.body)} with title ${appleScriptLiteral(n.title)}`,
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (platform === "linux") {
|
|
76
|
+
// `--` first, because notify-send parses options before positionals. The
|
|
77
|
+
// PR's own title reaches this as the notice *body* (`notice()` builds the
|
|
78
|
+
// summary itself), so the body is the argument carrying somebody else's
|
|
79
|
+
// text — a PR titled "--help me" would be read as a flag and cost the
|
|
80
|
+
// notification. The marker ends option parsing ahead of both arguments, so
|
|
81
|
+
// neither the summary nor the body can be read as anything but text.
|
|
82
|
+
return { file: "notify-send", args: ["--app-name=cerber", "--", n.title, n.body] };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Show it, and say whether it was shown. False is an ordinary answer here —
|
|
88
|
+
* an unsupported platform, no `notify-send` installed, a headless box, a
|
|
89
|
+
* notifier that took too long — and the caller reports it once rather than
|
|
90
|
+
* every poll. Never throws and never hangs: a notification is the least
|
|
91
|
+
* important thing a poll does, so it is also the last thing allowed to stop
|
|
92
|
+
* one.
|
|
93
|
+
*/
|
|
94
|
+
export async function notify(n) {
|
|
95
|
+
const cmd = notifyCommand(n);
|
|
96
|
+
if (!cmd)
|
|
97
|
+
return false;
|
|
98
|
+
try {
|
|
99
|
+
await execFileAsync(cmd.file, cmd.args, { timeout: NOTIFY_TIMEOUT_MS });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/core/refresh.js
CHANGED
|
@@ -34,21 +34,48 @@ export function refreshArtifact(artifact, pr, diff) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Is this artifact's status the user's to keep, rather than the run's to set?
|
|
38
|
+
*
|
|
39
|
+
* A send or a settle is a decision about the PR; a run finishing is a fact
|
|
40
|
+
* about the code. Whichever way the run went — a draft or an error — it does
|
|
41
|
+
* not get to reopen one, which is the same rule `SETTLED_BY_YOU` applies to
|
|
42
|
+
* starting a run in the first place.
|
|
40
43
|
*/
|
|
41
|
-
export function
|
|
42
|
-
return
|
|
44
|
+
export function userOwnsStatus(a) {
|
|
45
|
+
return a.sent !== null || a.status === "reviewed" || a.status === "skipped";
|
|
43
46
|
}
|
|
44
47
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
48
|
+
* Fold a finished review run onto whatever the artifact says now.
|
|
49
|
+
*
|
|
50
|
+
* A run takes minutes and the cockpit stays live throughout, so by the time one
|
|
51
|
+
* lands the artifact may have moved under it. Saving the result wholesale
|
|
52
|
+
* silently undid whatever had happened in between — the same mistake
|
|
53
|
+
* `mergeConcurrentEdits` exists to stop a chat turn making.
|
|
54
|
+
*
|
|
55
|
+
* `fresh` is what the run produced; `current` is what is on disk now. The run
|
|
56
|
+
* owns the draft — summary, chapters, verdict, comments — and that includes
|
|
57
|
+
* replacing comments the user wrote: a re-review starts fresh by design, and
|
|
58
|
+
* `docs/lifecycle.md` says so plainly rather than leaving anyone to count on
|
|
59
|
+
* work surviving one. What it does *not* own are the decisions:
|
|
60
|
+
*
|
|
61
|
+
* - **a send** stands, and takes the status with it. Without this a run
|
|
62
|
+
* finishing after a send wrote `sent: null` back over the record, and the
|
|
63
|
+
* "already sent" guard would then wave a second submission through.
|
|
64
|
+
* - **a settle** stands too: `reviewed` and `skipped` are decisions about the
|
|
65
|
+
* PR, not facts about the code, so a run completing does not reopen one.
|
|
66
|
+
* The fresh draft still lands underneath, which is what the row shows if
|
|
67
|
+
* the user changes their mind.
|
|
68
|
+
* - **the conversation** is the user's writing, snapshot and all.
|
|
47
69
|
*/
|
|
48
|
-
export function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
70
|
+
export function mergeRunResult(fresh, current) {
|
|
71
|
+
return {
|
|
72
|
+
...fresh,
|
|
73
|
+
status: userOwnsStatus(current) ? current.status : fresh.status,
|
|
74
|
+
sent: current.sent,
|
|
75
|
+
calibration: current.calibration,
|
|
76
|
+
filed: current.filed,
|
|
77
|
+
chat: current.chat,
|
|
78
|
+
preChat: current.preChat,
|
|
79
|
+
pendingChat: current.pendingChat,
|
|
80
|
+
};
|
|
54
81
|
}
|
package/dist/core/state.js
CHANGED
|
@@ -98,11 +98,21 @@ export async function updateArtifactByKey(key, mutate) {
|
|
|
98
98
|
* this it stays wedged forever, with no way to retry it from the cockpit. A
|
|
99
99
|
* `cerber review` running in another terminal is the one false positive; it
|
|
100
100
|
* overwrites the artifact when it finishes anyway.
|
|
101
|
+
*
|
|
102
|
+
* `inUse` names the runs this process genuinely owns, and they are left alone.
|
|
103
|
+
* Callers should still reconcile before starting anything that polls — but
|
|
104
|
+
* ordering alone is a promise about wiring, and this is the guard that holds
|
|
105
|
+
* whatever order they are started in: without it, reconciliation racing a
|
|
106
|
+
* daemon's first tick could stamp "interrupted" over a review that had just
|
|
107
|
+
* legitimately begun.
|
|
101
108
|
*/
|
|
102
|
-
export async function reconcileRunning() {
|
|
109
|
+
export async function reconcileRunning(opts = {}) {
|
|
110
|
+
const inUse = opts.inUse ?? (() => false);
|
|
103
111
|
const artifacts = await listArtifacts();
|
|
104
112
|
let cleared = 0;
|
|
105
113
|
for (const artifact of artifacts) {
|
|
114
|
+
if (inUse(artifact.id))
|
|
115
|
+
continue;
|
|
106
116
|
const stuckRun = artifact.status === "running";
|
|
107
117
|
const stuckChat = artifact.pendingChat && artifact.pendingChat.error == null ? artifact.pendingChat : null;
|
|
108
118
|
if (!stuckRun && !stuckChat)
|
package/dist/runner/review.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { AiReviewSchema, SCHEMA_VERSION, artifactId, } from "../core/artifact.js";
|
|
2
|
+
import { AiReviewSchema, SCHEMA_VERSION, artifactId, artifactKey, } from "../core/artifact.js";
|
|
3
3
|
import { createRunDir, evictOldCheckouts, prepareCheckout, removeRunDir } from "../core/checkout.js";
|
|
4
4
|
import { fetchPrDiff, fetchPrInfo, isOrgMember, isTeamMember } from "../core/gh.js";
|
|
5
|
-
import {
|
|
6
|
-
import { loadArtifact, saveArtifact } from "../core/state.js";
|
|
5
|
+
import { mergeRunResult, userOwnsStatus } from "../core/refresh.js";
|
|
6
|
+
import { loadArtifact, saveArtifact, updateArtifactByKey } from "../core/state.js";
|
|
7
7
|
import { loadConfig } from "../core/config.js";
|
|
8
8
|
import { decideTrust, membershipQueries, parseTrustRules } from "../core/trust.js";
|
|
9
9
|
import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
|
|
@@ -91,10 +91,15 @@ async function runReview(ref, opts) {
|
|
|
91
91
|
log(`You marked this ${existing.status} — leaving it alone. Use --force to re-review.`);
|
|
92
92
|
return { artifact: existing, skipped: true };
|
|
93
93
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
// The sha the AI *read*, not the one the artifact happens to mention.
|
|
95
|
+
// `pr.headSha` is moved forward by the refresh that runs whenever a review
|
|
96
|
+
// is opened, so comparing against it meant that merely looking at a draft
|
|
97
|
+
// after a push convinced this guard the draft was current, and the poll
|
|
98
|
+
// never re-reviewed it again. Older artifacts have no `reviewedSha` and
|
|
99
|
+
// fall back to the old comparison.
|
|
100
|
+
const reviewedSha = existing.run?.reviewedSha ?? existing.pr.headSha;
|
|
101
|
+
if (HEAD_SENSITIVE.has(existing.status) && reviewedSha !== "" && reviewedSha === pr.headSha) {
|
|
102
|
+
log(`Up to date (reviewed at ${reviewedSha.slice(0, 7)}, status ${existing.status}) — skipping. Use --force to re-review.`);
|
|
98
103
|
return { artifact: existing, skipped: true };
|
|
99
104
|
}
|
|
100
105
|
}
|
|
@@ -120,6 +125,14 @@ async function runReview(ref, opts) {
|
|
|
120
125
|
`reviewing from the diff alone.`);
|
|
121
126
|
}
|
|
122
127
|
}
|
|
128
|
+
// A re-review regenerates the draft, comments and all — deliberately, and
|
|
129
|
+
// including any the user wrote. Carrying them across is a feature this tool
|
|
130
|
+
// has decided not to have, so nothing here tries to half-keep them: they go
|
|
131
|
+
// when the run starts, whichever way it ends. `docs/lifecycle.md` says so
|
|
132
|
+
// where a reader would otherwise assume otherwise.
|
|
133
|
+
if (existing && existing.comments.length > 0) {
|
|
134
|
+
log(`Replacing the previous draft's ${existing.comments.length} comment(s) — a re-review starts fresh.`);
|
|
135
|
+
}
|
|
123
136
|
let artifact = {
|
|
124
137
|
schemaVersion: SCHEMA_VERSION,
|
|
125
138
|
id: artifactId(pr),
|
|
@@ -142,6 +155,7 @@ async function runReview(ref, opts) {
|
|
|
142
155
|
trusted: trusted && source !== null,
|
|
143
156
|
sessionId: null,
|
|
144
157
|
trigger: opts.trigger ?? "user",
|
|
158
|
+
reviewedSha: null,
|
|
145
159
|
},
|
|
146
160
|
sent: null,
|
|
147
161
|
refresh: null,
|
|
@@ -204,34 +218,37 @@ async function runReview(ref, opts) {
|
|
|
204
218
|
// whose working directory was empty has nothing a chat turn could read.
|
|
205
219
|
sessionId: source ? review.sessionId : null,
|
|
206
220
|
trigger: artifact.run.trigger,
|
|
221
|
+
// What this run actually read — the thing the freshness guard needs
|
|
222
|
+
// and `pr.headSha` cannot say, because a refresh moves that forward
|
|
223
|
+
// without anybody re-reading the code.
|
|
224
|
+
reviewedSha: pr.headSha,
|
|
207
225
|
},
|
|
208
226
|
};
|
|
209
|
-
// A re-review regenerates the AI's comments, but the human's own comments
|
|
210
|
-
// and their rewrites of AI ones are work the run must not throw away.
|
|
211
|
-
if (existing) {
|
|
212
|
-
const { comments, carried, drifted } = carryOverComments(existing, artifact);
|
|
213
|
-
if (carried > 0) {
|
|
214
|
-
artifact = { ...artifact, comments };
|
|
215
|
-
log(`Carried over ${carried} comment(s) you wrote or edited` +
|
|
216
|
-
(drifted > 0 ? `; ${drifted} no longer match the diff and will post in the body.` : "."));
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
227
|
}
|
|
220
228
|
catch (err) {
|
|
221
|
-
|
|
222
|
-
...artifact,
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
run: {
|
|
226
|
-
...artifact.run,
|
|
227
|
-
finishedAt: now(),
|
|
228
|
-
error: err instanceof Error ? err.message : String(err),
|
|
229
|
-
},
|
|
229
|
+
const run = {
|
|
230
|
+
...artifact.run,
|
|
231
|
+
finishedAt: now(),
|
|
232
|
+
error: err instanceof Error ? err.message : String(err),
|
|
230
233
|
};
|
|
231
|
-
|
|
234
|
+
// Onto what is on disk, not over it: the artifact this run built holds none
|
|
235
|
+
// of the comments or marks that may have landed while it ran, and a failure
|
|
236
|
+
// is no reason to lose them — nor to reopen a decision the user made.
|
|
237
|
+
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
|
|
238
|
+
...a,
|
|
239
|
+
status: userOwnsStatus(a) ? a.status : "failed",
|
|
240
|
+
run,
|
|
241
|
+
}));
|
|
242
|
+
artifact = saved ?? { ...artifact, status: "failed", run, updatedAt: now() };
|
|
243
|
+
if (!saved)
|
|
244
|
+
await saveArtifact(artifact);
|
|
232
245
|
throw err;
|
|
233
246
|
}
|
|
234
|
-
await
|
|
247
|
+
const merged = await updateArtifactByKey(artifactKey(artifact.id), (current) => mergeRunResult(artifact, current));
|
|
248
|
+
if (merged)
|
|
249
|
+
artifact = merged;
|
|
250
|
+
else
|
|
251
|
+
await saveArtifact(artifact);
|
|
235
252
|
return { artifact, skipped: false };
|
|
236
253
|
}
|
|
237
254
|
/** Run tasks with bounded concurrency, preserving order of results. */
|
package/dist/server/daemon.js
CHANGED
|
@@ -2,6 +2,7 @@ import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
|
|
|
2
2
|
import { evaluateAutoSend } from "../core/autosend.js";
|
|
3
3
|
import { loadConfig } from "../core/config.js";
|
|
4
4
|
import { classifyReply, currentLogin, fetchConversation, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
5
|
+
import { notice, notify } from "../core/notify.js";
|
|
5
6
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
6
7
|
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
7
8
|
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
@@ -140,6 +141,9 @@ export function startDaemon(opts) {
|
|
|
140
141
|
repos: opts.repos,
|
|
141
142
|
intervalMs: opts.intervalMs,
|
|
142
143
|
pollEnabled: true,
|
|
144
|
+
// Claims nothing until a poll has read the config, so the cockpit's bell
|
|
145
|
+
// keeps its own counsel for the few seconds that takes.
|
|
146
|
+
notify: false,
|
|
143
147
|
autoReview: opts.autoReview,
|
|
144
148
|
trustedRuns: false,
|
|
145
149
|
polls: 0,
|
|
@@ -178,7 +182,7 @@ export function startDaemon(opts) {
|
|
|
178
182
|
* search results lag GitHub by minutes and never override them.
|
|
179
183
|
*/
|
|
180
184
|
async function syncQueue(refs) {
|
|
181
|
-
|
|
185
|
+
const discovered = [];
|
|
182
186
|
for (const ref of refs) {
|
|
183
187
|
const existing = await loadArtifact(artifactId(ref));
|
|
184
188
|
if (existing) {
|
|
@@ -195,7 +199,7 @@ export function startDaemon(opts) {
|
|
|
195
199
|
continue;
|
|
196
200
|
}
|
|
197
201
|
await saveArtifact(stubArtifact(ref));
|
|
198
|
-
discovered
|
|
202
|
+
discovered.push(ref);
|
|
199
203
|
}
|
|
200
204
|
// Asked once per pass, and only if a row actually reaches the check that
|
|
201
205
|
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
@@ -333,6 +337,41 @@ export function startDaemon(opts) {
|
|
|
333
337
|
}
|
|
334
338
|
return { discovered };
|
|
335
339
|
}
|
|
340
|
+
/** Said once, not every poll: a machine with no notifier will never grow one. */
|
|
341
|
+
let notifierMissing = false;
|
|
342
|
+
/**
|
|
343
|
+
* What the last attempt to tap this machine actually did, or null before one
|
|
344
|
+
* has been made.
|
|
345
|
+
*
|
|
346
|
+
* `status.notify` is a promise to the cockpit — it stands its own bell down
|
|
347
|
+
* on the strength of it — so it has to mean "this machine gets told", not
|
|
348
|
+
* "this machine was asked to tell". A box with no `notify-send` would
|
|
349
|
+
* otherwise go on claiming the job while the browser politely declined it,
|
|
350
|
+
* and one PR would be announced by nobody at all. Untried counts as working:
|
|
351
|
+
* the alternative is both bells ringing everywhere until the first PR lands.
|
|
352
|
+
*/
|
|
353
|
+
let notifierWorks = null;
|
|
354
|
+
/**
|
|
355
|
+
* Tap this machine when a PR lands in the queue.
|
|
356
|
+
*
|
|
357
|
+
* An artifact is the ledger this rides on: a PR is announced on the poll that
|
|
358
|
+
* first writes a stub for it, so a restart re-announces nothing, and a PR
|
|
359
|
+
* that arrived while `serve` was down is still news the next time it runs.
|
|
360
|
+
* The cockpit's bell announces the same arrival, off the same stub — which is
|
|
361
|
+
* why it stands down when this is on and both are on one machine.
|
|
362
|
+
*/
|
|
363
|
+
async function announce(arrivals) {
|
|
364
|
+
const n = notice(arrivals);
|
|
365
|
+
if (!n)
|
|
366
|
+
return;
|
|
367
|
+
notifierWorks = await notify(n);
|
|
368
|
+
if (notifierWorks)
|
|
369
|
+
return;
|
|
370
|
+
if (notifierMissing)
|
|
371
|
+
return;
|
|
372
|
+
notifierMissing = true;
|
|
373
|
+
log("no desktop notifier here — arrivals will only show in the cockpit");
|
|
374
|
+
}
|
|
336
375
|
/**
|
|
337
376
|
* Whose move it is on each awaiting PR, one conversation read apiece.
|
|
338
377
|
*
|
|
@@ -410,6 +449,14 @@ export function startDaemon(opts) {
|
|
|
410
449
|
const knobs = config.daemon;
|
|
411
450
|
const autoReview = opts.autoReview && knobs.autoReview;
|
|
412
451
|
status.pollEnabled = knobs.poll;
|
|
452
|
+
// What the cockpit's bell defers to: a loop that isn't polling announces
|
|
453
|
+
// nothing, whatever the notify toggle says.
|
|
454
|
+
const notifyOn = opts.notify && knobs.notify;
|
|
455
|
+
// Only true while all three hold, and the third is only knowable by
|
|
456
|
+
// having tried: a notifier that isn't there is not a bell the cockpit
|
|
457
|
+
// should be standing down for.
|
|
458
|
+
const announcing = () => knobs.poll && notifyOn && notifierWorks !== false;
|
|
459
|
+
status.notify = announcing();
|
|
413
460
|
status.autoReview = autoReview;
|
|
414
461
|
// Recomputed too, so a trust rule added mid-run shows up in the strip.
|
|
415
462
|
status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
|
|
@@ -428,6 +475,16 @@ export function startDaemon(opts) {
|
|
|
428
475
|
// A failed poll leaves the last good list up; lastPollError says so.
|
|
429
476
|
status.awaiting = await classifyAll(refs);
|
|
430
477
|
const { discovered } = await syncQueue(refs);
|
|
478
|
+
// Before the reviews, not after: the arrival is the news, and drafting it
|
|
479
|
+
// takes minutes. This is the same moment the cockpit's bell would ring.
|
|
480
|
+
if (notifyOn && discovered.length > 0) {
|
|
481
|
+
await announce(discovered);
|
|
482
|
+
// Recomputed on the spot rather than left to the next poll: that is
|
|
483
|
+
// minutes away, and the cockpit would spend them deferring to a tap
|
|
484
|
+
// that never came. Recomputed rather than only cleared, so a machine
|
|
485
|
+
// whose notifier comes back takes the job back with it.
|
|
486
|
+
status.notify = announcing();
|
|
487
|
+
}
|
|
431
488
|
if (autoReview) {
|
|
432
489
|
const { reviewed, skipped, failed } = await reviewAll(refs);
|
|
433
490
|
status.lastSummary =
|
|
@@ -435,7 +492,7 @@ export function startDaemon(opts) {
|
|
|
435
492
|
`${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
436
493
|
}
|
|
437
494
|
else {
|
|
438
|
-
status.lastSummary = `${refs.length} awaiting${discovered > 0 ? `, ${discovered} new` : ""} (auto-review off)`;
|
|
495
|
+
status.lastSummary = `${refs.length} awaiting${discovered.length > 0 ? `, ${discovered.length} new` : ""} (auto-review off)`;
|
|
439
496
|
}
|
|
440
497
|
log(status.lastSummary);
|
|
441
498
|
}
|