@fullstackhouse/cerber 0.7.0 → 0.9.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.
@@ -1,13 +1,63 @@
1
- import { artifactKey } from "../core/artifact.js";
1
+ import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
2
2
  import { evaluateAutoSend } from "../core/autosend.js";
3
- import { searchAwaitingMe, submitReview } from "../core/gh.js";
3
+ import { loadConfig } from "../core/config.js";
4
+ import { fetchPrInfo, searchAwaitingMe, submitReview } from "../core/gh.js";
4
5
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
5
- import { appendAutoSendLog, updateArtifactByKey } from "../core/state.js";
6
+ import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
6
7
  import { ReviewInProgressError } from "../runner/inflight.js";
7
8
  import { pool, reviewPr } from "../runner/review.js";
8
9
  /**
9
- * Poll GitHub for PRs awaiting the user's review and keep their artifacts
10
- * warm. Reviews are read-only; the daemon NEVER sends anything.
10
+ * A pure discovery stub: enough artifact for the queue to show an awaiting row.
11
+ * The AI run that follows (automatic or clicked) replaces it wholesale.
12
+ */
13
+ export function stubArtifact(ref) {
14
+ const now = new Date().toISOString();
15
+ return {
16
+ schemaVersion: SCHEMA_VERSION,
17
+ id: artifactId(ref),
18
+ status: "awaiting",
19
+ createdAt: now,
20
+ updatedAt: now,
21
+ pr: {
22
+ owner: ref.owner,
23
+ repo: ref.repo,
24
+ number: ref.number,
25
+ title: ref.title,
26
+ url: ref.url,
27
+ author: ref.author,
28
+ body: "",
29
+ baseRefName: "",
30
+ headRefName: "",
31
+ headSha: "",
32
+ state: "OPEN",
33
+ additions: 0,
34
+ deletions: 0,
35
+ changedFiles: 0,
36
+ },
37
+ diff: "",
38
+ summary: "",
39
+ chapters: [],
40
+ comments: [],
41
+ verdict: null,
42
+ run: null,
43
+ sent: null,
44
+ refresh: null,
45
+ calibration: null,
46
+ };
47
+ }
48
+ /** An artifact nothing has touched yet — safe to delete when its PR leaves the queue. */
49
+ export function isPureStub(artifact) {
50
+ return artifact.status === "awaiting" && artifact.comments.length === 0 && artifact.run === null;
51
+ }
52
+ /** How many open-PR artifacts to re-check against GitHub per poll, and how often each. */
53
+ const STATE_REFRESH_CAP = 10;
54
+ const STATE_REFRESH_MIN_MS = 30 * 60_000;
55
+ /**
56
+ * The inbox loop behind `cerber serve`: poll GitHub for PRs awaiting the
57
+ * user's review, list them as `awaiting`, and (unless auto-review is off)
58
+ * draft a review for each. Merged/closed PRs are archived out of the queue.
59
+ * Reviews stay local; the daemon NEVER sends anything except via the
60
+ * explicitly opted-in auto-send path.
11
61
  */
