getgloss 0.7.2 → 0.8.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.
@@ -1,4 +1,5 @@
1
1
  // src/server/daemon.ts
2
+ import { rm as rm3 } from "fs/promises";
2
3
  import { serve } from "@hono/node-server";
3
4
 
4
5
  // src/shared/paths.ts
@@ -9,7 +10,7 @@ import path from "path";
9
10
  // package.json
10
11
  var package_default = {
11
12
  name: "getgloss",
12
- version: "0.7.2",
13
+ version: "0.8.1",
13
14
  description: "Local browser-based diff review for coding-agent loops.",
14
15
  type: "module",
15
16
  packageManager: "pnpm@10.33.2",
@@ -40,6 +41,8 @@ var package_default = {
40
41
  },
41
42
  dependencies: {
42
43
  "@hono/node-server": "1.19.14",
44
+ "@shikijs/langs": "4.1.0",
45
+ "@shikijs/themes": "4.1.0",
43
46
  "@tailwindcss/vite": "4.3.0",
44
47
  commander: "14.0.3",
45
48
  execa: "9.6.1",
@@ -49,6 +52,7 @@ var package_default = {
49
52
  open: "10.2.0",
50
53
  react: "19.2.6",
51
54
  "react-dom": "19.2.6",
55
+ shiki: "4.1.0",
52
56
  ulid: "3.0.2",
53
57
  zustand: "5.0.13"
54
58
  },
@@ -108,6 +112,27 @@ function globalReviewsDir() {
108
112
  function globalReviewDir(reviewId) {
109
113
  return path.join(globalReviewsDir(), reviewId);
110
114
  }
115
+ function globalReviewTurnsDir(reviewId) {
116
+ return path.join(globalReviewDir(reviewId), "turns");
117
+ }
118
+ function globalReviewTurnDir(reviewId, turnId) {
119
+ return path.join(globalReviewTurnsDir(reviewId), turnId);
120
+ }
121
+ function globalReviewTurnMetaFile(reviewId, turnId) {
122
+ return path.join(globalReviewTurnDir(reviewId, turnId), "turn.json");
123
+ }
124
+ function globalReviewTurnDiffFile(reviewId, turnId) {
125
+ return path.join(globalReviewTurnDir(reviewId, turnId), "diff.json");
126
+ }
127
+ function globalReviewTurnFeedbackFile(reviewId, turnId) {
128
+ return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.json");
129
+ }
130
+ function globalReviewTurnMarkdownFile(reviewId, turnId) {
131
+ return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.md");
132
+ }
133
+ function globalReviewTurnResolvedFile(reviewId, turnId) {
134
+ return path.join(globalReviewTurnDir(reviewId, turnId), "resolved.json");
135
+ }
111
136
  function globalReviewMetaFile(reviewId) {
112
137
  return path.join(globalReviewDir(reviewId), "meta.json");
113
138
  }
@@ -130,23 +155,49 @@ async function ensureDir(dir) {
130
155
  // src/shared/server-info.ts
131
156
  import { readFile } from "fs/promises";
132
157
 
158
+ // src/shared/errors.ts
159
+ function formatError(error) {
160
+ return error instanceof Error ? error.message : String(error);
161
+ }
162
+ function isFileNotFound(error) {
163
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
164
+ }
165
+
133
166
  // src/shared/json.ts
134
- import { writeFile } from "fs/promises";
167
+ import { randomUUID } from "crypto";
168
+ import { rename, rm, writeFile } from "fs/promises";
169
+ import path2 from "path";
135
170
  function serializeJson(value) {
136
171
  return `${JSON.stringify(value, null, 2)}
137
172
  `;
138
173
  }
139
174
  async function writeJsonFile(filePath, value) {
140
- await writeFile(filePath, serializeJson(value));
175
+ await writeTextFile(filePath, serializeJson(value));
176
+ }
177
+ async function writeTextFile(filePath, value) {
178
+ const tempPath = path2.join(
179
+ path2.dirname(filePath),
180
+ `.${path2.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
181
+ );
182
+ try {
183
+ await writeFile(tempPath, value);
184
+ await rename(tempPath, filePath);
185
+ } catch (error) {
186
+ await rm(tempPath, { force: true }).catch(() => void 0);
187
+ throw error;
188
+ }
141
189
  }
142
190
 
191
+ // src/shared/types.ts
192
+ var SIDES = ["L", "R"];
193
+ var REVIEW_STATUSES = ["pending", "submitted", "cancelled", "resolved"];
194
+ var DIFF_LINE_TYPES = ["context", "add", "delete"];
195
+ var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
196
+ var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
197
+ var REVIEW_SCOPE_MODES = ["all", "single", "range"];
198
+ var RESOLUTION_STATUSES = ["partial", "resolved"];
199
+
143
200
  // src/shared/validation.ts
144
- var reviewStatuses = ["pending", "submitted", "cancelled", "resolved"];
145
- var resolutionStatuses = ["partial", "resolved"];
146
- var sides = ["L", "R"];
147
- var diffLineTypes = ["context", "add", "delete"];
148
- var diffScopeModes = ["working", "branch", "explicit"];
149
- var diffFallbackReasons = ["working-tree-clean", "missing-branch-base"];
150
201
  function parseJson(raw, guard, label) {
151
202
  const parsed = JSON.parse(raw);
152
203
  return parseJsonValue(parsed, guard, label);
@@ -157,26 +208,53 @@ function parseJsonValue(value, guard, label) {
157
208
  }
158
209
  return value;
159
210
  }
211
+ function isServerInfo(value) {
212
+ return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
213
+ }
214
+ function isClearReviewsRequest(value) {
215
+ return isRecord(value) && isOptionalNonNegativeInteger(value.olderThanDays) && isOptionalBoolean(value.dryRun);
216
+ }
217
+ function isOpenFileRequest(value) {
218
+ return isRecord(value) && isString(value.filePath) && isOptionalString(value.turnId);
219
+ }
220
+ function isCommitRangeDiffRequest(value) {
221
+ return isRecord(value) && isString(value.fromSha) && isString(value.toSha) && isOptionalString(value.turnId);
222
+ }
160
223
  function isSubmitReviewRequest(value) {
161
- return isRecord(value) && isArrayOf(value.comments, isComment);
224
+ return isRecord(value) && isArrayOf(value.comments, isComment) && isOptional(value.reviewScope, isReviewScope);
162
225
  }
163
226
  function isResolutionRequest(value) {
164
- return isRecord(value) && isOptionalString(value.summary);
227
+ return isRecord(value) && isOptionalString(value.summary) && isOptionalString(value.turn);
165
228
  }
166
229
  function isStoredReviewMeta(value) {
167
- return isRecord(value) && isString(value.id) && isString(value.cwd) && isBaseRef(value.base) && isNullableString(value.branch) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isOptionalString(value.artifactDir) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
230
+ return isRecord(value) && isString(value.id) && isString(value.cwd) && isBaseRef(value.base) && isNullableString(value.branch) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isOptionalString(value.artifactDir) && isOptionalString(value.activeTurnId) && isOptional(
231
+ value.turns,
232
+ (turns) => isArrayOf(turns, isReviewTurnSummary)
233
+ ) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
168
234
  }
169
235
  function isDiffPayload(value) {
170
- return isRecord(value) && isBaseRef(value.base) && isNullableString(value.branch) && isString(value.cwd) && isDiffScope(value.scope) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile) && isString(value.capturedAt);
236
+ return isRecord(value) && isBaseRef(value.base) && isNullableString(value.branch) && isString(value.cwd) && isDiffScope(value.scope) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile) && isOptional(
237
+ value.commitDiffs,
238
+ (commitDiffs) => isArrayOf(commitDiffs, isCommitDiff)
239
+ ) && isString(value.capturedAt);
171
240
  }
172
241
  function isFeedbackBundle(value) {
173
- return isRecord(value) && value.version === 1 && isString(value.reviewId) && isString(value.timestamp) && isBaseRef(value.base) && isNullableString(value.branch) && isArrayOf(value.comments, isComment);
242
+ return isRecord(value) && value.version === 1 && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isString(value.timestamp) && isBaseRef(value.base) && isNullableString(value.branch) && isOptional(value.reviewScope, isReviewScope) && isArrayOf(value.comments, isComment);
174
243
  }
175
244
  function isResolutionBundle(value) {
176
- return isRecord(value) && isString(value.reviewId) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
245
+ return isRecord(value) && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
246
+ }
247
+ function isReviewTurnMeta(value) {
248
+ return isRecord(value) && isString(value.id) && isNumber(value.index) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isString(value.artifactDir) && isString(value.diffPath) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath) && isOptionalString(value.resolvedPath);
249
+ }
250
+ function isReviewTurnSummary(value) {
251
+ if (!isRecord(value) || !isReviewTurnMeta(value)) {
252
+ return false;
253
+ }
254
+ return isString(value.capturedAt) && isDiffStats(value.stats) && isResolutionCounts(value.comments);
177
255
  }
178
256
  function isDiffScope(value) {
179
- return isRecord(value) && isOneOf(value.mode, diffScopeModes) && isNullableString(value.requestedBase) && isBaseRef(value.base) && isDiffRef(value.comparison) && (value.fallbackReason === null || isOneOf(value.fallbackReason, diffFallbackReasons));
257
+ return isRecord(value) && isOneOf(value.mode, DIFF_SCOPE_MODES) && isNullableString(value.requestedBase) && isBaseRef(value.base) && isDiffRef(value.comparison) && (value.fallbackReason === null || isOneOf(value.fallbackReason, DIFF_FALLBACK_REASONS));
180
258
  }
181
259
  function isDiffRef(value) {
182
260
  return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
@@ -187,6 +265,25 @@ function isBaseRef(value) {
187
265
  function isDiffStats(value) {
188
266
  return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
189
267
  }
268
+ function isDiffCommit(value) {
269
+ return isRecord(value) && isString(value.sha) && isString(value.shortSha) && isString(value.subject) && isString(value.authorName) && isString(value.authorEmail) && isString(value.authoredAt) && isString(value.committedAt);
270
+ }
271
+ function isCommitDiff(value) {
272
+ return isRecord(value) && isDiffCommit(value.commit) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile);
273
+ }
274
+ function isReviewScope(value) {
275
+ if (!isRecord(value) || !isOneOf(value.mode, REVIEW_SCOPE_MODES)) {
276
+ return false;
277
+ }
278
+ switch (value.mode) {
279
+ case "all":
280
+ return true;
281
+ case "single":
282
+ return isString(value.sha);
283
+ case "range":
284
+ return isString(value.fromSha) && isString(value.toSha);
285
+ }
286
+ }
190
287
  function isDiffFile(value) {
191
288
  return isRecord(value) && isString(value.path) && isNullableString(value.oldPath) && isNumber(value.additions) && isNumber(value.deletions) && isBoolean(value.isBinary) && isBoolean(value.isDeleted) && isBoolean(value.isNew) && isBoolean(value.isRenamed) && isNullableString(value.language) && isArrayOf(value.hunks, isDiffHunk);
192
289
  }
@@ -194,19 +291,22 @@ function isDiffHunk(value) {
194
291
  return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
195
292
  }
196
293
  function isDiffLine(value) {
197
- return isRecord(value) && isOneOf(value.type, diffLineTypes) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
294
+ return isRecord(value) && isOneOf(value.type, DIFF_LINE_TYPES) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
198
295
  }
199
296
  function isComment(value) {
200
- return isRecord(value) && isString(value.id) && isString(value.filePath) && isNumber(value.startLine) && isNumber(value.endLine) && isOneOf(value.side, sides) && isString(value.body) && isString(value.originalSnippet) && isString(value.createdAt);
297
+ return isRecord(value) && isString(value.id) && isString(value.filePath) && isNumber(value.startLine) && isNumber(value.endLine) && isOneOf(value.side, SIDES) && isString(value.body) && isString(value.originalSnippet) && isString(value.createdAt);
201
298
  }
202
299
  function isResolvedComment(value) {
203
300
  return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
204
301
  }
302
+ function isResolutionCounts(value) {
303
+ return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
304
+ }
205
305
  function isReviewStatus(value) {
206
- return isOneOf(value, reviewStatuses);
306
+ return isOneOf(value, REVIEW_STATUSES);
207
307
  }
208
308
  function isResolutionStatus(value) {
209
- return isOneOf(value, resolutionStatuses);
309
+ return isOneOf(value, RESOLUTION_STATUSES);
210
310
  }
211
311
  function isRecord(value) {
212
312
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -214,6 +314,9 @@ function isRecord(value) {
214
314
  function isArrayOf(value, guard) {
215
315
  return Array.isArray(value) && value.every(guard);
216
316
  }
317
+ function isOptional(value, guard) {
318
+ return value === void 0 || guard(value);
319
+ }
217
320
  function isString(value) {
218
321
  return typeof value === "string";
219
322
  }
@@ -226,6 +329,15 @@ function isNullableString(value) {
226
329
  function isNumber(value) {
227
330
  return typeof value === "number" && Number.isFinite(value);
228
331
  }
332
+ function isOptionalNumber(value) {
333
+ return value === void 0 || isNumber(value);
334
+ }
335
+ function isOptionalNonNegativeInteger(value) {
336
+ return value === void 0 || isNumber(value) && Number.isInteger(value) && value >= 0;
337
+ }
338
+ function isOptionalBoolean(value) {
339
+ return value === void 0 || isBoolean(value);
340
+ }
229
341
  function isNullableNumber(value) {
230
342
  return value === null || isNumber(value);
231
343
  }
@@ -237,14 +349,34 @@ function isOneOf(value, options) {
237
349
  }
238
350
 
239
351
  // src/shared/server-info.ts
352
+ async function readServerInfo() {
353
+ let raw;
354
+ try {
355
+ raw = await readFile(globalServerFile(), "utf8");
356
+ } catch (error) {
357
+ if (isFileNotFound(error)) {
358
+ return null;
359
+ }
360
+ throw new Error(`Could not read server info at ${globalServerFile()}: ${formatError(error)}`, {
361
+ cause: error
362
+ });
363
+ }
364
+ try {
365
+ return parseJson(raw, isServerInfo, "server info");
366
+ } catch (error) {
367
+ throw new Error(`Invalid server info at ${globalServerFile()}: ${formatError(error)}`, {
368
+ cause: error
369
+ });
370
+ }
371
+ }
240
372
  async function writeServerInfo(info) {
241
373
  await ensureDir(globalStateDir());
242
374
  await writeJsonFile(globalServerFile(), info);
243
375
  }
244
376
 
245
377
  // src/server/index.ts
246
- import { readFile as readFile3 } from "fs/promises";
247
- import path3 from "path";
378
+ import { readFile as readFile4, realpath, stat } from "fs/promises";
379
+ import path5 from "path";
248
380
  import { fileURLToPath } from "url";
249
381
  import { Hono } from "hono";
250
382
  import { streamSSE } from "hono/streaming";
@@ -276,17 +408,11 @@ function resolutionCounts(feedback, resolvedComments = []) {
276
408
  };
277
409
  }
278
410
 
279
- // src/shared/reviews.ts
280
- function isResolvableReviewStatus(status) {
281
- return status === "submitted" || status === "resolved";
282
- }
283
-
284
- // src/server/store.ts
285
- import { readdir, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
286
- import { ulid } from "ulid";
411
+ // src/shared/git-diff.ts
412
+ import { execa } from "execa";
287
413
 
288
414
  // src/shared/language.ts
289
- import path2 from "path";
415
+ import path3 from "path";
290
416
  var languageByExtension = {
291
417
  cjs: "js",
292
418
  css: "css",
@@ -308,13 +434,454 @@ var languageByExtension = {
308
434
  yml: "yaml"
309
435
  };
310
436
  function languageForPath(filePath) {
311
- const ext = path2.extname(filePath).slice(1).toLowerCase();
437
+ const ext = path3.extname(filePath).slice(1).toLowerCase();
312
438
  if (!ext) {
313
439
  return null;
314
440
  }
315
441
  return languageByExtension[ext] ?? ext;
316
442
  }
317
443
 
444
+ // src/shared/diff-parser.ts
445
+ var hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
446
+ function stripGitPath(input) {
447
+ return input.replace(/^[ab]\//, "");
448
+ }
449
+ function emptyFile() {
450
+ return {
451
+ path: "",
452
+ oldPath: null,
453
+ additions: 0,
454
+ deletions: 0,
455
+ isBinary: false,
456
+ isDeleted: false,
457
+ isNew: false,
458
+ isRenamed: false,
459
+ language: null,
460
+ hunks: []
461
+ };
462
+ }
463
+ function parseUnifiedDiff(diffText) {
464
+ const files = [];
465
+ let current = null;
466
+ let currentHunk = null;
467
+ let oldCursor = 0;
468
+ let newCursor = 0;
469
+ const finalizeFile = () => {
470
+ if (current?.path) {
471
+ current.language = languageForPath(current.path);
472
+ files.push(current);
473
+ }
474
+ };
475
+ for (const line of diffText.split("\n")) {
476
+ if (line.startsWith("diff --git ")) {
477
+ finalizeFile();
478
+ current = emptyFile();
479
+ currentHunk = null;
480
+ oldCursor = 0;
481
+ newCursor = 0;
482
+ const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
483
+ if (match) {
484
+ current.oldPath = match[1];
485
+ current.path = match[2];
486
+ }
487
+ continue;
488
+ }
489
+ if (!current) {
490
+ continue;
491
+ }
492
+ if (line.startsWith("new file mode")) {
493
+ current.isNew = true;
494
+ continue;
495
+ }
496
+ if (line.startsWith("deleted file mode")) {
497
+ current.isDeleted = true;
498
+ continue;
499
+ }
500
+ if (line.startsWith("rename from ")) {
501
+ current.oldPath = line.slice("rename from ".length);
502
+ current.isRenamed = true;
503
+ continue;
504
+ }
505
+ if (line.startsWith("rename to ")) {
506
+ current.path = line.slice("rename to ".length);
507
+ current.isRenamed = true;
508
+ continue;
509
+ }
510
+ if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch")) {
511
+ current.isBinary = true;
512
+ continue;
513
+ }
514
+ if (line.startsWith("--- ")) {
515
+ const oldPath = line.slice(4).trim();
516
+ current.oldPath = oldPath === "/dev/null" ? null : stripGitPath(oldPath);
517
+ continue;
518
+ }
519
+ if (line.startsWith("+++ ")) {
520
+ const newPath = line.slice(4).trim();
521
+ current.path = newPath === "/dev/null" ? current.oldPath ?? current.path : stripGitPath(newPath);
522
+ continue;
523
+ }
524
+ const hunkMatch = hunkHeaderPattern.exec(line);
525
+ if (hunkMatch) {
526
+ const oldStart = Number(hunkMatch[1]);
527
+ const oldLines = Number(hunkMatch[2] ?? "1");
528
+ const newStart = Number(hunkMatch[3]);
529
+ const newLines = Number(hunkMatch[4] ?? "1");
530
+ currentHunk = {
531
+ oldStart,
532
+ oldLines,
533
+ newStart,
534
+ newLines,
535
+ header: hunkMatch[5]?.trim() ?? "",
536
+ lines: []
537
+ };
538
+ current.hunks.push(currentHunk);
539
+ oldCursor = oldStart;
540
+ newCursor = newStart;
541
+ continue;
542
+ }
543
+ if (!currentHunk) {
544
+ continue;
545
+ }
546
+ const marker = line[0];
547
+ const content = line.slice(1);
548
+ let diffLine = null;
549
+ if (marker === "+") {
550
+ diffLine = { type: "add", oldLine: null, newLine: newCursor, content };
551
+ current.additions += 1;
552
+ newCursor += 1;
553
+ } else if (marker === "-") {
554
+ diffLine = { type: "delete", oldLine: oldCursor, newLine: null, content };
555
+ current.deletions += 1;
556
+ oldCursor += 1;
557
+ } else if (marker === " ") {
558
+ diffLine = { type: "context", oldLine: oldCursor, newLine: newCursor, content };
559
+ oldCursor += 1;
560
+ newCursor += 1;
561
+ } else if (line.startsWith("\")) {
562
+ continue;
563
+ }
564
+ if (diffLine) {
565
+ currentHunk.lines.push(diffLine);
566
+ }
567
+ }
568
+ finalizeFile();
569
+ return files;
570
+ }
571
+
572
+ // src/shared/diff-stats.ts
573
+ function summarizeDiffFiles(files) {
574
+ return files.reduce(
575
+ (stats, file) => ({
576
+ files: stats.files + 1,
577
+ additions: stats.additions + file.additions,
578
+ deletions: stats.deletions + file.deletions
579
+ }),
580
+ { files: 0, additions: 0, deletions: 0 }
581
+ );
582
+ }
583
+
584
+ // src/shared/git-diff.ts
585
+ var DIFF_ARGS = ["diff", "--no-color", "--find-renames", "--find-copies"];
586
+ async function git(args, cwd) {
587
+ const result = await execa("git", args, { cwd });
588
+ return result.stdout.trimEnd();
589
+ }
590
+ async function captureCommitRangeDiff(fromSha, toSha, repoRoot) {
591
+ const rawDiff = await git([...DIFF_ARGS, `${fromSha}^`, toSha, "--"], repoRoot);
592
+ const files = parseUnifiedDiff(rawDiff);
593
+ return {
594
+ stats: summarizeDiffFiles(files),
595
+ rawDiff,
596
+ files
597
+ };
598
+ }
599
+
600
+ // src/shared/reviews.ts
601
+ function isResolvableReviewStatus(status) {
602
+ return status === "submitted" || status === "resolved";
603
+ }
604
+
605
+ // src/server/local-open.ts
606
+ import open from "open";
607
+ async function openLocalPath(filePath) {
608
+ await open(filePath, { wait: false });
609
+ }
610
+
611
+ // src/server/store.ts
612
+ import { createHash } from "crypto";
613
+ import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
614
+ import path4 from "path";
615
+ import { ulid } from "ulid";
616
+
617
+ // src/shared/cleanup.ts
618
+ import { readdir, readFile as readFile2, rm as rm2 } from "fs/promises";
619
+ var DEFAULT_REVIEW_RETENTION_DAYS = 30;
620
+ var clearableStatuses = /* @__PURE__ */ new Set(["submitted", "resolved", "cancelled"]);
621
+ var millisecondsPerDay = 24 * 60 * 60 * 1e3;
622
+ async function clearReviewArtifacts(options = {}) {
623
+ const olderThanDays = normalizeRetentionDays(options.olderThanDays);
624
+ const dryRun = options.dryRun === true;
625
+ const now = options.now ?? /* @__PURE__ */ new Date();
626
+ const cutoff = new Date(now.getTime() - olderThanDays * millisecondsPerDay);
627
+ const reviewsDir = globalReviewsDir();
628
+ const candidates = [];
629
+ const deleted = [];
630
+ const skipped = [];
631
+ let entries;
632
+ try {
633
+ entries = await readdir(reviewsDir, { withFileTypes: true });
634
+ } catch (error) {
635
+ if (isFileNotFound(error)) {
636
+ return cleanupResult({
637
+ reviewsDir,
638
+ cutoff,
639
+ olderThanDays,
640
+ dryRun,
641
+ candidates,
642
+ deleted,
643
+ skipped
644
+ });
645
+ }
646
+ throw new Error(`Could not read reviews directory at ${reviewsDir}: ${formatError(error)}`, {
647
+ cause: error
648
+ });
649
+ }
650
+ for (const entry of entries) {
651
+ if (!entry.isDirectory()) {
652
+ continue;
653
+ }
654
+ const reviewId = entry.name;
655
+ const artifactDir = globalReviewDir(reviewId);
656
+ const candidate = await cleanupCandidate(reviewId, artifactDir, cutoff, skipped);
657
+ if (!candidate) {
658
+ continue;
659
+ }
660
+ candidates.push(candidate);
661
+ if (!dryRun) {
662
+ await rm2(artifactDir, { recursive: true, force: true });
663
+ deleted.push(candidate);
664
+ }
665
+ }
666
+ return cleanupResult({ reviewsDir, cutoff, olderThanDays, dryRun, candidates, deleted, skipped });
667
+ }
668
+ function normalizeRetentionDays(value) {
669
+ const days = value ?? DEFAULT_REVIEW_RETENTION_DAYS;
670
+ if (!Number.isInteger(days) || days < 0) {
671
+ throw new Error("olderThanDays must be a non-negative integer");
672
+ }
673
+ return days;
674
+ }
675
+ async function cleanupCandidate(reviewId, artifactDir, cutoff, skipped) {
676
+ let raw;
677
+ try {
678
+ raw = await readFile2(globalReviewMetaFile(reviewId), "utf8");
679
+ } catch (error) {
680
+ if (isFileNotFound(error)) {
681
+ skipped.push({ reviewId, artifactDir, reason: "missing metadata" });
682
+ return null;
683
+ }
684
+ skipped.push({ reviewId, artifactDir, reason: `unreadable metadata: ${formatError(error)}` });
685
+ return null;
686
+ }
687
+ let meta;
688
+ try {
689
+ meta = parseJson(raw, isStoredReviewMeta, "review metadata");
690
+ } catch (error) {
691
+ skipped.push({ reviewId, artifactDir, reason: `invalid metadata: ${formatError(error)}` });
692
+ return null;
693
+ }
694
+ if (meta.id !== reviewId) {
695
+ skipped.push({ reviewId, artifactDir, reason: `metadata id mismatch: ${meta.id}` });
696
+ return null;
697
+ }
698
+ if (!clearableStatuses.has(meta.status)) {
699
+ return null;
700
+ }
701
+ const turnState = await persistedTurnCleanupState(reviewId, artifactDir, skipped);
702
+ if (turnState === "preserve") {
703
+ return null;
704
+ }
705
+ const lastActivityAt = latestTimestamp([
706
+ ...metadataTimestamps(meta),
707
+ ...turnState === "none" ? [] : turnState.timestamps
708
+ ]);
709
+ if (!lastActivityAt) {
710
+ skipped.push({ reviewId, artifactDir, reason: "missing valid activity timestamp" });
711
+ return null;
712
+ }
713
+ if (Date.parse(lastActivityAt) >= cutoff.getTime()) {
714
+ return null;
715
+ }
716
+ return {
717
+ reviewId,
718
+ status: meta.status,
719
+ artifactDir,
720
+ lastActivityAt
721
+ };
722
+ }
723
+ async function persistedTurnCleanupState(reviewId, artifactDir, skipped) {
724
+ let entries;
725
+ try {
726
+ entries = await readdir(globalReviewTurnsDir(reviewId), { withFileTypes: true });
727
+ } catch (error) {
728
+ if (isFileNotFound(error)) {
729
+ return "none";
730
+ }
731
+ skipped.push({
732
+ reviewId,
733
+ artifactDir,
734
+ reason: `unreadable turns directory: ${formatError(error)}`
735
+ });
736
+ return "preserve";
737
+ }
738
+ const turnDirs = entries.filter((entry) => entry.isDirectory());
739
+ if (turnDirs.length === 0) {
740
+ return "none";
741
+ }
742
+ const timestamps = [];
743
+ for (const entry of turnDirs) {
744
+ const turn = await readPersistedTurnMeta(reviewId, entry.name, artifactDir, skipped);
745
+ if (!turn) {
746
+ return "preserve";
747
+ }
748
+ if (turn.status === "pending" || !clearableStatuses.has(turn.status)) {
749
+ return "preserve";
750
+ }
751
+ timestamps.push(turn.createdAt, turn.submittedAt, turn.resolvedAt);
752
+ }
753
+ return { timestamps };
754
+ }
755
+ async function readPersistedTurnMeta(reviewId, turnDirName, artifactDir, skipped) {
756
+ let raw;
757
+ try {
758
+ raw = await readFile2(globalReviewTurnMetaFile(reviewId, turnDirName), "utf8");
759
+ } catch (error) {
760
+ skipped.push({
761
+ reviewId,
762
+ artifactDir,
763
+ reason: `${isFileNotFound(error) ? "missing" : "unreadable"} turn metadata for ${turnDirName}${isFileNotFound(error) ? "" : `: ${formatError(error)}`}`
764
+ });
765
+ return null;
766
+ }
767
+ try {
768
+ const turn = parseJson(raw, isReviewTurnMeta, "review turn metadata");
769
+ if (turn.id !== turnDirName) {
770
+ skipped.push({
771
+ reviewId,
772
+ artifactDir,
773
+ reason: `turn metadata id mismatch for ${turnDirName}: ${turn.id}`
774
+ });
775
+ return null;
776
+ }
777
+ return turn;
778
+ } catch (error) {
779
+ skipped.push({
780
+ reviewId,
781
+ artifactDir,
782
+ reason: `invalid turn metadata for ${turnDirName}: ${formatError(error)}`
783
+ });
784
+ return null;
785
+ }
786
+ }
787
+ function metadataTimestamps(meta) {
788
+ return [
789
+ meta.createdAt,
790
+ meta.submittedAt,
791
+ meta.resolvedAt,
792
+ ...(meta.turns ?? []).flatMap((turn) => [
793
+ turn.createdAt,
794
+ turn.capturedAt,
795
+ turn.submittedAt,
796
+ turn.resolvedAt
797
+ ])
798
+ ];
799
+ }
800
+ function latestTimestamp(timestamps) {
801
+ const latest = Math.max(
802
+ ...timestamps.map((timestamp) => timestamp ? Date.parse(timestamp) : Number.NaN).filter((timestamp) => Number.isFinite(timestamp))
803
+ );
804
+ return Number.isFinite(latest) ? new Date(latest).toISOString() : null;
805
+ }
806
+ function cleanupResult({
807
+ reviewsDir,
808
+ cutoff,
809
+ olderThanDays,
810
+ dryRun,
811
+ candidates,
812
+ deleted,
813
+ skipped
814
+ }) {
815
+ return {
816
+ reviewsDir,
817
+ cutoff: cutoff.toISOString(),
818
+ olderThanDays,
819
+ dryRun,
820
+ candidates,
821
+ deleted,
822
+ skipped,
823
+ counts: {
824
+ candidates: candidates.length,
825
+ deleted: deleted.length,
826
+ skipped: skipped.length
827
+ }
828
+ };
829
+ }
830
+
831
+ // src/shared/review-scope.ts
832
+ var ALL_REVIEW_SCOPE = { mode: "all" };
833
+ function normalizeReviewScope(diff, scope = ALL_REVIEW_SCOPE) {
834
+ if (scope.mode === "all") {
835
+ return ALL_REVIEW_SCOPE;
836
+ }
837
+ const commitDiffs = diff.commitDiffs ?? [];
838
+ if (commitDiffs.length === 0) {
839
+ throw new Error("Review scope requires a review with per-commit diffs");
840
+ }
841
+ if (scope.mode === "single") {
842
+ const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
843
+ if (!commit) {
844
+ throw new Error("Review scope must use commits from this review");
845
+ }
846
+ return { mode: "single", sha: commit.commit.sha };
847
+ }
848
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
849
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
850
+ if (fromIndex < 0 || toIndex < 0) {
851
+ throw new Error("Review scope must use commits from this review");
852
+ }
853
+ if (fromIndex > toIndex) {
854
+ throw new Error("Review scope range must be in review order");
855
+ }
856
+ return {
857
+ mode: "range",
858
+ fromSha: commitDiffs[fromIndex].commit.sha,
859
+ toSha: commitDiffs[toIndex].commit.sha
860
+ };
861
+ }
862
+ function sameReviewScope(left, right) {
863
+ return JSON.stringify(left ?? ALL_REVIEW_SCOPE) === JSON.stringify(right ?? ALL_REVIEW_SCOPE);
864
+ }
865
+ function reviewScopeLabel(scope = ALL_REVIEW_SCOPE, commitDiffs = []) {
866
+ if (scope.mode === "all") {
867
+ return "All commits";
868
+ }
869
+ if (scope.mode === "single") {
870
+ const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
871
+ return commit ? `${commit.commit.shortSha} ${commit.commit.subject}` : `Commit ${shortSha(scope.sha)}`;
872
+ }
873
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
874
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
875
+ if (fromIndex >= 0 && toIndex >= fromIndex) {
876
+ const count = toIndex - fromIndex + 1;
877
+ return `${count} commits \xB7 ${commitDiffs[fromIndex].commit.shortSha} to ${commitDiffs[toIndex].commit.shortSha}`;
878
+ }
879
+ return `Commit range ${shortSha(scope.fromSha)} to ${shortSha(scope.toSha)}`;
880
+ }
881
+ function shortSha(sha) {
882
+ return sha.slice(0, 7);
883
+ }
884
+
318
885
  // src/shared/markdown.ts
319
886
  function fenceFor(snippet) {
320
887
  let fence = "```";
@@ -329,20 +896,32 @@ function languageForSnippet(filePath, snippet) {
329
896
  return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath) ?? "";
330
897
  }
331
898
  function serializeFeedbackMarkdown(bundle) {
332
- const comments = [...bundle.comments].sort(compareCommentsByLocation);
333
- const files = [...new Set(comments.map((comment) => comment.filePath))];
899
+ const comments = bundle.comments.toSorted(compareCommentsByLocation);
900
+ const commentsByFile = /* @__PURE__ */ new Map();
901
+ const files = [];
902
+ for (const comment of comments) {
903
+ const fileComments = commentsByFile.get(comment.filePath);
904
+ if (fileComments) {
905
+ fileComments.push(comment);
906
+ } else {
907
+ commentsByFile.set(comment.filePath, [comment]);
908
+ files.push(comment.filePath);
909
+ }
910
+ }
334
911
  const lines = [
335
912
  `# Gloss feedback - ${bundle.timestamp}`,
336
913
  `Review: ${bundle.reviewId}`,
914
+ ...bundle.turnIndex ? [`Turn: ${bundle.turnIndex} (${bundle.turnId ?? "unknown"})`] : [],
915
+ ...bundle.reviewScope ? [`Review scope: ${reviewScopeLabel(bundle.reviewScope)}`] : [],
337
916
  `Base: ${bundle.base.ref} (${bundle.base.sha.slice(0, 7)}) Branch: ${bundle.branch ?? "(detached)"}`,
338
917
  `Files: ${files.length} Comments: ${comments.length}`,
339
918
  ""
340
919
  ];
341
920
  for (const filePath of files) {
342
921
  lines.push(`## ${filePath}`, "");
343
- for (const comment of comments.filter((item) => item.filePath === filePath)) {
922
+ for (const comment of commentsByFile.get(filePath) ?? []) {
344
923
  const snippet = comment.originalSnippet.trimEnd();
345
- const firstSnippetLine = snippet.split("\n").find((line) => line.trim().length > 0);
924
+ const firstSnippetLine = firstNonEmptyLine(snippet);
346
925
  const heading = comment.startLine === comment.endLine && firstSnippetLine ? `### ${formatLineRange(comment)} - \`${firstSnippetLine.trim().slice(0, 80)}\`` : `### ${formatLineRange(comment)}`;
347
926
  lines.push(heading, comment.body.trim(), "");
348
927
  if (snippet) {
@@ -354,6 +933,14 @@ function serializeFeedbackMarkdown(bundle) {
354
933
  return `${lines.join("\n").trimEnd()}
355
934
  `;
356
935
  }
936
+ function firstNonEmptyLine(text) {
937
+ for (const line of text.split("\n")) {
938
+ if (line.trim().length > 0) {
939
+ return line;
940
+ }
941
+ }
942
+ return void 0;
943
+ }
357
944
 
358
945
  // src/server/store.ts
359
946
  var ReviewStore = class {
@@ -362,6 +949,7 @@ var ReviewStore = class {
362
949
  async create(diff) {
363
950
  const id = ulid();
364
951
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
952
+ const turn = createTurn(id, 1, diff, createdAt);
365
953
  const meta = {
366
954
  id,
367
955
  cwd: diff.cwd,
@@ -369,108 +957,197 @@ var ReviewStore = class {
369
957
  branch: diff.branch,
370
958
  status: "pending",
371
959
  createdAt,
372
- artifactDir: globalReviewDir(id)
960
+ artifactDir: globalReviewDir(id),
961
+ activeTurnId: turn.id
373
962
  };
374
- const record = { meta, diff };
963
+ const record = normalizeRecord({ meta, turns: [turn], diff: turn.diff });
375
964
  this.reviews.set(id, record);
376
- await this.persistInitial(record);
965
+ await this.persistInitial(record, turn);
377
966
  this.emit({ type: "review.opened", reviewId: id });
378
967
  return record;
379
968
  }
969
+ async appendTurn(id, diff) {
970
+ const record = await this.get(id);
971
+ if (!record) {
972
+ throw new Error(`Review ${id} not found`);
973
+ }
974
+ if (record.meta.cwd !== diff.cwd) {
975
+ throw new Error(`Review ${id} belongs to ${record.meta.cwd}, not ${diff.cwd}`);
976
+ }
977
+ const latest = latestTurn(record);
978
+ if (latest.status === "pending") {
979
+ if (diffFingerprint(latest.diff) === diffFingerprint(diff)) {
980
+ this.emit({
981
+ type: "review.turn.created",
982
+ reviewId: id,
983
+ turnId: latest.id,
984
+ turnIndex: latest.index,
985
+ reused: true
986
+ });
987
+ return { record, turn: latest, reused: true };
988
+ }
989
+ throw new Error(`Review ${id} already has a pending turn`);
990
+ }
991
+ if (latest.status === "cancelled") {
992
+ throw new Error(`Review ${id} is cancelled and cannot be continued`);
993
+ }
994
+ const turn = createTurn(id, latest.index + 1, diff, (/* @__PURE__ */ new Date()).toISOString());
995
+ const nextRecord = normalizeRecord({
996
+ ...record,
997
+ meta: { ...record.meta, activeTurnId: turn.id },
998
+ turns: [...record.turns, turn]
999
+ });
1000
+ this.reviews.set(id, nextRecord);
1001
+ await this.persistInitial(nextRecord, turn);
1002
+ this.emit({
1003
+ type: "review.turn.created",
1004
+ reviewId: id,
1005
+ turnId: turn.id,
1006
+ turnIndex: turn.index,
1007
+ reused: false
1008
+ });
1009
+ return { record: nextRecord, turn, reused: false };
1010
+ }
380
1011
  async list() {
381
1012
  await this.loadAllReviews();
382
- return [...this.reviews.values()].map((record) => record.meta).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1013
+ return Array.from(this.reviews.values()).map((record) => record.meta).toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
1014
+ }
1015
+ async clearReviewArtifacts(options = {}) {
1016
+ const result = await clearReviewArtifacts(options);
1017
+ if (!result.dryRun) {
1018
+ for (const review of result.deleted) {
1019
+ this.reviews.delete(review.reviewId);
1020
+ }
1021
+ }
1022
+ return result;
383
1023
  }
384
1024
  async get(id) {
385
1025
  return this.reviews.get(id) ?? await this.loadKnownReview(id);
386
1026
  }
387
- async submit(id, comments) {
1027
+ async getTurn(id, turnId) {
1028
+ const record = await this.get(id);
1029
+ return record?.turns.find((turn) => turn.id === turnId) ?? null;
1030
+ }
1031
+ async submit(id, comments, reviewScope) {
388
1032
  const record = await this.get(id);
389
1033
  if (!record) {
390
1034
  throw new Error(`Review ${id} not found`);
391
1035
  }
392
- if (record.meta.status !== "pending") {
393
- throw new Error(`Review ${id} is ${record.meta.status} and cannot be submitted`);
1036
+ const turn = activeTurn(record);
1037
+ const sortedComments = comments.toSorted(compareCommentsByLocation);
1038
+ const normalizedReviewScope = normalizeReviewScope(turn.diff, reviewScope);
1039
+ if (turn.status !== "pending") {
1040
+ if (turn.feedback && sameComments(turn.feedback.comments, sortedComments) && sameReviewScope(turn.feedback.reviewScope, normalizedReviewScope)) {
1041
+ return {
1042
+ record,
1043
+ feedbackPath: requiredPath(turn.feedbackPath, "feedback path"),
1044
+ markdownPath: requiredPath(turn.markdownPath, "markdown path"),
1045
+ turn
1046
+ };
1047
+ }
1048
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be submitted`);
394
1049
  }
395
1050
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1051
+ const feedbackPath = globalReviewTurnFeedbackFile(id, turn.id);
1052
+ const markdownPath = globalReviewTurnMarkdownFile(id, turn.id);
396
1053
  const feedback = {
397
1054
  version: 1,
398
1055
  reviewId: id,
1056
+ turnId: turn.id,
1057
+ turnIndex: turn.index,
399
1058
  timestamp,
400
- base: record.diff.base,
401
- branch: record.diff.branch,
402
- comments: [...comments].sort(compareCommentsByLocation)
1059
+ base: turn.diff.base,
1060
+ branch: turn.diff.branch,
1061
+ reviewScope: normalizedReviewScope,
1062
+ comments: sortedComments
403
1063
  };
404
- record.feedback = feedback;
405
- record.meta = { ...record.meta, status: "submitted", submittedAt: timestamp };
406
- this.reviews.set(id, record);
407
- const artifactDir = globalReviewDir(id);
408
- const feedbackPath = globalReviewFeedbackFile(id);
409
- const markdownPath = globalReviewMarkdownFile(id);
410
- record.meta = {
411
- ...record.meta,
412
- artifactDir,
1064
+ const nextTurn = {
1065
+ ...turn,
1066
+ status: "submitted",
1067
+ submittedAt: timestamp,
413
1068
  feedbackPath,
414
- markdownPath
1069
+ markdownPath,
1070
+ feedback
415
1071
  };
416
- await ensureDir(artifactDir);
1072
+ const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
1073
+ this.reviews.set(id, nextRecord);
1074
+ await ensureDir(globalReviewTurnDir(id, nextTurn.id));
417
1075
  await Promise.all([
418
- writeJsonFile(globalReviewMetaFile(id), record.meta),
1076
+ writeJsonFile(globalReviewTurnMetaFile(id, nextTurn.id), turnMeta(nextTurn)),
419
1077
  writeJsonFile(feedbackPath, feedback),
420
- writeFile2(markdownPath, serializeFeedbackMarkdown(feedback))
1078
+ writeTextFile(markdownPath, serializeFeedbackMarkdown(feedback))
421
1079
  ]);
1080
+ await this.persistMeta(nextRecord);
422
1081
  this.emit({
423
1082
  type: "review.submitted",
424
1083
  reviewId: id,
1084
+ turnId: nextTurn.id,
1085
+ turnIndex: nextTurn.index,
425
1086
  counts: {
426
1087
  files: countCommentFiles(feedback.comments),
427
1088
  comments: feedback.comments.length
428
1089
  }
429
1090
  });
430
- return { record, feedbackPath, markdownPath };
1091
+ return { record: nextRecord, feedbackPath, markdownPath, turn: nextTurn };
431
1092
  }
432
1093
  async feedback(id) {
433
1094
  const record = await this.get(id);
434
1095
  return record?.feedback ?? null;
435
1096
  }
436
- async markResolved(id, summary) {
1097
+ async markResolved(id, summary, turnSelector) {
437
1098
  const record = await this.get(id);
438
1099
  if (!record) {
439
1100
  throw new Error(`Review ${id} not found`);
440
1101
  }
441
- this.assertResolvable(record, id);
1102
+ const turn = this.resolveTurnSelector(record, turnSelector);
1103
+ this.assertResolvable(turn, id);
442
1104
  const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
443
1105
  const existingById = new Map(
444
- (record.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
1106
+ (turn.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
445
1107
  );
446
1108
  const comments = this.sortResolvedComments(
447
- (record.feedback?.comments ?? []).map((comment) => ({
1109
+ (turn.feedback?.comments ?? []).map((comment) => ({
448
1110
  ...existingById.get(comment.id),
449
1111
  commentId: comment.id,
450
1112
  status: "resolved",
451
1113
  resolvedAt: existingById.get(comment.id)?.resolvedAt ?? resolvedAt
452
1114
  })),
453
- record
1115
+ turn
454
1116
  );
455
1117
  const resolution = {
456
1118
  reviewId: id,
1119
+ turnId: turn.id,
1120
+ turnIndex: turn.index,
457
1121
  status: "resolved",
458
- summary: summary ?? record.resolution?.summary ?? null,
1122
+ summary: summary ?? turn.resolution?.summary ?? null,
459
1123
  resolvedAt,
460
1124
  comments
461
1125
  };
462
- record.meta = { ...record.meta, status: "resolved", resolvedAt };
463
- return this.persistResolution(record, resolution, "review-resolved");
1126
+ const nextTurn = {
1127
+ ...turn,
1128
+ status: "resolved",
1129
+ resolvedAt
1130
+ };
1131
+ return this.persistResolution(record, nextTurn, resolution, "review-resolved");
464
1132
  }
465
1133
  async resolveComment(id, commentId, summary) {
466
1134
  const record = await this.get(id);
467
1135
  if (!record) {
468
1136
  throw new Error(`Review ${id} not found`);
469
1137
  }
470
- this.assertResolvable(record, id);
471
- this.assertCommentExists(record, commentId);
1138
+ const turn = this.findTurnForComment(record, commentId);
1139
+ if (!turn) {
1140
+ const currentTurn = activeTurn(record);
1141
+ if (!isResolvableReviewStatus(currentTurn.status)) {
1142
+ throw new Error(
1143
+ `Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
1144
+ );
1145
+ }
1146
+ throw new Error(`Comment ${commentId} not found`);
1147
+ }
1148
+ this.assertResolvable(turn, id);
472
1149
  const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
473
- const previous = record.resolution?.comments.find((comment) => comment.commentId === commentId);
1150
+ const previous = turn.resolution?.comments.find((comment) => comment.commentId === commentId);
474
1151
  const nextSummary = summary ?? previous?.summary;
475
1152
  const nextComment = {
476
1153
  commentId,
@@ -480,46 +1157,59 @@ var ReviewStore = class {
480
1157
  };
481
1158
  const comments = this.sortResolvedComments(
482
1159
  [
483
- ...(record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1160
+ ...(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
484
1161
  nextComment
485
1162
  ],
486
- record
1163
+ turn
487
1164
  );
488
- const counts = resolutionCounts(record.feedback, comments);
1165
+ const counts = resolutionCounts(turn.feedback, comments);
489
1166
  const fullyResolved = counts.total === counts.resolved;
490
1167
  const resolution = {
491
1168
  reviewId: id,
1169
+ turnId: turn.id,
1170
+ turnIndex: turn.index,
492
1171
  status: fullyResolved ? "resolved" : "partial",
493
- summary: fullyResolved ? record.resolution?.summary ?? null : null,
1172
+ summary: fullyResolved ? turn.resolution?.summary ?? null : null,
494
1173
  resolvedAt: fullyResolved ? resolvedAt : null,
495
1174
  comments
496
1175
  };
497
- record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
498
- return this.persistResolution(record, resolution, "comment-resolved");
1176
+ const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt } : { ...turn, status: "submitted", resolvedAt: void 0 };
1177
+ return this.persistResolution(record, nextTurn, resolution, "comment-resolved");
499
1178
  }
500
1179
  async reopenComment(id, commentId) {
501
1180
  const record = await this.get(id);
502
1181
  if (!record) {
503
1182
  throw new Error(`Review ${id} not found`);
504
1183
  }
505
- this.assertResolvable(record, id);
506
- this.assertCommentExists(record, commentId);
1184
+ const turn = this.findTurnForComment(record, commentId);
1185
+ if (!turn) {
1186
+ const currentTurn = activeTurn(record);
1187
+ if (!isResolvableReviewStatus(currentTurn.status)) {
1188
+ throw new Error(
1189
+ `Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
1190
+ );
1191
+ }
1192
+ throw new Error(`Comment ${commentId} not found`);
1193
+ }
1194
+ this.assertResolvable(turn, id);
507
1195
  const comments = this.sortResolvedComments(
508
- (record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
509
- record
1196
+ (turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
1197
+ turn
510
1198
  );
511
- const counts = resolutionCounts(record.feedback, comments);
1199
+ const counts = resolutionCounts(turn.feedback, comments);
512
1200
  const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
513
1201
  const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
514
1202
  const resolution = {
515
1203
  reviewId: id,
1204
+ turnId: turn.id,
1205
+ turnIndex: turn.index,
516
1206
  status: fullyResolved ? "resolved" : "partial",
517
- summary: fullyResolved ? record.resolution?.summary ?? null : null,
1207
+ summary: fullyResolved ? turn.resolution?.summary ?? null : null,
518
1208
  resolvedAt,
519
1209
  comments
520
1210
  };
521
- record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
522
- return this.persistResolution(record, resolution, "comment-reopened");
1211
+ const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...turn, status: "submitted", resolvedAt: void 0 };
1212
+ return this.persistResolution(record, nextTurn, resolution, "comment-reopened");
523
1213
  }
524
1214
  subscribe(reviewId, listener) {
525
1215
  const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
@@ -537,13 +1227,17 @@ var ReviewStore = class {
537
1227
  listener(event);
538
1228
  }
539
1229
  }
540
- async persistInitial(record) {
541
- const dir = globalReviewDir(record.meta.id);
542
- await ensureDir(dir);
1230
+ async persistInitial(record, turn) {
1231
+ await ensureDir(turn.artifactDir);
543
1232
  await Promise.all([
544
- writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta),
545
- writeJsonFile(globalReviewDiffFile(record.meta.id), record.diff)
1233
+ writeJsonFile(globalReviewTurnMetaFile(record.meta.id, turn.id), turnMeta(turn)),
1234
+ writeJsonFile(turn.diffPath, turn.diff)
546
1235
  ]);
1236
+ await this.persistMeta(record);
1237
+ }
1238
+ async persistMeta(record) {
1239
+ await ensureDir(globalReviewDir(record.meta.id));
1240
+ await writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta);
547
1241
  }
548
1242
  async loadKnownReview(id) {
549
1243
  const existing = this.reviews.get(id);
@@ -555,7 +1249,7 @@ var ReviewStore = class {
555
1249
  async loadAllReviews() {
556
1250
  let entries;
557
1251
  try {
558
- entries = await readdir(globalReviewsDir(), { withFileTypes: true });
1252
+ entries = await readdir2(globalReviewsDir(), { withFileTypes: true });
559
1253
  } catch (error) {
560
1254
  if (isFileNotFound(error)) {
561
1255
  return;
@@ -567,86 +1261,217 @@ var ReviewStore = class {
567
1261
  }
568
1262
  );
569
1263
  }
570
- await Promise.all(
571
- entries.filter((entry) => entry.isDirectory()).map((entry) => this.loadReview(entry.name))
572
- );
1264
+ const reviewLoads = [];
1265
+ for (const entry of entries) {
1266
+ if (entry.isDirectory()) {
1267
+ reviewLoads.push(this.loadReview(entry.name));
1268
+ }
1269
+ }
1270
+ await Promise.all(reviewLoads);
573
1271
  }
574
1272
  async loadReview(id) {
575
1273
  const metaPath = globalReviewMetaFile(id);
576
- const diffPath = globalReviewDiffFile(id);
1274
+ let metaRaw;
1275
+ try {
1276
+ metaRaw = await readFile3(metaPath, "utf8");
1277
+ } catch (error) {
1278
+ if (isFileNotFound(error)) {
1279
+ return this.loadReviewFromTurnsOnly(id);
1280
+ }
1281
+ throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
1282
+ }
1283
+ const storedMeta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
1284
+ const persistedTurns = await this.loadPersistedTurns(id);
1285
+ const legacyTurn = await this.loadLegacyTurn(id, storedMeta);
1286
+ const turns = mergeRecoveredTurns(legacyTurn, persistedTurns);
1287
+ if (turns.length === 0) {
1288
+ throw new Error(`Review ${id} has no recoverable turns`);
1289
+ }
1290
+ const latest = latestTurn({ turns });
1291
+ const record = normalizeRecord({
1292
+ meta: {
1293
+ ...storedMeta,
1294
+ artifactDir: storedMeta.artifactDir ?? globalReviewDir(id),
1295
+ activeTurnId: latest.id
1296
+ },
1297
+ turns,
1298
+ diff: latest.diff
1299
+ });
1300
+ this.reviews.set(id, record);
1301
+ return record;
1302
+ }
1303
+ async loadReviewFromTurnsOnly(id) {
1304
+ const turns = await this.loadPersistedTurns(id);
1305
+ if (turns.length === 0) {
1306
+ return null;
1307
+ }
1308
+ const latest = latestTurn({ turns });
1309
+ const record = normalizeRecord({
1310
+ meta: {
1311
+ id,
1312
+ cwd: latest.diff.cwd,
1313
+ base: latest.diff.base,
1314
+ branch: latest.diff.branch,
1315
+ status: latest.status,
1316
+ createdAt: turns[0]?.createdAt ?? latest.createdAt,
1317
+ artifactDir: globalReviewDir(id),
1318
+ activeTurnId: latest.id
1319
+ },
1320
+ turns,
1321
+ diff: latest.diff
1322
+ });
1323
+ this.reviews.set(id, record);
1324
+ await this.persistMeta(record);
1325
+ return record;
1326
+ }
1327
+ async loadPersistedTurns(id) {
1328
+ let entries;
1329
+ try {
1330
+ entries = await readdir2(globalReviewTurnsDir(id), { withFileTypes: true });
1331
+ } catch (error) {
1332
+ if (isFileNotFound(error)) {
1333
+ return [];
1334
+ }
1335
+ throw new Error(`Could not read review turns for ${id}: ${formatError(error)}`, {
1336
+ cause: error
1337
+ });
1338
+ }
1339
+ const turns = [];
1340
+ for (const entry of entries) {
1341
+ if (!entry.isDirectory()) {
1342
+ continue;
1343
+ }
1344
+ const turn = await this.loadPersistedTurn(id, entry.name);
1345
+ if (turn) {
1346
+ turns.push(turn);
1347
+ }
1348
+ }
1349
+ return turns.toSorted((a, b) => a.index - b.index);
1350
+ }
1351
+ async loadPersistedTurn(id, turnId) {
1352
+ const metaPath = globalReviewTurnMetaFile(id, turnId);
1353
+ const diffPath = globalReviewTurnDiffFile(id, turnId);
577
1354
  let metaRaw;
578
1355
  let diffRaw;
579
1356
  try {
580
1357
  [metaRaw, diffRaw] = await Promise.all([
581
- readFile2(metaPath, "utf8"),
582
- readFile2(diffPath, "utf8")
1358
+ readFile3(metaPath, "utf8"),
1359
+ readFile3(diffPath, "utf8")
583
1360
  ]);
584
1361
  } catch (error) {
585
1362
  if (isFileNotFound(error)) {
586
1363
  return null;
587
1364
  }
588
- throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
1365
+ throw new Error(`Could not load review ${id} turn ${turnId}: ${formatError(error)}`, {
1366
+ cause: error
1367
+ });
1368
+ }
1369
+ const meta = parseJsonFile(metaRaw, isReviewTurnMeta, "review turn metadata", metaPath);
1370
+ const diff = parseJsonFile(diffRaw, isDiffPayload, "review turn diff", diffPath);
1371
+ const [feedback, resolution] = await Promise.all([
1372
+ readOptionalJsonFile(
1373
+ globalReviewTurnFeedbackFile(id, turnId),
1374
+ isFeedbackBundle,
1375
+ "review feedback"
1376
+ ),
1377
+ readOptionalJsonFile(
1378
+ globalReviewTurnResolvedFile(id, turnId),
1379
+ isResolutionBundle,
1380
+ "review resolution"
1381
+ )
1382
+ ]);
1383
+ return reconcileTurn(meta, diff, feedback, resolution);
1384
+ }
1385
+ async loadLegacyTurn(id, storedMeta) {
1386
+ const diffPath = globalReviewDiffFile(id);
1387
+ let diffRaw;
1388
+ try {
1389
+ diffRaw = await readFile3(diffPath, "utf8");
1390
+ } catch (error) {
1391
+ if (isFileNotFound(error)) {
1392
+ return null;
1393
+ }
1394
+ throw new Error(`Could not load legacy review ${id}: ${formatError(error)}`, {
1395
+ cause: error
1396
+ });
589
1397
  }
590
- const meta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
591
1398
  const diff = parseJsonFile(diffRaw, isDiffPayload, "review diff", diffPath);
592
- const feedback = await readOptionalJsonFile(
593
- globalReviewFeedbackFile(id),
594
- isFeedbackBundle,
595
- "review feedback"
596
- );
597
- const resolution = await readOptionalJsonFile(
598
- globalReviewResolvedFile(id),
599
- isResolutionBundle,
600
- "review resolution"
601
- );
602
- const record = {
603
- meta: {
604
- ...meta,
605
- artifactDir: meta.artifactDir ?? globalReviewDir(id),
606
- feedbackPath: meta.feedbackPath ?? (feedback ? globalReviewFeedbackFile(id) : void 0),
607
- markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
608
- },
609
- diff,
610
- feedback,
611
- resolution
1399
+ const [feedback, resolution] = await Promise.all([
1400
+ readOptionalJsonFile(globalReviewFeedbackFile(id), isFeedbackBundle, "review feedback"),
1401
+ readOptionalJsonFile(globalReviewResolvedFile(id), isResolutionBundle, "review resolution")
1402
+ ]);
1403
+ const artifactDir = storedMeta.artifactDir ?? globalReviewDir(id);
1404
+ const legacySummary = storedMeta.turns?.find(
1405
+ (turn) => turn.artifactDir === artifactDir || turn.diffPath === diffPath
1406
+ ) ?? storedMeta.turns?.find((turn) => turn.index === 1) ?? storedMeta.turns?.[0];
1407
+ const meta = {
1408
+ id: legacySummary?.id ?? storedMeta.activeTurnId ?? "turn-1",
1409
+ index: legacySummary?.index ?? 1,
1410
+ status: legacySummary?.status ?? storedMeta.status,
1411
+ createdAt: legacySummary?.createdAt ?? storedMeta.createdAt,
1412
+ submittedAt: legacySummary?.submittedAt ?? storedMeta.submittedAt,
1413
+ resolvedAt: legacySummary?.resolvedAt ?? storedMeta.resolvedAt,
1414
+ artifactDir: legacySummary?.artifactDir ?? artifactDir,
1415
+ diffPath,
1416
+ ...feedback ? { feedbackPath: globalReviewFeedbackFile(id), markdownPath: globalReviewMarkdownFile(id) } : {},
1417
+ ...resolution ? { resolvedPath: globalReviewResolvedFile(id) } : {}
612
1418
  };
613
- this.reviews.set(id, record);
614
- return record;
1419
+ return reconcileTurn(meta, diff, feedback, resolution);
615
1420
  }
616
- assertResolvable(record, id) {
617
- if (!isResolvableReviewStatus(record.meta.status)) {
618
- throw new Error(`Review ${id} is ${record.meta.status} and cannot be resolved`);
1421
+ assertResolvable(turn, id) {
1422
+ if (!isResolvableReviewStatus(turn.status)) {
1423
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be resolved`);
619
1424
  }
620
- if (!record.feedback) {
621
- throw new Error(`Review ${id} has no submitted feedback`);
1425
+ if (!turn.feedback) {
1426
+ throw new Error(`Review ${id} turn ${turn.index} has no submitted feedback`);
622
1427
  }
623
1428
  }
624
- assertCommentExists(record, commentId) {
625
- if (!record.feedback.comments.some((comment) => comment.id === commentId)) {
626
- throw new Error(`Comment ${commentId} not found`);
1429
+ resolveTurnSelector(record, selector) {
1430
+ if (!selector) {
1431
+ return activeTurn(record);
627
1432
  }
1433
+ const turn = record.turns.find((candidate) => candidate.id === selector) ?? record.turns.find((candidate) => String(candidate.index) === selector);
1434
+ if (!turn) {
1435
+ throw new Error(`Turn ${selector} not found in review ${record.meta.id}`);
1436
+ }
1437
+ return turn;
628
1438
  }
629
- async persistResolution(record, resolution, reason) {
630
- record.resolution = resolution;
631
- this.reviews.set(record.meta.id, record);
632
- const resolvedPath = globalReviewResolvedFile(record.meta.id);
633
- await ensureDir(globalReviewDir(record.meta.id));
1439
+ findTurnForComment(record, commentId) {
1440
+ return [...record.turns].reverse().find(
1441
+ (candidate) => candidate.feedback?.comments.some((comment) => comment.id === commentId)
1442
+ ) ?? null;
1443
+ }
1444
+ async persistResolution(record, turn, resolution, reason) {
1445
+ const resolvedPath = globalReviewTurnResolvedFile(record.meta.id, turn.id);
1446
+ const nextTurn = {
1447
+ ...turn,
1448
+ resolvedPath,
1449
+ resolution
1450
+ };
1451
+ const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
1452
+ this.reviews.set(record.meta.id, nextRecord);
1453
+ await ensureDir(globalReviewTurnDir(record.meta.id, nextTurn.id));
634
1454
  await Promise.all([
635
1455
  writeJsonFile(resolvedPath, resolution),
636
- writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta)
1456
+ writeJsonFile(globalReviewTurnMetaFile(record.meta.id, nextTurn.id), turnMeta(nextTurn))
637
1457
  ]);
1458
+ await this.persistMeta(nextRecord);
638
1459
  const result = {
639
1460
  ok: true,
640
1461
  reviewId: record.meta.id,
641
- status: record.meta.status,
1462
+ turnId: nextTurn.id,
1463
+ turnIndex: nextTurn.index,
1464
+ status: nextTurn.status,
642
1465
  resolutionStatus: resolution.status,
643
- comments: resolutionCounts(record.feedback, resolution.comments),
1466
+ comments: resolutionCounts(nextTurn.feedback, resolution.comments),
644
1467
  path: resolvedPath,
645
1468
  resolution
646
1469
  };
647
1470
  this.emit({
648
1471
  type: "review.updated",
649
1472
  reviewId: record.meta.id,
1473
+ turnId: nextTurn.id,
1474
+ turnIndex: nextTurn.index,
650
1475
  reason,
651
1476
  status: result.status,
652
1477
  resolutionStatus: result.resolutionStatus,
@@ -654,19 +1479,129 @@ var ReviewStore = class {
654
1479
  });
655
1480
  return result;
656
1481
  }
657
- sortResolvedComments(comments, record) {
1482
+ sortResolvedComments(comments, turn) {
658
1483
  const feedbackIndex = new Map(
659
- record.feedback.comments.map((comment, index) => [comment.id, index])
660
- );
661
- return comments.filter((comment) => feedbackIndex.has(comment.commentId)).sort(
662
- (a, b) => (feedbackIndex.get(a.commentId) ?? Number.MAX_SAFE_INTEGER) - (feedbackIndex.get(b.commentId) ?? Number.MAX_SAFE_INTEGER)
1484
+ turn.feedback.comments.map((comment, index) => [comment.id, index])
663
1485
  );
1486
+ return comments.map((comment) => ({ comment, index: feedbackIndex.get(comment.commentId) })).filter(
1487
+ (entry) => entry.index !== void 0
1488
+ ).sort((a, b) => a.index - b.index).map(({ comment }) => comment);
664
1489
  }
665
1490
  };
1491
+ function createTurn(reviewId, index, diff, createdAt) {
1492
+ const id = ulid();
1493
+ return {
1494
+ id,
1495
+ index,
1496
+ status: "pending",
1497
+ createdAt,
1498
+ artifactDir: globalReviewTurnDir(reviewId, id),
1499
+ diffPath: globalReviewTurnDiffFile(reviewId, id),
1500
+ diff
1501
+ };
1502
+ }
1503
+ function normalizeRecord(record) {
1504
+ const turns = record.turns.toSorted((a, b) => a.index - b.index);
1505
+ const active = turns.find((turn) => turn.id === record.meta.activeTurnId) ?? turns[turns.length - 1];
1506
+ const meta = {
1507
+ ...record.meta,
1508
+ base: active.diff.base,
1509
+ branch: active.diff.branch,
1510
+ status: active.status,
1511
+ submittedAt: active.submittedAt,
1512
+ resolvedAt: active.resolvedAt,
1513
+ artifactDir: record.meta.artifactDir ?? globalReviewDir(record.meta.id),
1514
+ activeTurnId: active.id,
1515
+ turns: turns.map(turnSummary),
1516
+ feedbackPath: active.feedbackPath,
1517
+ markdownPath: active.markdownPath
1518
+ };
1519
+ return {
1520
+ meta,
1521
+ turns,
1522
+ diff: active.diff,
1523
+ ...active.feedback ? { feedback: active.feedback } : {},
1524
+ ...active.resolution ? { resolution: active.resolution } : {}
1525
+ };
1526
+ }
1527
+ function replaceTurn(record, nextTurn) {
1528
+ return {
1529
+ ...record,
1530
+ turns: record.turns.map((turn) => turn.id === nextTurn.id ? nextTurn : turn)
1531
+ };
1532
+ }
1533
+ function activeTurn(record) {
1534
+ return record.turns.find((turn) => turn.id === record.meta.activeTurnId) ?? record.turns[record.turns.length - 1];
1535
+ }
1536
+ function latestTurn(record) {
1537
+ return record.turns.toSorted((a, b) => a.index - b.index)[record.turns.length - 1];
1538
+ }
1539
+ function turnMeta(turn) {
1540
+ return {
1541
+ id: turn.id,
1542
+ index: turn.index,
1543
+ status: turn.status,
1544
+ createdAt: turn.createdAt,
1545
+ submittedAt: turn.submittedAt,
1546
+ resolvedAt: turn.resolvedAt,
1547
+ artifactDir: turn.artifactDir,
1548
+ diffPath: turn.diffPath,
1549
+ feedbackPath: turn.feedbackPath,
1550
+ markdownPath: turn.markdownPath,
1551
+ resolvedPath: turn.resolvedPath
1552
+ };
1553
+ }
1554
+ function turnSummary(turn) {
1555
+ return {
1556
+ ...turnMeta(turn),
1557
+ capturedAt: turn.diff.capturedAt,
1558
+ stats: turn.diff.stats,
1559
+ comments: resolutionCounts(turn.feedback, turn.resolution?.comments ?? [])
1560
+ };
1561
+ }
1562
+ function reconcileTurn(meta, diff, feedback, resolution) {
1563
+ const status = resolution?.status === "resolved" ? "resolved" : feedback ? "submitted" : "pending";
1564
+ return {
1565
+ ...meta,
1566
+ status,
1567
+ submittedAt: feedback?.timestamp ?? meta.submittedAt,
1568
+ resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
1569
+ feedbackPath: feedback ? meta.feedbackPath ?? path4.join(meta.artifactDir, "feedback.json") : void 0,
1570
+ markdownPath: feedback ? meta.markdownPath ?? path4.join(meta.artifactDir, "feedback.md") : void 0,
1571
+ resolvedPath: resolution ? meta.resolvedPath ?? path4.join(meta.artifactDir, "resolved.json") : void 0,
1572
+ diff,
1573
+ ...feedback ? { feedback } : {},
1574
+ ...resolution ? { resolution } : {}
1575
+ };
1576
+ }
1577
+ function mergeRecoveredTurns(legacyTurn, persistedTurns) {
1578
+ const turns = legacyTurn && !persistedTurns.some((turn) => turn.id === legacyTurn.id || turn.index === legacyTurn.index) ? [legacyTurn, ...persistedTurns] : persistedTurns;
1579
+ return turns.toSorted((a, b) => a.index - b.index);
1580
+ }
1581
+ function diffFingerprint(diff) {
1582
+ return createHash("sha256").update(
1583
+ JSON.stringify({
1584
+ base: diff.base,
1585
+ branch: diff.branch,
1586
+ cwd: diff.cwd,
1587
+ scope: diff.scope,
1588
+ rawDiff: diff.rawDiff
1589
+ })
1590
+ ).digest("hex");
1591
+ }
1592
+ function sameComments(left, right) {
1593
+ return JSON.stringify(left.toSorted(compareCommentsByLocation)) === JSON.stringify(right.toSorted(compareCommentsByLocation));
1594
+ }
1595
+ function requiredPath(value, label) {
1596
+ if (!value) {
1597
+ throw new Error(`Submitted review is missing ${label}`);
1598
+ }
1599
+ return value;
1600
+ }
666
1601
  async function readOptionalJsonFile(filePath, guard, label) {
667
1602
  let raw;
668
1603
  try {
669
- raw = await readFile2(filePath, "utf8");
1604
+ raw = await readFile3(filePath, "utf8");
670
1605
  } catch (error) {
671
1606
  if (isFileNotFound(error)) {
672
1607
  return void 0;
@@ -684,12 +1619,6 @@ function parseJsonFile(raw, guard, label, filePath) {
684
1619
  throw new Error(`Invalid ${label} at ${filePath}: ${formatError(error)}`, { cause: error });
685
1620
  }
686
1621
  }
687
- function isFileNotFound(error) {
688
- return error instanceof Error && "code" in error && error.code === "ENOENT";
689
- }
690
- function formatError(error) {
691
- return error instanceof Error ? error.message : String(error);
692
- }
693
1622
  var reviewStore = new ReviewStore();
694
1623
 
695
1624
  // src/server/index.ts
@@ -705,7 +1634,7 @@ var mimeTypes = {
705
1634
  ".sh": "text/x-shellscript; charset=utf-8",
706
1635
  ".svg": "image/svg+xml"
707
1636
  };
708
- function createApp(origin2) {
1637
+ function createApp(origin2, options = {}) {
709
1638
  const app = new Hono();
710
1639
  app.get("/api/health", async (c) => {
711
1640
  const reviews = await reviewStore.list();
@@ -720,6 +1649,15 @@ function createApp(origin2) {
720
1649
  const response = { reviews: await reviewStore.list() };
721
1650
  return c.json(response);
722
1651
  });
1652
+ app.post("/api/maintenance/clear-reviews", async (c) => {
1653
+ const parsed = await readJsonBody(c, isClearReviewsRequest, "clear reviews request");
1654
+ if (!parsed.ok) {
1655
+ return parsed.response;
1656
+ }
1657
+ const body = parsed.body;
1658
+ const result = await reviewStore.clearReviewArtifacts(body);
1659
+ return c.json(result);
1660
+ });
723
1661
  app.post("/api/reviews", async (c) => {
724
1662
  const parsed = await readJsonBody(c, isDiffPayload, "review diff");
725
1663
  if (!parsed.ok) {
@@ -729,10 +1667,36 @@ function createApp(origin2) {
729
1667
  const record = await reviewStore.create(diff);
730
1668
  const response = {
731
1669
  meta: record.meta,
1670
+ turn: activeTurnSummary(record.meta),
732
1671
  url: `${origin2}/review/${record.meta.id}`
733
1672
  };
1673
+ options.onReviewActivity?.();
734
1674
  return c.json(response, 201);
735
1675
  });
1676
+ app.post("/api/reviews/:id/turns", async (c) => {
1677
+ const id = c.req.param("id");
1678
+ const existing = await reviewStore.get(id);
1679
+ if (!existing) {
1680
+ return c.json({ error: "review not found" }, 404);
1681
+ }
1682
+ const parsed = await readJsonBody(c, isDiffPayload, "review diff");
1683
+ if (!parsed.ok) {
1684
+ return parsed.response;
1685
+ }
1686
+ try {
1687
+ const { record, turn, reused } = await reviewStore.appendTurn(id, parsed.body);
1688
+ const response = {
1689
+ meta: record.meta,
1690
+ turn: turnSummary2(record.meta, turn.id),
1691
+ url: `${origin2}/review/${id}`,
1692
+ reused
1693
+ };
1694
+ options.onReviewActivity?.();
1695
+ return c.json(response);
1696
+ } catch (error) {
1697
+ return c.json({ error: formatError(error) }, 409);
1698
+ }
1699
+ });
736
1700
  app.get("/api/reviews/:id", async (c) => {
737
1701
  const record = await reviewStore.get(c.req.param("id"));
738
1702
  if (!record) {
@@ -740,6 +1704,13 @@ function createApp(origin2) {
740
1704
  }
741
1705
  return c.json(record);
742
1706
  });
1707
+ app.get("/api/reviews/:id/turns/:turnId", async (c) => {
1708
+ const turn = await reviewStore.getTurn(c.req.param("id"), c.req.param("turnId"));
1709
+ if (!turn) {
1710
+ return c.json({ error: "turn not found" }, 404);
1711
+ }
1712
+ return c.json(turn);
1713
+ });
743
1714
  app.get("/api/reviews/:id/feedback", async (c) => {
744
1715
  const feedback = await reviewStore.feedback(c.req.param("id"));
745
1716
  if (!feedback) {
@@ -758,6 +1729,7 @@ function createApp(origin2) {
758
1729
  let pending = Promise.resolve();
759
1730
  let cleanup = null;
760
1731
  let close = null;
1732
+ let unregisterEventStream = null;
761
1733
  const closedPromise = new Promise((resolve) => {
762
1734
  close = () => {
763
1735
  if (closed) {
@@ -768,6 +1740,7 @@ function createApp(origin2) {
768
1740
  resolve();
769
1741
  };
770
1742
  });
1743
+ unregisterEventStream = options.registerEventStream?.(() => close?.()) ?? null;
771
1744
  const send = (event) => {
772
1745
  pending = pending.then(() => stream.writeSSE({ data: JSON.stringify(event) })).then(() => {
773
1746
  if (event.type === "review.cancelled") {
@@ -788,6 +1761,8 @@ function createApp(origin2) {
788
1761
  cleanup = () => {
789
1762
  clearInterval(heartbeat);
790
1763
  unsubscribe();
1764
+ unregisterEventStream?.();
1765
+ unregisterEventStream = null;
791
1766
  };
792
1767
  stream.onAbort(() => close?.());
793
1768
  send({ type: "review.opened", reviewId: id });
@@ -795,6 +1770,8 @@ function createApp(origin2) {
795
1770
  send({
796
1771
  type: "review.submitted",
797
1772
  reviewId: id,
1773
+ turnId: record.meta.activeTurnId,
1774
+ turnIndex: record.meta.turns?.find((turn) => turn.id === record.meta.activeTurnId)?.index,
798
1775
  counts: {
799
1776
  files: countCommentFiles(record.feedback.comments),
800
1777
  comments: record.feedback.comments.length
@@ -810,44 +1787,151 @@ function createApp(origin2) {
810
1787
  if (!existing) {
811
1788
  return c.json({ error: "review not found" }, 404);
812
1789
  }
813
- if (existing.meta.status !== "pending") {
814
- return c.json({ error: `review is ${existing.meta.status} and cannot be submitted` }, 409);
815
- }
816
1790
  const parsed = await readJsonBody(c, isSubmitReviewRequest, "submit review request");
817
1791
  if (!parsed.ok) {
818
1792
  return parsed.response;
819
1793
  }
820
1794
  const body = parsed.body;
821
- const { record, feedbackPath, markdownPath } = await reviewStore.submit(id, body.comments);
1795
+ let submitted;
1796
+ try {
1797
+ submitted = await reviewStore.submit(id, body.comments, body.reviewScope);
1798
+ } catch (error) {
1799
+ return c.json({ error: formatError(error) }, 409);
1800
+ }
1801
+ const { feedbackPath, markdownPath, turn } = submitted;
822
1802
  const response = {
823
1803
  reviewId: id,
1804
+ turnId: turn.id,
1805
+ turnIndex: turn.index,
824
1806
  url: `${origin2}/review/${id}`,
825
- files: record.diff.files.length,
1807
+ files: turn.diff.files.length,
826
1808
  comments: body.comments.length,
827
- artifactDir: record.meta.artifactDir,
1809
+ artifactDir: turn.artifactDir,
828
1810
  feedbackPath,
829
1811
  markdownPath
830
1812
  };
1813
+ options.onReviewActivity?.();
831
1814
  return c.json(response);
832
1815
  });
833
- app.post("/api/reviews/:id/resolved", async (c) => {
1816
+ app.post("/api/reviews/:id/commits/range", async (c) => {
834
1817
  const id = c.req.param("id");
835
1818
  const existing = await reviewStore.get(id);
836
1819
  if (!existing) {
837
1820
  return c.json({ error: "review not found" }, 404);
838
1821
  }
839
- if (!isResolvableReviewStatus(existing.meta.status)) {
840
- return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
1822
+ const parsed = await readJsonBody(c, isCommitRangeDiffRequest, "commit range diff request");
1823
+ if (!parsed.ok) {
1824
+ return parsed.response;
1825
+ }
1826
+ const requestedTurnId = parsed.body.turnId;
1827
+ const turn = requestedTurnId ? await reviewStore.getTurn(id, requestedTurnId) : null;
1828
+ if (requestedTurnId && !turn) {
1829
+ return c.json({ error: "turn not found" }, 404);
841
1830
  }
842
- if (!existing.feedback) {
843
- return c.json({ error: "submitted feedback not found" }, 409);
1831
+ const diffPayload = turn?.diff ?? existing.diff;
1832
+ const commitDiffs = diffPayload.commitDiffs ?? [];
1833
+ if (commitDiffs.length === 0) {
1834
+ return c.json({ error: "commit ranges are only available for branch reviews" }, 409);
1835
+ }
1836
+ const { fromSha, toSha } = parsed.body;
1837
+ const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === fromSha);
1838
+ const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === toSha);
1839
+ if (fromIndex < 0 || toIndex < 0) {
1840
+ return c.json({ error: "commit range must use commits from this review" }, 404);
1841
+ }
1842
+ if (fromIndex > toIndex) {
1843
+ return c.json({ error: "fromSha must come before or match toSha" }, 400);
1844
+ }
1845
+ const diff = fromSha === toSha ? commitDiffs[fromIndex] : await captureCommitRangeDiff(fromSha, toSha, diffPayload.cwd);
1846
+ const response = {
1847
+ fromSha,
1848
+ toSha,
1849
+ stats: diff.stats,
1850
+ rawDiff: diff.rawDiff,
1851
+ files: diff.files
1852
+ };
1853
+ return c.json(response);
1854
+ });
1855
+ app.post("/api/reviews/:id/files/open", async (c) => {
1856
+ const id = c.req.param("id");
1857
+ const existing = await reviewStore.get(id);
1858
+ if (!existing) {
1859
+ return c.json({ error: "review not found" }, 404);
1860
+ }
1861
+ const parsed = await readJsonBody(c, isOpenFileRequest, "open file request");
1862
+ if (!parsed.ok) {
1863
+ return parsed.response;
1864
+ }
1865
+ const { filePath, turnId } = parsed.body;
1866
+ if (!filePath || filePath.includes("\0") || path5.isAbsolute(filePath)) {
1867
+ return c.json({ error: "filePath must be a repo-relative path" }, 400);
1868
+ }
1869
+ const repoRoot = path5.resolve(existing.diff.cwd);
1870
+ const requestedAbsolutePath = path5.resolve(repoRoot, filePath);
1871
+ if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
1872
+ return c.json({ error: "filePath must stay within the review cwd" }, 400);
1873
+ }
1874
+ const turn = turnId ? await reviewStore.getTurn(id, turnId) : null;
1875
+ if (turnId && !turn) {
1876
+ return c.json({ error: "turn not found" }, 404);
1877
+ }
1878
+ const diffPayload = turn?.diff ?? existing.diff;
1879
+ const reviewFiles = [
1880
+ ...diffPayload.files,
1881
+ ...(diffPayload.commitDiffs ?? []).flatMap((commitDiff) => commitDiff.files)
1882
+ ].filter((file) => file.path === filePath);
1883
+ if (reviewFiles.length === 0) {
1884
+ return c.json({ error: "file is not part of this review" }, 404);
1885
+ }
1886
+ if (reviewFiles.every((file) => file.isDeleted)) {
1887
+ return c.json({ error: "deleted files cannot be opened locally" }, 409);
1888
+ }
1889
+ let realRepoRoot;
1890
+ let realFilePath;
1891
+ try {
1892
+ [realRepoRoot, realFilePath] = await Promise.all([
1893
+ realpath(repoRoot),
1894
+ realpath(requestedAbsolutePath)
1895
+ ]);
1896
+ } catch (error) {
1897
+ if (isFileNotFound(error)) {
1898
+ return c.json({ error: "file no longer exists on disk" }, 404);
1899
+ }
1900
+ throw error;
1901
+ }
1902
+ if (!isPathWithin(realRepoRoot, realFilePath)) {
1903
+ return c.json({ error: "filePath must stay within the review cwd" }, 400);
1904
+ }
1905
+ const fileStats = await stat(realFilePath);
1906
+ if (!fileStats.isFile()) {
1907
+ return c.json({ error: "path is not a file" }, 409);
1908
+ }
1909
+ try {
1910
+ await openLocalPath(realFilePath);
1911
+ } catch (error) {
1912
+ return c.json({ error: `could not open file: ${formatError(error)}` }, 500);
1913
+ }
1914
+ const response = { ok: true, path: realFilePath };
1915
+ return c.json(response);
1916
+ });
1917
+ app.post("/api/reviews/:id/resolved", async (c) => {
1918
+ const id = c.req.param("id");
1919
+ const existing = await reviewStore.get(id);
1920
+ if (!existing) {
1921
+ return c.json({ error: "review not found" }, 404);
844
1922
  }
845
1923
  const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
846
1924
  if (!parsed.ok) {
847
1925
  return parsed.response;
848
1926
  }
849
1927
  const body = parsed.body;
850
- return c.json(await reviewStore.markResolved(id, body.summary));
1928
+ try {
1929
+ const result = await reviewStore.markResolved(id, body.summary, body.turn);
1930
+ options.onReviewActivity?.();
1931
+ return c.json(result);
1932
+ } catch (error) {
1933
+ return c.json({ error: formatError(error) }, statusForStoreError(error));
1934
+ }
851
1935
  });
852
1936
  app.post("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
853
1937
  const id = c.req.param("id");
@@ -856,18 +1940,18 @@ function createApp(origin2) {
856
1940
  if (!existing) {
857
1941
  return c.json({ error: "review not found" }, 404);
858
1942
  }
859
- if (!isResolvableReviewStatus(existing.meta.status)) {
860
- return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
861
- }
862
- if (!existing.feedback?.comments.some((comment) => comment.id === commentId)) {
863
- return c.json({ error: "comment not found" }, 404);
864
- }
865
1943
  const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
866
1944
  if (!parsed.ok) {
867
1945
  return parsed.response;
868
1946
  }
869
1947
  const body = parsed.body;
870
- return c.json(await reviewStore.resolveComment(id, commentId, body.summary));
1948
+ try {
1949
+ const result = await reviewStore.resolveComment(id, commentId, body.summary);
1950
+ options.onReviewActivity?.();
1951
+ return c.json(result);
1952
+ } catch (error) {
1953
+ return c.json({ error: formatError(error) }, statusForStoreError(error));
1954
+ }
871
1955
  });
872
1956
  app.delete("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
873
1957
  const id = c.req.param("id");
@@ -876,13 +1960,13 @@ function createApp(origin2) {
876
1960
  if (!existing) {
877
1961
  return c.json({ error: "review not found" }, 404);
878
1962
  }
879
- if (!isResolvableReviewStatus(existing.meta.status)) {
880
- return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
881
- }
882
- if (!existing.feedback?.comments.some((comment) => comment.id === commentId)) {
883
- return c.json({ error: "comment not found" }, 404);
1963
+ try {
1964
+ const result = await reviewStore.reopenComment(id, commentId);
1965
+ options.onReviewActivity?.();
1966
+ return c.json(result);
1967
+ } catch (error) {
1968
+ return c.json({ error: formatError(error) }, statusForStoreError(error));
884
1969
  }
885
- return c.json(await reviewStore.reopenComment(id, commentId));
886
1970
  });
887
1971
  app.get("/logo.svg", serveRootFile("logo.svg", mimeTypes[".svg"]));
888
1972
  app.get("/logo-mark.svg", serveRootFile("logo-mark.svg", mimeTypes[".svg"]));
@@ -899,37 +1983,46 @@ function createApp(origin2) {
899
1983
  }
900
1984
  async function serveAsset(c) {
901
1985
  const requestPath = new URL(c.req.url).pathname.replace(/^\/assets\//, "");
902
- const normalized = path3.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
903
- const assetPath = path3.join(webRoot, "assets", normalized);
1986
+ const normalized = path5.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
1987
+ const assetPath = path5.join(webRoot, "assets", normalized);
904
1988
  try {
905
- const body = await readFile3(assetPath);
1989
+ const body = await readFile4(assetPath);
906
1990
  return new Response(body, {
907
1991
  headers: {
908
- "content-type": mimeTypes[path3.extname(assetPath)] ?? "application/octet-stream"
1992
+ "content-type": mimeTypes[path5.extname(assetPath)] ?? "application/octet-stream"
909
1993
  }
910
1994
  });
911
- } catch {
1995
+ } catch (error) {
1996
+ if (!isFileNotFound(error)) {
1997
+ throw error;
1998
+ }
912
1999
  return new Response("Not found", { status: 404 });
913
2000
  }
914
2001
  }
915
2002
  async function serveIndex() {
916
2003
  try {
917
- const body = await readFile3(path3.join(webRoot, "index.html"));
2004
+ const body = await readFile4(path5.join(webRoot, "index.html"));
918
2005
  return new Response(body, {
919
2006
  headers: { "content-type": "text/html; charset=utf-8" }
920
2007
  });
921
- } catch {
2008
+ } catch (error) {
2009
+ if (!isFileNotFound(error)) {
2010
+ throw error;
2011
+ }
922
2012
  return new Response("Gloss web assets are missing. Run pnpm build.", { status: 500 });
923
2013
  }
924
2014
  }
925
2015
  function serveRootFile(fileName, contentType) {
926
2016
  return async () => {
927
2017
  try {
928
- const body = await readFile3(path3.join(webRoot, fileName));
2018
+ const body = await readFile4(path5.join(webRoot, fileName));
929
2019
  return new Response(body, {
930
2020
  headers: { "content-type": contentType }
931
2021
  });
932
- } catch {
2022
+ } catch (error) {
2023
+ if (!isFileNotFound(error)) {
2024
+ throw error;
2025
+ }
933
2026
  return new Response(`${fileName} is missing. Run pnpm build.`, { status: 404 });
934
2027
  }
935
2028
  };
@@ -941,7 +2034,7 @@ async function readJsonBody(c, guard, label) {
941
2034
  } catch (error) {
942
2035
  return {
943
2036
  ok: false,
944
- response: c.json({ error: `invalid JSON body: ${formatError2(error)}` }, 400)
2037
+ response: c.json({ error: `invalid JSON body: ${formatError(error)}` }, 400)
945
2038
  };
946
2039
  }
947
2040
  try {
@@ -949,22 +2042,77 @@ async function readJsonBody(c, guard, label) {
949
2042
  } catch (error) {
950
2043
  return {
951
2044
  ok: false,
952
- response: c.json({ error: formatError2(error) }, 400)
2045
+ response: c.json({ error: formatError(error) }, 400)
953
2046
  };
954
2047
  }
955
2048
  }
956
- function formatError2(error) {
957
- return error instanceof Error ? error.message : String(error);
2049
+ function isPathWithin(parentPath, childPath) {
2050
+ const relative = path5.relative(parentPath, childPath);
2051
+ return relative === "" || !relative.startsWith("..") && !path5.isAbsolute(relative);
2052
+ }
2053
+ function activeTurnSummary(meta) {
2054
+ if (!meta.activeTurnId) {
2055
+ throw new Error(`Review ${meta.id} has no active turn`);
2056
+ }
2057
+ return turnSummary2(meta, meta.activeTurnId);
2058
+ }
2059
+ function turnSummary2(meta, turnId) {
2060
+ const summary = meta.turns?.find((turn) => turn.id === turnId);
2061
+ if (!summary) {
2062
+ throw new Error(`Review ${meta.id} is missing turn ${turnId}`);
2063
+ }
2064
+ return summary;
2065
+ }
2066
+ function statusForStoreError(error) {
2067
+ return /not found/i.test(formatError(error)) ? 404 : 409;
2068
+ }
2069
+
2070
+ // src/server/maintenance.ts
2071
+ var defaultLogger = {
2072
+ info: (message) => {
2073
+ process.stdout.write(`${message}
2074
+ `);
2075
+ },
2076
+ error: (message) => {
2077
+ process.stderr.write(`${message}
2078
+ `);
2079
+ }
2080
+ };
2081
+ async function runStartupCleanup(logger = defaultLogger) {
2082
+ try {
2083
+ const result = await reviewStore.clearReviewArtifacts({
2084
+ olderThanDays: DEFAULT_REVIEW_RETENTION_DAYS
2085
+ });
2086
+ logger.info(
2087
+ `Gloss cleanup deleted ${result.counts.deleted} review artifact(s); skipped ${result.counts.skipped}`
2088
+ );
2089
+ } catch (error) {
2090
+ logger.error(`Gloss cleanup failed: ${formatError(error)}`);
2091
+ }
958
2092
  }
959
2093
 
960
2094
  // src/server/daemon.ts
961
2095
  var port = Number(process.env.GLOSS_PORT ?? "0");
2096
+ var idleTimeoutMs = Number(process.env.GLOSS_IDLE_TIMEOUT_MS ?? "120000");
962
2097
  if (!port) {
963
2098
  throw new Error("GLOSS_PORT is required");
964
2099
  }
965
2100
  var origin = `http://localhost:${port}`;
2101
+ var eventStreams = /* @__PURE__ */ new Set();
2102
+ var idleTimer = null;
2103
+ var shuttingDown = false;
966
2104
  var server = serve({
967
- fetch: createApp(origin).fetch,
2105
+ fetch: createApp(origin, {
2106
+ onReviewActivity: () => {
2107
+ void scheduleIdleShutdown();
2108
+ },
2109
+ registerEventStream: (close) => {
2110
+ eventStreams.add(close);
2111
+ return () => {
2112
+ eventStreams.delete(close);
2113
+ };
2114
+ }
2115
+ }).fetch,
968
2116
  port
969
2117
  });
970
2118
  await writeServerInfo({
@@ -974,9 +2122,65 @@ await writeServerInfo({
974
2122
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
975
2123
  stateDir: globalStateDir()
976
2124
  });
977
- process.on("SIGTERM", () => {
978
- server.close(() => {
979
- process.exit(0);
2125
+ await runStartupCleanup();
2126
+ await scheduleIdleShutdown();
2127
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
2128
+ process.on(signal, () => {
2129
+ void shutdown(0);
980
2130
  });
981
- });
2131
+ }
2132
+ async function scheduleIdleShutdown() {
2133
+ if (shuttingDown || idleTimeoutMs <= 0) {
2134
+ return;
2135
+ }
2136
+ const activeReviews = await countActiveReviews();
2137
+ if (activeReviews > 0) {
2138
+ if (idleTimer) {
2139
+ clearTimeout(idleTimer);
2140
+ idleTimer = null;
2141
+ }
2142
+ return;
2143
+ }
2144
+ if (!idleTimer) {
2145
+ idleTimer = setTimeout(() => {
2146
+ idleTimer = null;
2147
+ void shutdownIfIdle();
2148
+ }, idleTimeoutMs);
2149
+ }
2150
+ }
2151
+ async function shutdownIfIdle() {
2152
+ if (await countActiveReviews() > 0) {
2153
+ await scheduleIdleShutdown();
2154
+ return;
2155
+ }
2156
+ await shutdown(0);
2157
+ }
2158
+ async function countActiveReviews() {
2159
+ const reviews = await reviewStore.list();
2160
+ return reviews.filter((review) => review.status === "pending").length;
2161
+ }
2162
+ async function shutdown(exitCode) {
2163
+ if (shuttingDown) {
2164
+ return;
2165
+ }
2166
+ shuttingDown = true;
2167
+ if (idleTimer) {
2168
+ clearTimeout(idleTimer);
2169
+ idleTimer = null;
2170
+ }
2171
+ for (const close of [...eventStreams]) {
2172
+ close();
2173
+ }
2174
+ await new Promise((resolve) => {
2175
+ server.close(() => resolve());
2176
+ });
2177
+ await removeCurrentServerInfo();
2178
+ process.exit(exitCode);
2179
+ }
2180
+ async function removeCurrentServerInfo() {
2181
+ const info = await readServerInfo().catch(() => null);
2182
+ if (!info || info.pid === process.pid) {
2183
+ await rm3(globalServerFile(), { force: true });
2184
+ }
2185
+ }
982
2186
  //# sourceMappingURL=daemon.js.map