@fullstackhouse/cerber 0.29.0 → 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 +24 -11
- package/assets/cerber-icon.svg +21 -0
- package/assets/cerber.icns +0 -0
- package/dist/cli/index.js +9 -2
- package/dist/core/artifact.js +26 -0
- package/dist/core/config.js +4 -3
- package/dist/core/notify.js +411 -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 +106 -22
- package/dist/server/index.js +85 -6
- package/package.json +2 -1
- package/web/dist/assets/{index-DNaKDgSy.js → index-LX8NvM54.js} +64 -64
- package/web/dist/index.html +1 -1
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,
|
|
@@ -538,6 +541,8 @@ export function startDaemon(opts) {
|
|
|
538
541
|
}
|
|
539
542
|
/** Said once, not every poll: a machine with no notifier will never grow one. */
|
|
540
543
|
let notifierMissing = false;
|
|
544
|
+
/** Null until `serve` is listening and says so — see `cockpitAt`. */
|
|
545
|
+
let cockpitUrl = opts.cockpitUrl ?? null;
|
|
541
546
|
/**
|
|
542
547
|
* What the last attempt to tap this machine actually did, or null before one
|
|
543
548
|
* has been made.
|
|
@@ -551,20 +556,48 @@ export function startDaemon(opts) {
|
|
|
551
556
|
*/
|
|
552
557
|
let notifierWorks = null;
|
|
553
558
|
/**
|
|
554
|
-
* Tap this machine
|
|
559
|
+
* Tap this machine about every row that has become worth walking back to,
|
|
560
|
+
* once each.
|
|
555
561
|
*
|
|
556
|
-
*
|
|
557
|
-
* first
|
|
558
|
-
* that arrived while `serve` was down is
|
|
559
|
-
*
|
|
560
|
-
*
|
|
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.
|
|
561
575
|
*/
|
|
562
|
-
async function announce(
|
|
563
|
-
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);
|
|
564
585
|
if (!n)
|
|
565
586
|
return;
|
|
566
|
-
|
|
567
|
-
|
|
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)
|
|
568
601
|
return;
|
|
569
602
|
if (notifierMissing)
|
|
570
603
|
return;
|
|
@@ -599,6 +632,49 @@ export function startDaemon(opts) {
|
|
|
599
632
|
}
|
|
600
633
|
});
|
|
601
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
|
+
}
|
|
602
678
|
async function reviewAll(refs) {
|
|
603
679
|
let reviewed = 0;
|
|
604
680
|
let skipped = 0;
|
|
@@ -628,7 +704,9 @@ export function startDaemon(opts) {
|
|
|
628
704
|
}
|
|
629
705
|
failed++;
|
|
630
706
|
status.errors++;
|
|
631
|
-
|
|
707
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
708
|
+
await recordEarlyFailure(label, message);
|
|
709
|
+
log(`[${label}] failed: ${message}`);
|
|
632
710
|
}
|
|
633
711
|
});
|
|
634
712
|
return { reviewed, skipped, failed };
|
|
@@ -677,16 +755,6 @@ export function startDaemon(opts) {
|
|
|
677
755
|
// A failed poll leaves the last good list up; lastPollError says so.
|
|
678
756
|
status.awaiting = await classifyAll(refs);
|
|
679
757
|
const { discovered } = await syncQueue(refs);
|
|
680
|
-
// Before the reviews, not after: the arrival is the news, and drafting it
|
|
681
|
-
// takes minutes. This is the same moment the cockpit's bell would ring.
|
|
682
|
-
if (notifyOn && discovered.length > 0) {
|
|
683
|
-
await announce(discovered);
|
|
684
|
-
// Recomputed on the spot rather than left to the next poll: that is
|
|
685
|
-
// minutes away, and the cockpit would spend them deferring to a tap
|
|
686
|
-
// that never came. Recomputed rather than only cleared, so a machine
|
|
687
|
-
// whose notifier comes back takes the job back with it.
|
|
688
|
-
status.notify = announcing();
|
|
689
|
-
}
|
|
690
758
|
if (autoReview) {
|
|
691
759
|
const { reviewed, skipped, failed } = await reviewAll(refs);
|
|
692
760
|
status.lastSummary =
|
|
@@ -696,6 +764,19 @@ export function startDaemon(opts) {
|
|
|
696
764
|
else {
|
|
697
765
|
status.lastSummary = `${refs.length} awaiting${discovered.length > 0 ? `, ${discovered.length} new` : ""} (auto-review off)`;
|
|
698
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();
|
|
699
780
|
log(status.lastSummary);
|
|
700
781
|
}
|
|
701
782
|
catch (err) {
|
|
@@ -756,6 +837,9 @@ export function startDaemon(opts) {
|
|
|
756
837
|
void poll();
|
|
757
838
|
return {
|
|
758
839
|
status: () => ({ ...status }),
|
|
840
|
+
cockpitAt: (url) => {
|
|
841
|
+
cockpitUrl = url;
|
|
842
|
+
},
|
|
759
843
|
stop: () => {
|
|
760
844
|
stopped = true;
|
|
761
845
|
if (timer)
|
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
|
}
|
|
@@ -690,6 +733,39 @@ export async function buildApp(opts) {
|
|
|
690
733
|
}
|
|
691
734
|
return app;
|
|
692
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* The wildcard binds, and the loopback address each one answers on.
|
|
738
|
+
*
|
|
739
|
+
* Only these two are translated. A bind to `::1`, `localhost` or a particular
|
|
740
|
+
* interface is already an address that answers here, and rewriting it would
|
|
741
|
+
* point the click somewhere nothing is listening — `serve --host ::1` serves
|
|
742
|
+
* IPv6 loopback alone, where `127.0.0.1` does not connect at all. `::` maps to
|
|
743
|
+
* IPv6 loopback rather than IPv4, because a dual-stack socket is the only
|
|
744
|
+
* reason `127.0.0.1` would work on one and it is not guaranteed to be one.
|
|
745
|
+
*/
|
|
746
|
+
const WILDCARD = new Map([
|
|
747
|
+
["0.0.0.0", "127.0.0.1"],
|
|
748
|
+
["::", "::1"],
|
|
749
|
+
]);
|
|
750
|
+
/**
|
|
751
|
+
* The cockpit's own address, as reached from the machine `serve` runs on — what
|
|
752
|
+
* a desktop notification opens when you click it.
|
|
753
|
+
*
|
|
754
|
+
* It is the bound host only when that host is a particular interface; a
|
|
755
|
+
* wildcard bind is every interface, and the one that always answers from here
|
|
756
|
+
* is loopback. The token rides along because a click has to land on the review
|
|
757
|
+
* rather than on a 401, and it is the same token `serve` already prints to the
|
|
758
|
+
* console on startup. Null where there is no address to name yet — port 0 is
|
|
759
|
+
* whatever the OS picks, which isn't known until it has picked it.
|
|
760
|
+
*/
|
|
761
|
+
export function cockpitUrl(opts) {
|
|
762
|
+
if (!Number.isInteger(opts.port) || opts.port <= 0)
|
|
763
|
+
return null;
|
|
764
|
+
const host = WILDCARD.get(opts.host) ?? opts.host;
|
|
765
|
+
const authority = host.includes(":") ? `[${host}]` : host;
|
|
766
|
+
const query = opts.token ? `?token=${encodeURIComponent(opts.token)}` : "";
|
|
767
|
+
return `http://${authority}:${opts.port}/${query}`;
|
|
768
|
+
}
|
|
693
769
|
/**
|
|
694
770
|
* Serve the cockpit. `reconcileRunning` is deliberately *not* called here: the
|
|
695
771
|
* caller must have done it before starting the daemon, because the daemon polls
|
|
@@ -701,6 +777,9 @@ export async function startServer(opts) {
|
|
|
701
777
|
serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
|
|
702
778
|
const tokenHint = opts.token ? `/?token=${opts.token}` : "";
|
|
703
779
|
console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
|
|
780
|
+
// The bound port, not the asked-for one: `--port 0` is the OS's to choose,
|
|
781
|
+
// and this callback is the first moment anyone knows what it chose.
|
|
782
|
+
opts.daemon?.cockpitAt(cockpitUrl({ ...opts, port: info.port }));
|
|
704
783
|
if (opts.daemon) {
|
|
705
784
|
const s = opts.daemon.status();
|
|
706
785
|
console.log(`inbox: polling every ${Math.round(s.intervalMs / 60_000)}m` +
|
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)",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
18
|
"web/dist",
|
|
19
|
+
"assets",
|
|
19
20
|
"README.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|