prreviewbuddy 0.31.0 → 0.36.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,170 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.36.3
4
+
5
+ **The reviews list is one click away from anywhere.** The strip at the top of every workspace page
6
+ now opens it, beside the notifications bell. Until now the only way back to the list was the rail,
7
+ which a narrow window hides, so from a review page it was the browser's Back button or nothing.
8
+
9
+ Every workspace place now has an icon up there, so the text links that used to appear in the strip
10
+ on a narrow window are gone: each of them was the same destination as the icon beside it.
11
+
12
+ ## 0.36.2
13
+
14
+ The note saying where your settings are kept now ends the settings, above the Server section rather
15
+ than below it. Between Stop server and the foot of the page it read as a footnote about the server,
16
+ which is the one thing on that page it says nothing about.
17
+
18
+ ## 0.36.1
19
+
20
+ **A status dot says whether the workspace server is there.** It sits beside the restart button:
21
+ green while the server is answering, amber while a restart is in flight, red once nothing is. It is
22
+ small on purpose — while you are reading a page, the page itself is the proof the server is up, so
23
+ the dot earns its place in the other two states.
24
+
25
+ **A restart now says it worked.** The page comes back with "Server restarted. Workspace is back
26
+ online." rather than simply blinking. There is still nothing to confirm beforehand: it is one click
27
+ when the server is idle.
28
+
29
+ If a restart does not come back, the page says so and stays saying so. It tells the two cases apart:
30
+ a restart the server refused before stopping anything leaves it running and says that, and only a
31
+ restart that actually lost the server names the command that brings it back.
32
+
33
+ ## 0.36.0
34
+
35
+ **The workspace can restart its own server.** A restart button sits in the global strip on every
36
+ page: the running server starts its replacement, hands over the port and goes, and the page reloads
37
+ itself onto the new one. It needs no terminal, and because the replacement is read from disk it is
38
+ how you pick up a new build without leaving the browser. While the server is making a review of its
39
+ own the button is disabled and says why, since that work would end with the process; a review being
40
+ made from a terminal is a different process and never stands in the way.
41
+
42
+ If the replacement cannot be started, nothing is stopped. The server refuses before it tears
43
+ anything down, keeps serving, and the page says so rather than reloading into nothing.
44
+
45
+ **Stop server has moved to Settings.** It used to sit at the foot of the rail on every page, one
46
+ press from ending every open review, next to the links you navigate with. It is the one thing the
47
+ workspace cannot undo — the page it leaves behind has no server to ask — so it now lives on the
48
+ Settings page with the sentence that matters beside it: start it again with `prreviewbuddy open`,
49
+ from any directory. The rail is navigation again, and the strip offers the recoverable action
50
+ instead.
51
+
52
+ `prreviewbuddy open` has always worked from any directory and starts the server if it is not
53
+ running; it is now tested to keep doing so. The workspace is global; only reviews belong to a
54
+ repository.
55
+
56
+ ## 0.35.0
57
+
58
+ **Notifications have a home in the top-right corner.** A bell sits in the workspace's new global
59
+ strip, on every page including a review, and shows a dot when something is waiting. It was reachable
60
+ only from the foot of the rail before, which is not where anybody looks for it. The bell opens the
61
+ Notifications page and marks nothing read: that still happens only by a deliberate act. How many are
62
+ waiting is still said in words in the rail, which is where there is room to say it; the dot is only
63
+ the news that there is something to look at.
64
+
65
+ The caret on a menu is ours now rather than the browser's. The native one sat hard against the
66
+ control's right edge in a colour used nowhere else in the product; this one is inset by the same
67
+ distance the text has on the other side, and drawn in the muted grey everything quiet here shares.
68
+
69
+ **Settings is in that strip too**, as an icon beside the bell, reachable from any page without
70
+ going to the rail for it. It still sits in the rail with its name beside it.
71
+
72
+ **The workspace has two bars instead of one.** The product's own controls and the review's identity
73
+ used to share a single header, which is why there was no room for the bell. They are separated now:
74
+ a thin strip carries the wordmark, the bell and the build, and the bar below it belongs entirely to
75
+ what you are looking at. On a review that is the branch, the size, the freshness and Mark reviewed,
76
+ exactly as before but with the product's chrome lifted out of it. On the other pages it names the
77
+ page and carries that page's own controls, which have moved up out of the column they act on rather
78
+ than being added: the reviews search and filters, the Notifications count and Mark all read, and the
79
+ way back out of Settings.
80
+
81
+ The build version now sits in the strip on every page, including a review, where it used to be at
82
+ the foot of the rail.
83
+
84
+ Two things that had never worked in a window too narrow for the rail, both found by looking at the
85
+ real thing. The workspace links meant to replace the hidden rail were never shown at all, so at that
86
+ width there was no way to Notifications, Settings or the feedback form. And the reviews list,
87
+ Notifications and Settings were squeezed into the width the rail had vacated, with the rest of the
88
+ window empty beside them: the same fault the review itself had, fixed there in 0.34.0 and missed
89
+ here.
90
+
91
+ ## 0.34.0
92
+
93
+ **The Guide shows one theme at a time.** The workspace Guide used to stack every theme in one
94
+ column, so reading the second one meant scrolling past the whole of the first. The rail already
95
+ lists the themes, so it now drives the page: choosing one shows that theme on its own, and the
96
+ rail says how long each is likely to take. The theme tag on an issue or question opens its theme,
97
+ which it always named and never did. In a window too narrow for the rail, the same themes appear
98
+ above the theme itself.
99
+
100
+ A theme now reads in the order the work happens: why it matters, then what to check, then the files
101
+ it touches. That order is better on both surfaces, so the browser extension's Guide gets it too, and
102
+ is unchanged otherwise. Its parts stand apart rather than running together, and the theme itself is
103
+ no longer drawn as a card: with one theme on the page, the outline had nothing on the other side of
104
+ it. Nothing folds a theme away any more either, since there is now nothing behind it to reveal.
105
+
106
+ **The rail says what is left, in words.** A theme row gives the time it takes and, when something is
107
+ still open on it, how much: "~22 min, 1 of 2 issues left". That count used to be a bare digit whose
108
+ meaning only a screen reader was told. The file count has gone from the rail, which is what leaves
109
+ room to spell the rest out. Clicking the words "Review path" folds the list, not just the caret.
110
+
111
+ **The Overview's numbers read as numbers.** Each stat card leads with the figure and sets the label
112
+ beside it, so the four cards line up whether or not one of them carries a second line.
113
+
114
+ Two things that were wrong wherever you looked. The document pane was painted a slightly different
115
+ grey from the page behind it, leaving a seam down every view. And in a window too narrow for the
116
+ rail, the review was squeezed into the width the rail had vacated, with half the window empty beside
117
+ it.
118
+
119
+ The progress line no longer counts what it cannot change: completing a theme is part of guided
120
+ review, which the workspace does not have, so the Guide states the size of the job rather than
121
+ reporting nothing done.
122
+
123
+ ## 0.33.0
124
+
125
+ **A type scale, a palette for the review, and fewer boxes.** Text in the workspace now comes from
126
+ seven named sizes rather than fifteen ad-hoc ones, so a heading, a sentence, a piece of metadata and
127
+ a status chip differ by size instead of by weight, and ordinary text is ordinary weight again. The
128
+ review itself now has its own light palette: three greys where one grey had been written several
129
+ ways, a single hairline where six different border shades were drawing the same line, and one purple
130
+ where there were five. Seven panels that were a box drawn inside another box have lost their
131
+ outline and sit on the page as white cards against a slightly deeper background. Purple now marks
132
+ what you have selected, what has keyboard focus, and the main action on a page, so hovering a row no
133
+ longer shouts as loudly as choosing one. The browser extension is unchanged.
134
+
135
+ ## 0.32.1
136
+
137
+ **The rail on every workspace page.** The reviews list, Notifications and Settings now carry the
138
+ same rail a review does, so Reviews, Notifications and Settings are always one click away without
139
+ opening a review first. A review's rail keeps everything it had, with the workspace places beneath
140
+ it. The unread count sits beside Notifications wherever the rail is. In a window too narrow for the
141
+ rail, the same places appear in the header instead.
142
+
143
+ ## 0.32.0
144
+
145
+ **Notifications.** When a review, reanalysis, update or KISS analysis finishes or fails, PR Review
146
+ Buddy now records it on a new Notifications page in the workspace, so you can start a review, or a
147
+ batch of them, get on with something else, and see what finished when you come back. The reviews
148
+ list shows how many are unread, and every review's rail links to the page. Opening a notification
149
+ takes you to its review and marks it read, and Mark all read clears the rest; visiting the review
150
+ some other way leaves it unread. A review you stop with Ctrl-C is not recorded as a failure.
151
+ Notifications are kept for thirty days, on this machine only, and nothing about them is sent
152
+ anywhere.
153
+
154
+ **Toasts in the workspace.** While any workspace page is open, a notification also appears for a
155
+ few seconds in the bottom-right corner, with Open beside it. It stays while your pointer is on it,
156
+ and no more than three show at once: when a batch finishes together, the last one says how many more
157
+ there are, with View all. Letting a toast go or dismissing it leaves the notification unread on the
158
+ Notifications page.
159
+
160
+ **Closing the terminal no longer strands a review.** The CLI used to say a review continues if its
161
+ terminal closes. It did not: the review stopped with the terminal, and running the command again
162
+ said it was still being made for the next half hour. The message now asks you to keep the terminal
163
+ open, and running the same command again picks the review up at once from the step it reached.
164
+
165
+ Also fixed: a review picked up part way through its analysis showed that step as finished while it
166
+ was still running.
167
+
3
168
  ## 0.31.0