12
62
  export function startDaemon(opts) {
13
63
  let timer = null;
@@ -17,68 +67,181 @@ export function startDaemon(opts) {
17
67
  polling: false,
18
68
  repos: opts.repos,
19
69
  intervalMs: opts.intervalMs,
70
+ pollEnabled: true,
71
+ autoReview: opts.autoReview,
72
+ trustedRuns: false,
20
73
  polls: 0,
21
74
  errors: 0,
22
75
  lastPollAt: null,
23
76
  nextPollAt: new Date(Date.now()).toISOString(),
24
77
  lastSummary: null,
78
+ lastPollError: null,
25
79
  autoSend: opts.autoSend,
26
80
  autoSendThreshold: opts.autoSendThreshold,
27
81
  autoSent: 0,
28
82
  autoSendCandidates: 0,
29
83
  };
30
84
  const log = (m) => console.log(`[daemon] ${m}`);
85
+ /** When GitHub last confirmed an artifact's PR state, by artifact id. In-memory:
86
+ * a restart just re-checks sooner, which is harmless. */
87
+ const stateCheckedAt = new Map();
88
+ async function discover() {
89
+ const filters = opts.repos.length > 0 ? opts.repos : [undefined];
90
+ const seen = new Set();
91
+ return (await Promise.all(filters.map((repo) => searchAwaitingMe(repo))))
92
+ .flat()
93
+ .filter((r) => {
94
+ const id = artifactId(r);
95
+ if (seen.has(id))
96
+ return false;
97
+ seen.add(id);
98
+ return true;
99
+ });
100
+ }
101
+ /**
102
+ * Make the local queue mirror what's actually awaiting: stub artifacts for
103
+ * newly discovered PRs, archive merged/closed ones, drop stubs whose review
104
+ * request went away. Freshness decisions always come from local artifacts —
105
+ * search results lag GitHub by minutes and never override them.
106
+ */
107
+ async function syncQueue(refs) {
108
+ let discovered = 0;
109
+ for (const ref of refs) {
110
+ if (await loadArtifact(artifactId(ref)))
111
+ continue;
112
+ await saveArtifact(stubArtifact(ref));
113
+ discovered++;
114
+ }
115
+ const awaitingIds = new Set(refs.map((r) => artifactId(r)));
116
+ let refreshed = 0;
117
+ for (const artifact of await listArtifacts()) {
118
+ if (artifact.status === "running")
119
+ continue;
120
+ if (awaitingIds.has(artifact.id))
121
+ continue;
122
+ if (artifact.pr.state !== "OPEN")
123
+ continue;
124
+ if (isPureStub(artifact)) {
125
+ // Discovered earlier, gone from the search now: merged/closed (archive
126
+ // it), or the request was withdrawn / handled elsewhere (drop it — it
127
+ // holds no work, and search lag at worst re-creates it next poll).
128
+ try {
129
+ const pr = await fetchPrInfo(artifact.pr);
130
+ if (pr.state !== "OPEN") {
131
+ await updateArtifactByKey(artifactKey(artifact.id), (a) => ({ ...a, pr }));
132
+ log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
133
+ }
134
+ else {
135
+ await deleteArtifact(artifact.id);
136
+ log(`[${artifact.id}] no longer awaiting your review — removed from the queue`);
137
+ }
138
+ }
139
+ catch {
140
+ // GitHub unreachable — leave the stub; next poll retries.
141
+ }
142
+ continue;
143
+ }
144
+ // Anything with real work in it: periodically confirm the PR is still
145
+ // open, so merged/closed ones stop cluttering the queue. Capped and
146
+ // spaced out — this is book-keeping, not a data feed.
147
+ if (refreshed >= STATE_REFRESH_CAP)
148
+ continue;
149
+ const last = stateCheckedAt.get(artifact.id) ?? 0;
150
+ if (Date.now() - last < STATE_REFRESH_MIN_MS)
151
+ continue;
152
+ refreshed++;
153
+ stateCheckedAt.set(artifact.id, Date.now());
154
+ try {
155
+ const pr = await fetchPrInfo(artifact.pr);
156
+ if (pr.state !== "OPEN") {
157
+ await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
158
+ ...a,
159
+ pr: { ...a.pr, state: pr.state },
160
+ }));
161
+ log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
162
+ }
163
+ }
164
+ catch {
165
+ // Same: book-keeping can wait for the next poll.
166
+ }
167
+ }
168
+ return { discovered };
169
+ }
170
+ async function reviewAll(refs) {
171
+ let reviewed = 0;
172
+ let skipped = 0;
173
+ let failed = 0;
174
+ await pool(refs, opts.parallel, async (ref) => {
175
+ const label = artifactId(ref);
176
+ try {
177
+ const result = await reviewPr(ref, {
178
+ model: opts.model,
179
+ withSource: opts.withSource,
180
+ trust: opts.trust,
181
+ onProgress: (m) => log(`[${label}] ${m}`),
182
+ });
183
+ if (result.skipped)
184
+ skipped++;
185
+ else {
186
+ reviewed++;
187
+ await handleAutoSend(result.artifact);
188
+ }
189
+ }
190
+ catch (err) {
191
+ if (err instanceof ReviewInProgressError) {
192
+ // The cockpit (or an earlier, slower poll) is already on it.
193
+ skipped++;
194
+ return;
195
+ }
196
+ failed++;
197
+ status.errors++;
198
+ log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
199
+ }
200
+ });
201
+ return { reviewed, skipped, failed };
202
+ }
31
203
  async function poll() {
32
204
  if (status.polling)
33
205
  return;
34
206
  status.polling = true;
35
207
  status.lastPollAt = new Date().toISOString();
36
208
  try {
37
- const filters = opts.repos.length > 0 ? opts.repos : [undefined];
38
- const seen = new Set();
39
- const refs = (await Promise.all(filters.map((repo) => searchAwaitingMe(repo))))
40
- .flat()
41
- .filter((r) => {
42
- const id = `${r.owner}/${r.repo}#${r.number}`;
43
- if (seen.has(id))
44
- return false;
45
- seen.add(id);
46
- return true;
47
- });
48
- let reviewed = 0;
49
- let skipped = 0;
50
- let failed = 0;
51
- await pool(refs, opts.parallel, async (ref) => {
52
- const label = `${ref.owner}/${ref.repo}#${ref.number}`;
53
- try {
54
- const result = await reviewPr(ref, {
55
- model: opts.model,
56
- onProgress: (m) => log(`[${label}] ${m}`),
57
- });
58
- if (result.skipped)
59
- skipped++;
60
- else {
61
- reviewed++;
62
- await handleAutoSend(result.artifact);
63
- }
64
- }
65
- catch (err) {
66
- if (err instanceof ReviewInProgressError) {
67
- // The cockpit (or an earlier, slower poll) is already on it.
68
- skipped++;
69
- return;
70
- }
71
- failed++;
72
- status.errors++;
73
- log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
74
- }
75
- });
76
- status.lastSummary = `${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date${failed > 0 ? `, ${failed} failed` : ""}`;
209
+ // The config is re-read every poll, so the cockpit's Settings toggles
210
+ // take effect on the next tick — no restart. CLI --no-* flags cap it.
211
+ // loadConfig is strict on purpose (a typo must not silently change trust
212
+ // or the knobs), so a broken file skips the tick loudly instead of
213
+ // proceeding on guessed defaults.
214
+ const config = await loadConfig();
215
+ const knobs = config.daemon;
216
+ const autoReview = opts.autoReview && knobs.autoReview;
217
+ status.pollEnabled = knobs.poll;
218
+ status.autoReview = autoReview;
219
+ // Recomputed too, so a trust rule added mid-run shows up in the strip.
220
+ status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
221
+ if (!knobs.poll) {
222
+ // Deliberately off is not an outage — don't leave a stale error up.
223
+ status.lastPollError = null;
224
+ status.lastSummary = "polling off (settings) — the queue only shows what you review by hand";
225
+ return;
226
+ }
227
+ const refs = await discover();
228
+ status.lastPollError = null;
229
+ const { discovered } = await syncQueue(refs);
230
+ if (autoReview) {
231
+ const { reviewed, skipped, failed } = await reviewAll(refs);
232
+ status.lastSummary =
233
+ `${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date` +
234
+ `${failed > 0 ? `, ${failed} failed` : ""}`;
235
+ }
236
+ else {
237
+ status.lastSummary = `${refs.length} awaiting${discovered > 0 ? `, ${discovered} new` : ""} (auto-review off)`;
238
+ }
77
239
  log(status.lastSummary);
78
240
  }
79
241
  catch (err) {
80
242
  status.errors++;
81
- status.lastSummary = `poll failed: ${err instanceof Error ? err.message : err}`;
243
+ status.lastPollError = err instanceof Error ? err.message : String(err);
244
+ status.lastSummary = `poll failed: ${status.lastPollError}`;
82
245
  log(status.lastSummary);
83
246
  }
84
247
  finally {
@@ -9,7 +9,10 @@ import { randomUUID } from "node:crypto";
9
9
  import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
10
10
  import { toMarkdown } from "../core/export.js";
11
11
  import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
12
+ import { z } from "zod";
13
+ import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
12
14
  import { refreshArtifact } from "../core/refresh.js";
15
+ import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
13
16
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
14
17
  import { listArtifacts, loadArtifactByKey, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
15
18
  import { isReviewRunning } from "../runner/inflight.js";
@@ -34,6 +37,91 @@ export async function buildApp(opts) {
34
37
  });
35
38
  }
36
39
  app.get("/api/daemon", (c) => c.json(opts.daemon ? opts.daemon.status() : { enabled: false }));
40
+ // ---- Config: trust rules, edited from the cockpit's Settings view ----
41
+ const trustView = (lines) => lines.flatMap((line) => {
42
+ try {
43
+ const rule = parseTrustRule(line);
44
+ return rule ? [{ rule: describeRule(rule), explanation: explainRule(rule), denies: rule.negated }] : [];
45
+ }
46
+ catch {
47
+ // loadConfig rejects these, so reaching here means the file changed
48
+ // under us; showing the rest beats failing the whole screen.
49
+ return [];
50
+ }
51
+ });
52
+ app.get("/api/config", async (c) => {
53
+ try {
54
+ const config = await loadConfig();
55
+ return c.json({ path: configPath(), trust: trustView(config.trust), daemon: config.daemon });
56
+ }
57
+ catch (err) {
58
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
59
+ }
60
+ });
61
+ // The inbox knobs. The daemon re-reads the config every poll, so a toggle
62
+ // here applies on the next tick without restarting serve.
63
+ app.post("/api/config/daemon", async (c) => {
64
+ let body;
65
+ try {
66
+ body = await c.req.json();
67
+ }
68
+ catch {
69
+ return c.json({ error: "body must be JSON" }, 400);
70
+ }
71
+ try {
72
+ const config = await loadConfig();
73
+ const daemon = DaemonConfigSchema.parse({ ...config.daemon, ...body });
74
+ await saveConfig({ ...config, daemon });
75
+ return c.json({ path: configPath(), trust: trustView(config.trust), daemon });
76
+ }
77
+ catch (err) {
78
+ if (err instanceof z.ZodError) {
79
+ return c.json({ error: err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }, 400);
80
+ }
81
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
82
+ }
83
+ });
84
+ app.post("/api/config/trust", async (c) => {
85
+ let body;
86
+ try {
87
+ body = await c.req.json();
88
+ }
89
+ catch {
90
+ return c.json({ error: "body must be JSON" }, 400);
91
+ }
92
+ const { rule, remove } = body;
93
+ if (typeof rule !== "string" || !rule.trim()) {
94
+ return c.json({ error: "rule is required" }, 400);
95
+ }
96
+ let parsed;
97
+ try {
98
+ parsed = parseTrustRule(rule);
99
+ }
100
+ catch (err) {
101
+ // The message explains what to write instead — show it verbatim.
102
+ if (err instanceof TrustRuleError)
103
+ return c.json({ error: err.message }, 400);
104
+ throw err;
105
+ }
106
+ if (!parsed)
107
+ return c.json({ error: `not a trust rule: ${rule}` }, 400);
108
+ const canonical = describeRule(parsed);
109
+ try {
110
+ const config = await loadConfig();
111
+ const without = config.trust.filter((line) => {
112
+ const existing = parseTrustRule(line);
113
+ return !existing || describeRule(existing) !== canonical;
114
+ });
115
+ const trust = remove === true ? without : [...without, canonical];
116
+ await saveConfig({ ...config, trust });
117
+ // Full ConfigView — the cockpit replaces its config state with this
118
+ // wholesale, so omitting daemon would crash the Settings toggles.
119
+ return c.json({ path: configPath(), trust: trustView(trust), daemon: config.daemon });
120
+ }
121
+ catch (err) {
122
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
123
+ }
124
+ });
37
125
  app.get("/api/reviews", async (c) => {
38
126
  const artifacts = await listArtifacts();
39
127
  return c.json(artifacts.map((a) => ({
@@ -48,6 +136,7 @@ export async function buildApp(opts) {
48
136
  owner: a.pr.owner,
49
137
  repo: a.pr.repo,
50
138
  number: a.pr.number,
139
+ state: a.pr.state,
51
140
  additions: a.pr.additions,
52
141
  deletions: a.pr.deletions,
53
142
  changedFiles: a.pr.changedFiles,
@@ -188,15 +277,32 @@ export async function buildApp(opts) {
188
277
  return c.json({ error: "a review of this PR is already running" }, 409);
189
278
  }
190
279
  const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
280
+ // Source-backed unless the server was started with --no-source; ?source=1
281
+ // overrides that per re-review, ?source=0 opts one out.
282
+ const sourceParam = c.req.query("source");
283
+ const withSource = sourceParam == null ? opts.withSource : sourceParam !== "0";
191
284
  // Mark it running before responding, so the cockpit's next poll can't catch
192
285
  // the old "ready" status and conclude the run already finished.
193
286
  const running = await updateArtifactByKey(key, (a) => ({
194
287
  ...a,
195
288
  status: "running",
196
- run: { model: null, startedAt: new Date().toISOString(), finishedAt: null, costUsd: null, error: null },
289
+ run: {
290
+ model: null,
291
+ startedAt: new Date().toISOString(),
292
+ finishedAt: null,
293
+ costUsd: null,
294
+ error: null,
295
+ withSource: withSource !== false,
296
+ trusted: false,
297
+ },
197
298
  }));
198
299
  // Minutes-long: run it detached and let the cockpit poll the artifact.
199
- void reviewPr(ref, { force: true, onProgress: (m) => console.log(`[rerun ${artifact.id}] ${m}`) })
300
+ void reviewPr(ref, {
301
+ force: true,
302
+ withSource,
303
+ trust: opts.trust,
304
+ onProgress: (m) => console.log(`[rerun ${artifact.id}] ${m}`),
305
+ })
200
306
  .catch(async (err) => {
201
307
  const message = err instanceof Error ? err.message : String(err);
202
308
  console.error(`[rerun ${artifact.id}] failed: ${message}`);
@@ -281,7 +387,10 @@ export async function startServer(opts) {
281
387
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
282
388
  const tokenHint = opts.token ? `/?token=${opts.token}` : "";
283
389
  console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
284
- if (opts.daemon)
285
- console.log("daemon: polling for PRs awaiting your review");
390
+ if (opts.daemon) {
391
+ const s = opts.daemon.status();
392
+ console.log(`inbox: polling every ${Math.round(s.intervalMs / 60_000)}m` +
393
+ `${s.autoReview ? ", drafting a review for each PR awaiting you" : " (auto-review off — click review in the queue)"}`);
394
+ }
286
395
  });
287
396
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.7.0",
3
+ "version": "0.9.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
  "repository": "github:fullstackhouse/cerber",