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.
- package/README.md +59 -18
- package/dist/cli/index.js +1407 -343
- package/dist/cli/index.js.map +1 -1
- package/dist/server/daemon.js +1397 -193
- package/dist/server/daemon.js.map +1 -1
- package/dist/web/assets/index-DBK7WjlV.css +1 -0
- package/dist/web/assets/index-DkylW082.js +244 -0
- package/dist/web/assets/syntax-DdLzhglo.js +13 -0
- package/dist/web/index.html +2 -2
- package/dist/web/prompt.md +14 -6
- package/dist/web/setup.md +27 -8
- package/package.json +4 -1
- package/skill/SKILL.md +55 -36
- package/dist/web/assets/index-Dj8yXNw3.css +0 -1
- package/dist/web/assets/index-mwzx-OHz.js +0 -189
package/dist/server/daemon.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
|
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.
|
|
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) &&
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
306
|
+
return isOneOf(value, REVIEW_STATUSES);
|
|
207
307
|
}
|
|
208
308
|
function isResolutionStatus(value) {
|
|
209
|
-
return isOneOf(value,
|
|
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
|
|
247
|
-
import
|
|
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/
|
|
280
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
333
|
-
const
|
|
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
|
|
922
|
+
for (const comment of commentsByFile.get(filePath) ?? []) {
|
|
344
923
|
const snippet = comment.originalSnippet.trimEnd();
|
|
345
|
-
const firstSnippetLine = snippet
|
|
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
|
|
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
|
|
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
|
-
|
|
393
|
-
|
|
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:
|
|
401
|
-
branch:
|
|
402
|
-
|
|
1059
|
+
base: turn.diff.base,
|
|
1060
|
+
branch: turn.diff.branch,
|
|
1061
|
+
reviewScope: normalizedReviewScope,
|
|
1062
|
+
comments: sortedComments
|
|
403
1063
|
};
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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(
|
|
1076
|
+
writeJsonFile(globalReviewTurnMetaFile(id, nextTurn.id), turnMeta(nextTurn)),
|
|
419
1077
|
writeJsonFile(feedbackPath, feedback),
|
|
420
|
-
|
|
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.
|
|
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
|
-
(
|
|
1106
|
+
(turn.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
|
|
445
1107
|
);
|
|
446
1108
|
const comments = this.sortResolvedComments(
|
|
447
|
-
(
|
|
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
|
-
|
|
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 ??
|
|
1122
|
+
summary: summary ?? turn.resolution?.summary ?? null,
|
|
459
1123
|
resolvedAt,
|
|
460
1124
|
comments
|
|
461
1125
|
};
|
|
462
|
-
|
|
463
|
-
|
|
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.
|
|
471
|
-
|
|
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 =
|
|
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
|
-
...(
|
|
1160
|
+
...(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
484
1161
|
nextComment
|
|
485
1162
|
],
|
|
486
|
-
|
|
1163
|
+
turn
|
|
487
1164
|
);
|
|
488
|
-
const counts = resolutionCounts(
|
|
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 ?
|
|
1172
|
+
summary: fullyResolved ? turn.resolution?.summary ?? null : null,
|
|
494
1173
|
resolvedAt: fullyResolved ? resolvedAt : null,
|
|
495
1174
|
comments
|
|
496
1175
|
};
|
|
497
|
-
|
|
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.
|
|
506
|
-
|
|
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
|
-
(
|
|
509
|
-
|
|
1196
|
+
(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
1197
|
+
turn
|
|
510
1198
|
);
|
|
511
|
-
const counts = resolutionCounts(
|
|
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 ?
|
|
1207
|
+
summary: fullyResolved ? turn.resolution?.summary ?? null : null,
|
|
518
1208
|
resolvedAt,
|
|
519
1209
|
comments
|
|
520
1210
|
};
|
|
521
|
-
|
|
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
|
-
|
|
542
|
-
await ensureDir(dir);
|
|
1230
|
+
async persistInitial(record, turn) {
|
|
1231
|
+
await ensureDir(turn.artifactDir);
|
|
543
1232
|
await Promise.all([
|
|
544
|
-
writeJsonFile(
|
|
545
|
-
writeJsonFile(
|
|
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
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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)}`, {
|
|
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
|
|
593
|
-
globalReviewFeedbackFile(id),
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
);
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
614
|
-
return record;
|
|
1419
|
+
return reconcileTurn(meta, diff, feedback, resolution);
|
|
615
1420
|
}
|
|
616
|
-
assertResolvable(
|
|
617
|
-
if (!isResolvableReviewStatus(
|
|
618
|
-
throw new Error(`Review ${id} is ${
|
|
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 (!
|
|
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
|
-
|
|
625
|
-
if (!
|
|
626
|
-
|
|
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
|
-
|
|
630
|
-
record.
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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(
|
|
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
|
-
|
|
1462
|
+
turnId: nextTurn.id,
|
|
1463
|
+
turnIndex: nextTurn.index,
|
|
1464
|
+
status: nextTurn.status,
|
|
642
1465
|
resolutionStatus: resolution.status,
|
|
643
|
-
comments: resolutionCounts(
|
|
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,
|
|
1482
|
+
sortResolvedComments(comments, turn) {
|
|
658
1483
|
const feedbackIndex = new Map(
|
|
659
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
1807
|
+
files: turn.diff.files.length,
|
|
826
1808
|
comments: body.comments.length,
|
|
827
|
-
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/
|
|
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
|
-
|
|
840
|
-
|
|
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
|
-
|
|
843
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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 =
|
|
903
|
-
const assetPath =
|
|
1986
|
+
const normalized = path5.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
1987
|
+
const assetPath = path5.join(webRoot, "assets", normalized);
|
|
904
1988
|
try {
|
|
905
|
-
const body = await
|
|
1989
|
+
const body = await readFile4(assetPath);
|
|
906
1990
|
return new Response(body, {
|
|
907
1991
|
headers: {
|
|
908
|
-
"content-type": mimeTypes[
|
|
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
|
|
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
|
|
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: ${
|
|
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:
|
|
2045
|
+
response: c.json({ error: formatError(error) }, 400)
|
|
953
2046
|
};
|
|
954
2047
|
}
|
|
955
2048
|
}
|
|
956
|
-
function
|
|
957
|
-
|
|
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
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|