4
169
 
5
170
  **Review a single commit.** `prreviewbuddy review --commit <commit>` reviews the change one commit
package/README.md CHANGED
@@ -82,6 +82,8 @@ prreviewbuddy agents which coding agents you have
82
82
  prreviewbuddy config set agent <agent> remember which to use when you do not name one
83
83
  prreviewbuddy config set telemetry-upload on|off
84
84
  send usage telemetry, or stop. On by default
85
+ prreviewbuddy config get port the port the workspace runs on
86
+ prreviewbuddy config set port <port> move it, from the next start onwards
85
87
 
86
88
  prreviewbuddy uninstall remove everything this put on your machine
87
89
  ```
@@ -113,9 +115,11 @@ started with.
113
115
  The job is durable: closing the terminal does not stop it, and running the same command again after
114
116
  an interruption carries on from the phase that stopped rather than starting over.
115
117
 
116
- Reviews are kept in `~/.prreviewbuddy` for thirty days. A local server holds them open and outlives
117
- the terminal that started it, taking a new port each time it starts, so bookmark
118
- `file://~/.prreviewbuddy/reviews.html` rather than a port number.
118
+ Reviews are kept in `~/.prreviewbuddy` for thirty days. A local server holds them open at
119
+ `http://localhost:7726` and outlives the terminal that started it, so a link to a review is worth
120
+ keeping: the address does not move, and the same link still opens after a restart. Run
121
+ `prreviewbuddy open` from any directory to get back to the list, and
122
+ `prreviewbuddy config set port <port>` if 7726 is taken on your machine.
119
123
 
120
124
  The isolated checkout a review was made in is kept for six hours after it finishes, because that is
121
125
  what the assistant reads when you ask it questions, and is then removed.
@@ -2759,7 +2759,7 @@ async function resolveTarget(input) {
2759
2759
  ...named
2760
2760
  };
2761
2761
  }
2762
- const branch = await branchOf(root);
2762
+ const branch = await branchOf$1(root);
2763
2763
  let sha;
2764
2764
  try {
2765
2765
  sha = (await git(root, ["rev-parse", "HEAD"])).trim();
@@ -2941,7 +2941,7 @@ async function refuseEmptyTarget(target) {
2941
2941
  async function uncommittedIn(root, target) {
2942
2942
  if (target.pullRequest) return 0;
2943
2943
  try {
2944
- if ((await git(root, ["rev-parse", "HEAD"])).trim() !== target.sha || await branchOf(root) !== target.branch) return 0;
2944
+ if ((await git(root, ["rev-parse", "HEAD"])).trim() !== target.sha || await branchOf$1(root) !== target.branch) return 0;
2945
2945
  return (await git(root, ["status", "--porcelain"])).split("\n").filter((line) => line.trim()).length;
2946
2946
  } catch {
2947
2947
  return 0;
@@ -3023,7 +3023,7 @@ async function repositoryRoot(repoPath) {
3023
3023
  } catch {}
3024
3024
  throw new TargetError(`${repoPath} is not inside a git repository.`);
3025
3025
  }
3026
- async function branchOf(root) {
3026
+ async function branchOf$1(root) {
3027
3027
  let branch = "";
3028
3028
  try {
3029
3029
  branch = (await git(root, [
@@ -4766,6 +4766,18 @@ function isClaimed(jobId, now = Date.now()) {
4766
4766
  return liveClaim(jobId, now) !== null;
4767
4767
  }
4768
4768
  /**
4769
+ * Whether a driver took this job and is no longer there to finish it.
4770
+ *
4771
+ * The claim file is the evidence. A job minted a moment ago and not yet driven has no claim at all,
4772
+ * and a driver that finished released its claim on the way out, so a claim that is still on disk
4773
+ * with nobody live behind it can only belong to a run that ended without finishing: a terminal that
4774
+ * closed, a process that was killed. Answered at once rather than after the sweep's half hour,
4775
+ * because the reviewer asking is usually the one who just closed that terminal.
4776
+ */
4777
+ function driverGone(jobId, now = Date.now()) {
4778
+ return readClaim(jobId) !== null && liveClaim(jobId, now) === null;
4779
+ }
4780
+ /**
4769
4781
  * Take the claim on a job, or return null because another driver holds it.
4770
4782
  *
4771
4783
  * Two retries fired together, a retry racing the run `startJob` already kicked off, or a retry
@@ -5034,7 +5046,7 @@ function deleteJob(id) {
5034
5046
  }
5035
5047
  function advance(job, phase, progress) {
5036
5048
  const leaving = job.phase;
5037
- const completed = WORK_PHASES.includes(leaving) && !job.completed.includes(leaving) ? [...job.completed, leaving] : job.completed;
5049
+ const completed = WORK_PHASES.includes(leaving) && leaving !== phase && !job.completed.includes(leaving) ? [...job.completed, leaving] : job.completed;
5038
5050
  return {
5039
5051
  ...job,
5040
5052
  phase,
@@ -6972,7 +6984,7 @@ async function waitForPortToFree(port) {
6972
6984
  async function ensureServer() {
6973
6985
  const running = await healthyPort();
6974
6986
  if (running !== null) return running;
6975
- if (!existsSync(SERVER_ENTRY)) throw new Error(`The workspace server is not built. Expected it at ${SERVER_ENTRY}.${buildInstruction(SURFACE.client)}`);
6987
+ if (!serverEntryExists()) throw new Error(`The workspace server is not built. Expected it at ${serverEntry()}.${buildInstruction(SURFACE.client)}`);
6976
6988
  if (!acquireSpawnLock()) {
6977
6989
  const shared = await waitForHealthyServer();
6978
6990
  if (shared !== null) return shared;
@@ -6991,11 +7003,7 @@ async function ensureServer() {
6991
7003
  }
6992
7004
  if (occupant === "foreign") throw new Error(portTakenMessage(port));
6993
7005
  clearServerRecord();
6994
- spawn(process.execPath, [SERVER_ENTRY], {
6995
- detached: true,
6996
- stdio: "ignore",
6997
- cwd: STORE_ROOT
6998
- }).unref();
7006
+ spawnServer();
6999
7007
  const started = await waitForHealthyServer();
7000
7008
  if (started !== null) return started;
7001
7009
  if (await occupantOf(port) === "foreign") throw new Error(portTakenMessage(port));
@@ -7005,6 +7013,32 @@ async function ensureServer() {
7005
7013
  }
7006
7014
  }
7007
7015
  /**
7016
+ * Start a detached workspace server and let go of it.
7017
+ *
7018
+ * One function rather than one per caller: the launcher starts the first server, and a server being
7019
+ * restarted starts its own replacement, and those must be the same process in the same place with
7020
+ * the same entry — a second spawn written separately is how the two quietly drift apart.
7021
+ *
7022
+ * `serverEntry()` is read at call time rather than captured, so a restart after a rebuild picks up
7023
+ * the new file at the same path, which is the main reason anybody wants one.
7024
+ */
7025
+ function spawnServer() {
7026
+ const child = spawn(process.execPath, [serverEntry()], {
7027
+ detached: true,
7028
+ stdio: "ignore",
7029
+ cwd: STORE_ROOT
7030
+ });
7031
+ child.unref();
7032
+ return child;
7033
+ }
7034
+ /** Whether there is anything to start. The one foreseeable reason a restart cannot be attempted. */
7035
+ function serverEntryExists() {
7036
+ return existsSync(serverEntry());
7037
+ }
7038
+ function serverEntry() {
7039
+ return SERVER_ENTRY;
7040
+ }
7041
+ /**
7008
7042
  * The recorded server's port, if it is actually answering *and* is this build's.
7009
7043
  *
7010
7044
  * A server from another version is stopped rather than left alone. Two servers cannot both hold
@@ -9493,6 +9527,229 @@ async function withUsageRecorded(workspaceId, kind, env, body, runId, requestedM
9493
9527
  }
9494
9528
  }
9495
9529
  //#endregion
9530
+ //#region ../../packages/review-harness/src/workspace/notifications.ts
9531
+ /**
9532
+ * What happened while the reviewer was looking at something else, kept until they have seen it.
9533
+ *
9534
+ * **One file per notification, not one list.** Notifications are written by whichever process
9535
+ * drove the work to its end: the CLI for a review typed at a terminal, the workspace server for one
9536
+ * started from the page, the sweep for one whose driver died. A single `notifications.json` read,
9537
+ * modified and written by several processes loses whichever entry was written second, and the lost
9538
+ * one is by definition something the reviewer was not watching. `jobs/` is laid out the same way for
9539
+ * the same reason.
9540
+ *
9541
+ * **Created once, with `wx`.** The id is derived from the transition it records, so a second attempt
9542
+ * to record the same transition fails at the filesystem and returns null, and a caller that only
9543
+ * delivers what it created cannot deliver twice. Nothing that reads a job ever creates one: a page
9544
+ * reloading, a client polling or a review being reopened cannot announce anything.
9545
+ *
9546
+ * Imports only `store_root`, for the cycle `queue.ts` describes.
9547
+ */
9548
+ var NOTIFICATIONS_DIR = join(STORE_ROOT, "notifications");
9549
+ /** As long as a review is kept, so the list never names a review nobody can open for that reason. */
9550
+ var NOTIFICATION_RETENTION_MS = 2592e6;
9551
+ var OPERATION_NAMES = {
9552
+ review: "Review",
9553
+ reanalysis: "Reanalysis",
9554
+ update: "Review update",
9555
+ kiss: "KISS analysis"
9556
+ };
9557
+ /** What happened, in the words the list uses: `Review ready`, `Review update failed`. */
9558
+ function eventName(notification) {
9559
+ return `${OPERATION_NAMES[notification.operation]} ${notification.outcome}`;
9560
+ }
9561
+ /** The whole line, for anything that shows one event on its own: what happened, then to what. */
9562
+ function headline(notification) {
9563
+ return `${eventName(notification)} · ${notification.subject}`;
9564
+ }
9565
+ var OPERATIONS = [
9566
+ "review",
9567
+ "reanalysis",
9568
+ "update",
9569
+ "kiss"
9570
+ ];
9571
+ var OUTCOMES = ["ready", "failed"];
9572
+ /** An id is a file name, so it is held to a shape that cannot leave the directory. */
9573
+ var ID = /^[A-Za-z0-9-]{1,200}$/;
9574
+ function pathOf(id) {
9575
+ return join(NOTIFICATIONS_DIR, `${id}.json`);
9576
+ }
9577
+ /**
9578
+ * Record one event, or return null because it is already recorded or could not be written.
9579
+ *
9580
+ * Never throws. A notification that could not be written is a notification the reviewer does not
9581
+ * get, which is a smaller loss than the review job that was being announced.
9582
+ */
9583
+ function createNotification(input, now = Date.now()) {
9584
+ if (!ID.test(input.id)) return null;
9585
+ const record = {
9586
+ version: 1,
9587
+ kind: "analysis",
9588
+ ...input,
9589
+ createdAt: now
9590
+ };
9591
+ try {
9592
+ mkdirSync(NOTIFICATIONS_DIR, { recursive: true });
9593
+ writeFileSync(pathOf(input.id), JSON.stringify(record), {
9594
+ flag: "wx",
9595
+ mode: 384
9596
+ });
9597
+ return record;
9598
+ } catch {
9599
+ return null;
9600
+ }
9601
+ }
9602
+ /** Every notification, newest first. Unreadable and malformed files are skipped, not reported. */
9603
+ function listNotifications() {
9604
+ let names;
9605
+ try {
9606
+ names = readdirSync(NOTIFICATIONS_DIR);
9607
+ } catch {
9608
+ return [];
9609
+ }
9610
+ const found = [];
9611
+ for (const name of names) {
9612
+ if (!name.endsWith(".json")) continue;
9613
+ const record = readNotification(name.slice(0, -5));
9614
+ if (record) found.push(record);
9615
+ }
9616
+ return found.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : 1));
9617
+ }
9618
+ function readNotification(id) {
9619
+ if (!ID.test(id)) return null;
9620
+ try {
9621
+ const parsed = JSON.parse(readFileSync(pathOf(id), "utf8"));
9622
+ return isNotification(parsed) && parsed.id === id ? parsed : null;
9623
+ } catch {
9624
+ return null;
9625
+ }
9626
+ }
9627
+ function unreadCount() {
9628
+ return listNotifications().filter((notification) => notification.readAt === void 0).length;
9629
+ }
9630
+ /**
9631
+ * Mark these read, and say how many changed.
9632
+ *
9633
+ * Only ever by something the reviewer did to the notification itself: opening it, or one of the two
9634
+ * mark buttons. Opening the review it points at is not the same act and does not come through here.
9635
+ */
9636
+ function markRead(ids, now = Date.now()) {
9637
+ let changed = 0;
9638
+ for (const id of ids) {
9639
+ const record = readNotification(id);
9640
+ if (!record || record.readAt !== void 0) continue;
9641
+ try {
9642
+ writeFileSync(pathOf(id), JSON.stringify({
9643
+ ...record,
9644
+ readAt: now
9645
+ }), { mode: 384 });
9646
+ changed += 1;
9647
+ } catch {}
9648
+ }
9649
+ return changed;
9650
+ }
9651
+ function markAllRead(now = Date.now()) {
9652
+ return markRead(listNotifications().filter((notification) => notification.readAt === void 0).map((notification) => notification.id), now);
9653
+ }
9654
+ /**
9655
+ * Forget what is older than the retention, and anything past the cap, oldest first.
9656
+ *
9657
+ * Files that do not parse are removed once they are as old as the retention, judged by their
9658
+ * modification time, so a stray file neither lingers for ever nor disappears the moment it is seen.
9659
+ */
9660
+ function pruneNotifications(now = Date.now()) {
9661
+ let names;
9662
+ try {
9663
+ names = readdirSync(NOTIFICATIONS_DIR);
9664
+ } catch {
9665
+ return 0;
9666
+ }
9667
+ const kept = listNotifications();
9668
+ const keep = new Set(kept.filter((notification) => now - notification.createdAt < NOTIFICATION_RETENTION_MS).slice(0, 200).map((notification) => `${notification.id}.json`));
9669
+ const valid = new Set(kept.map((notification) => `${notification.id}.json`));
9670
+ let removed = 0;
9671
+ for (const name of names) {
9672
+ if (!name.endsWith(".json") || keep.has(name)) continue;
9673
+ const path = join(NOTIFICATIONS_DIR, name);
9674
+ try {
9675
+ if (!valid.has(name) && now - statSync(path).mtimeMs < 2592e6) continue;
9676
+ unlinkSync(path);
9677
+ removed += 1;
9678
+ } catch {}
9679
+ }
9680
+ return removed;
9681
+ }
9682
+ function isNotification(value) {
9683
+ if (!value || typeof value !== "object") return false;
9684
+ const record = value;
9685
+ return record.version === 1 && record.kind === "analysis" && typeof record.id === "string" && OPERATIONS.includes(record.operation) && OUTCOMES.includes(record.outcome) && typeof record.reviewId === "string" && typeof record.subject === "string" && (record.branch === void 0 || typeof record.branch === "string") && typeof record.repo === "string" && typeof record.createdAt === "number" && (record.readAt === void 0 || typeof record.readAt === "number");
9686
+ }
9687
+ //#endregion
9688
+ //#region ../../packages/review-harness/src/workspace/announce.ts
9689
+ /**
9690
+ * Tell the reviewer a job ended, once, after the ending has been written.
9691
+ *
9692
+ * Called by the three places that write an ending: `drive` in `review_job.ts`, `updateReview`, and
9693
+ * the sweep for a job whose driver died. Each calls it with the record it has just saved, in the
9694
+ * process that saved it, so the answer never depends on who happens to be looking.
9695
+ *
9696
+ * The id is the job and the moment it ended, so two calls about the same ending make one record,
9697
+ * and a job picked up again that ends later is a new event with its own.
9698
+ *
9699
+ * The record is the event. The Notifications page and the workspace's toasts both read it. Any
9700
+ * delivery that has to be pushed rather than read belongs here, after `createNotification`, and only
9701
+ * for a record this call created, which carries the once-only guarantee over to it for free. Desktop
9702
+ * delivery is written that way and parked outside the product: see `desktop_preference.ts`.
9703
+ *
9704
+ * Never throws. This runs on the success path of a review that has already been saved, and the
9705
+ * accounting must not break the thing it accounts for.
9706
+ */
9707
+ function announceJob(job) {
9708
+ try {
9709
+ if (!isTerminal(job) || job.failure?.stopped || purposeOf(job) === "ask") return;
9710
+ const workspace = loadWorkspace(job.workspaceId);
9711
+ if (!workspace) return;
9712
+ const branch = branchOf(workspace);
9713
+ createNotification({
9714
+ id: `${job.id}-${endedAt(job)}`,
9715
+ operation: operationOf(job),
9716
+ outcome: job.phase === "done" ? "ready" : "failed",
9717
+ reviewId: workspace.id,
9718
+ subject: subjectOf(workspace),
9719
+ ...branch ? { branch } : {},
9720
+ repo: basename(workspace.repoPath)
9721
+ });
9722
+ } catch {}
9723
+ }
9724
+ function operationOf(job) {
9725
+ if (job.reviewType === "kiss") return "kiss";
9726
+ if (purposeOf(job) === "update") return "update";
9727
+ return job.reanalysis ? "reanalysis" : "review";
9728
+ }
9729
+ /**
9730
+ * What was reviewed, as the reviewer would name it, and nothing that locates it on disk.
9731
+ *
9732
+ * A pull request by its number, spelled the way its forge spells it; a commit by its short sha; a
9733
+ * branch by the ref it follows, which is the same rule the reviews list and the CLI use.
9734
+ */
9735
+ function subjectOf(workspace) {
9736
+ const changeSet = workspace.changeSet;
9737
+ if (changeSet.targetType === "commit") return `commit ${changeSet.data.pullRequest.head.sha.slice(0, 7)}`;
9738
+ const number = changeSet.data.pullRequest?.number || workspace.prContext?.pullRequest.number;
9739
+ if (number) return /\/merge_requests\//.test(workspace.prContext?.pullRequest.url ?? "") ? `MR !${number}` : `PR #${number}`;
9740
+ return followedRefName(changeSet.headRef, changeSet.headResolvedRef) ?? changeSet.headRef;
9741
+ }
9742
+ /**
9743
+ * The branch beside a pull request's number, which alone does not say what the change is. Nothing
9744
+ * for a branch review, whose subject already is the branch, or for a commit, which has none.
9745
+ */
9746
+ function branchOf(workspace) {
9747
+ const changeSet = workspace.changeSet;
9748
+ if (changeSet.targetType === "commit") return void 0;
9749
+ if (!(changeSet.data.pullRequest?.number || workspace.prContext?.pullRequest.number)) return void 0;
9750
+ return (followedRefName(changeSet.headRef, changeSet.headResolvedRef) ?? changeSet.headRef) || void 0;
9751
+ }
9752
+ //#endregion
9496
9753
  //#region ../../packages/review-harness/src/telemetry/consent.ts
9497
9754
  /**
9498
9755
  * Who this machine is counted as, and what it has agreed to.
@@ -9815,6 +10072,7 @@ async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel)
9815
10072
  target,
9816
10073
  env: agent.captureEnv(),
9817
10074
  purpose: "review",
10075
+ reanalysis: true,
9818
10076
  ...model ? { requestedModel: model } : {}
9819
10077
  });
9820
10078
  saveJob(job);
@@ -10030,6 +10288,7 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
10030
10288
  else await applyGuide(job, workspaceId, payload, observedModel);
10031
10289
  job = update(job, "done");
10032
10290
  finished(job, workspaceId, startedAt, true);
10291
+ announceJob(job);
10033
10292
  return job;
10034
10293
  } catch (error) {
10035
10294
  if (error instanceof AgentCancelledError || signal?.aborted) {
@@ -10043,6 +10302,7 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
10043
10302
  job = recordFailure(job, explain(job, raw), driver, kind, raw);
10044
10303
  if (job.reviewType === "kiss") failCurrentKissRun(workspaceId, job, job.failure?.message ?? raw, kind, error instanceof KissRejected ? error.answer : void 0);
10045
10304
  finished(job, workspaceId, startedAt, false);
10305
+ announceJob(job);
10046
10306
  return job;
10047
10307
  }
10048
10308
  }
@@ -11221,10 +11481,14 @@ async function updateReview(workspace, runners = {}) {
11221
11481
  const step = (phase) => saveJob(advance(current(), phase));
11222
11482
  try {
11223
11483
  const outcome = await bringForward(workspace, runners, reviewedSha, held, step);
11224
- saveJob(outcome.stop === "analysis-failed" ? fail(current(), outcome.message) : advance(current(), "done", outcome.message));
11484
+ const ended = outcome.stop === "analysis-failed" ? fail(current(), outcome.message) : advance(current(), "done", outcome.message);
11485
+ saveJob(ended);
11486
+ announceJob(ended);
11225
11487
  return outcome;
11226
11488
  } catch (error) {
11227
- saveJob(fail(current(), error instanceof Error ? error.message : String(error)));
11489
+ const failed = fail(current(), error instanceof Error ? error.message : String(error));
11490
+ saveJob(failed);
11491
+ announceJob(failed);
11228
11492
  throw error;
11229
11493
  } finally {
11230
11494
  held.claim.release();
@@ -11814,4 +12078,4 @@ function lineageIds(id) {
11814
12078
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
11815
12079
  }
11816
12080
  //#endregion
11817
- export { fillFileUrlTemplate as $, writeAgentPreference as $t, removeWorktree as A, workspaceRevision as At, workspaceUrl as B, isQuestionOutstanding as Bt, recordTelemetryUploadConsent as C, previousKissResult as Ct, ReviewBeingDeletedError as D, saveWorkspace as Dt, EXPLAIN_SIMPLY_PROMPT as E, reviewedRepositories as Et, clearServerRecord as F, queueStamp as Ft, DEFAULT_WORKSPACE_PORT as G, describeAuthorship as Gt, BUILD_VERSION as H, questionsOutstanding as Ht, ensureServer as I, readQueue as It, parseWorkspacePort as J, git as Jt, MAX_WORKSPACE_PORT as K, resolveTarget as Kt, readServerRecord as L, writeQueue as Lt, relativeTime as M, dequeueOnComplete as Mt, readIndexToken as N, queueAdd as Nt, MANAGED_ROOT as O, summarise as Ot, bootstrapUrl as P, queueRemove as Pt, forgeResolver as Q, readAgentPreference as Qt, reviewsUrl as R, doneVerb as Rt, record as S, positionInLineage as St, withUsageRecorded as T, reviewedCommit as Tt, PACKAGE_NAME as U, reviewerDispositions as Ut, writeServerRecord as V, issuesOutstanding as Vt, feedbackUrl as W, processDiscussion as Wt, statedWorkspacePort as X, resolveModel as Xt, resolveWorkspacePort as Y, resolveAgent as Yt, writePortPreference as Z, clearAgentPreference as Zt, startKissJob as _, latestKissRun as _t, wasBlocked as a, agentFor as an, isTerminal as at, startJob as b, loadWorkspace as bt, updateReview as c, AgentUnavailableError as cn, PHASES as ct, askCheckout as d, purposeOf as dt, CONFIG_PATH as en, checkCodeFreshness as et, UpdateAlreadyRunningError as f, clearClaim as ft, recordKissRun as g, indexReviewGroups as gt, agentEnvOf as h, groupByLineage as ht, liveJobsFor as i, agentById as in, fail as it, modelHelpLines as j, workspaceStamps as jt, readMarker as k, touchWorkspace as kt, refreshPrContext as l, phasesFor as lt, runningJobs as m, followedRefName as mt, deleteReview as n, AGENT_IDS as nn, allJobs as nt, discardJob as o, detectAgents as on, loadJob as ot, liveUpdateFor as p, isClaimed as pt, MIN_WORKSPACE_PORT as q, displayRef as qt, lineageIds as r, DEFAULT_AGENT_ID as rn, endedAt as rt, RefreshUnavailableError as s, AgentCancelledError as sn, saveJob as st, deleteLineage as t, STORE_ROOT as tn, checkReview as tt, isUnchanged as u, progressSteps as ut, reanalyseReview as v, lineageKeyFor as vt, telemetryUploadConsent as w, recentWorkspaces as wt, readEvents as x, matchingWorkspaceIds as xt, runJob as y, lineagePosition as yt, stopServer as z, isIssueOutstanding as zt };
12081
+ export { BUILD_VERSION as $, issuesOutstanding as $t, markRead as A, lineageKeyFor as At, modelHelpLines as B, summarise as Bt, recordTelemetryUploadConsent as C, clearClaim as Ct, headline as D, groupByLineage as Dt, eventName as E, followedRefName as Et, EXPLAIN_SIMPLY_PROMPT as F, previousKissResult as Ft, ensureServer as G, queueAdd as Gt, readIndexToken as H, workspaceRevision as Ht, ReviewBeingDeletedError as I, recentWorkspaces as It, serverEntryExists as J, readQueue as Jt, readServerRecord as K, queueRemove as Kt, MANAGED_ROOT as L, reviewedCommit as Lt, readNotification as M, loadWorkspace as Mt, unreadCount as N, matchingWorkspaceIds as Nt, listNotifications as O, indexReviewGroups as Ot, withUsageRecorded as P, positionInLineage as Pt, writeServerRecord as Q, isQuestionOutstanding as Qt, readMarker as R, reviewedRepositories as Rt, record as S, purposeOf as St, announceJob as T, isClaimed as Tt, bootstrapUrl as U, workspaceStamps as Ut, relativeTime as V, touchWorkspace as Vt, clearServerRecord as W, dequeueOnComplete as Wt, stopServer as X, doneVerb as Xt, spawnServer as Y, writeQueue as Yt, workspaceUrl as Z, isIssueOutstanding as Zt, startKissJob as _, agentFor as _n, loadJob as _t, wasBlocked as a, displayRef as an, parseWorkspacePort as at, startJob as b, AgentUnavailableError as bn, phasesFor as bt, updateReview as c, resolveModel as cn, writePortPreference as ct, askCheckout as d, writeAgentPreference as dn, checkCodeFreshness as dt, questionsOutstanding as en, PACKAGE_NAME as et, UpdateAlreadyRunningError as f, CONFIG_PATH as fn, checkReview as ft, recordKissRun as g, agentById as gn, isTerminal as gt, agentEnvOf as h, DEFAULT_AGENT_ID as hn, fail as ht, liveJobsFor as i, resolveTarget as in, MIN_WORKSPACE_PORT as it, pruneNotifications as j, lineagePosition as jt, markAllRead as k, latestKissRun as kt, refreshPrContext as l, clearAgentPreference as ln, forgeResolver as lt, runningJobs as m, AGENT_IDS as mn, endedAt as mt, deleteReview as n, processDiscussion as nn, DEFAULT_WORKSPACE_PORT as nt, discardJob as o, git as on, resolveWorkspacePort as ot, liveUpdateFor as p, STORE_ROOT as pn, allJobs as pt, reviewsUrl as q, queueStamp as qt, lineageIds as r, describeAuthorship as rn, MAX_WORKSPACE_PORT as rt, RefreshUnavailableError as s, resolveAgent as sn, statedWorkspacePort as st, deleteLineage as t, reviewerDispositions as tn, feedbackUrl as tt, isUnchanged as u, readAgentPreference as un, fillFileUrlTemplate as ut, reanalyseReview as v, detectAgents as vn, saveJob as vt, telemetryUploadConsent as w, driverGone as wt, readEvents as x, progressSteps as xt, runJob as y, AgentCancelledError as yn, PHASES as yt, removeWorktree as z, saveWorkspace as zt };