getgloss 0.8.3 → 0.8.5
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 +13 -2
- package/dist/cli/index.js +423 -118
- package/dist/cli/index.js.map +1 -1
- package/dist/server/daemon.js +183 -67
- package/dist/server/daemon.js.map +1 -1
- package/dist/web/setup.md +7 -2
- package/package.json +1 -1
- package/skill/SKILL.md +6 -0
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
5
|
+
import { constants } from "fs";
|
|
6
|
+
import { access, rm as rm5, writeFile as writeFile4 } from "fs/promises";
|
|
7
|
+
import path7 from "path";
|
|
4
8
|
import { Command } from "commander";
|
|
5
9
|
import openBrowser from "open";
|
|
6
10
|
|
|
@@ -14,6 +18,9 @@ function formatError(error) {
|
|
|
14
18
|
function isFileNotFound(error) {
|
|
15
19
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
16
20
|
}
|
|
21
|
+
function isPermissionError(error) {
|
|
22
|
+
return error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
|
|
23
|
+
}
|
|
17
24
|
|
|
18
25
|
// src/shared/paths.ts
|
|
19
26
|
import { mkdir } from "fs/promises";
|
|
@@ -23,7 +30,7 @@ import path from "path";
|
|
|
23
30
|
// package.json
|
|
24
31
|
var package_default = {
|
|
25
32
|
name: "getgloss",
|
|
26
|
-
version: "0.8.
|
|
33
|
+
version: "0.8.5",
|
|
27
34
|
description: "Local browser-based diff review for coding-agent loops.",
|
|
28
35
|
type: "module",
|
|
29
36
|
packageManager: "pnpm@10.33.2",
|
|
@@ -119,6 +126,9 @@ function globalStateDir() {
|
|
|
119
126
|
function globalServerFile() {
|
|
120
127
|
return path.join(globalStateDir(), "server.json");
|
|
121
128
|
}
|
|
129
|
+
function globalServerLockDir() {
|
|
130
|
+
return path.join(globalStateDir(), "server.lock");
|
|
131
|
+
}
|
|
122
132
|
function globalLogDir() {
|
|
123
133
|
return path.join(globalStateDir(), "logs");
|
|
124
134
|
}
|
|
@@ -198,10 +208,10 @@ function parseJsonValue(value, guard, label) {
|
|
|
198
208
|
return value;
|
|
199
209
|
}
|
|
200
210
|
function isServerInfo(value) {
|
|
201
|
-
return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
|
|
211
|
+
return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
|
|
202
212
|
}
|
|
203
213
|
function isHealthResponse(value) {
|
|
204
|
-
return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews);
|
|
214
|
+
return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews) && isOptionalNumber(value.connections) && isOptionalString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
|
|
205
215
|
}
|
|
206
216
|
function isClearReviewsResult(value) {
|
|
207
217
|
return isRecord(value) && isString(value.reviewsDir) && isString(value.cutoff) && isNumber(value.olderThanDays) && isBoolean(value.dryRun) && isArrayOf(value.candidates, isClearReviewEntry) && isArrayOf(value.deleted, isClearReviewEntry) && isArrayOf(value.skipped, isClearReviewSkipped) && isRecord(value.counts) && isNumber(value.counts.candidates) && isNumber(value.counts.deleted) && isNumber(value.counts.skipped);
|
|
@@ -592,11 +602,127 @@ function cleanupResult({
|
|
|
592
602
|
};
|
|
593
603
|
}
|
|
594
604
|
|
|
605
|
+
// src/shared/server-info.ts
|
|
606
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
607
|
+
import { readFile as readFile2, rm as rm3, writeFile as writeFile2 } from "fs/promises";
|
|
608
|
+
import path3 from "path";
|
|
609
|
+
|
|
610
|
+
// src/shared/json.ts
|
|
611
|
+
import { randomUUID } from "crypto";
|
|
612
|
+
import { rename, rm as rm2, writeFile } from "fs/promises";
|
|
613
|
+
import path2 from "path";
|
|
614
|
+
function serializeJson(value) {
|
|
615
|
+
return `${JSON.stringify(value, null, 2)}
|
|
616
|
+
`;
|
|
617
|
+
}
|
|
618
|
+
async function writeJsonFile(filePath, value) {
|
|
619
|
+
await writeTextFile(filePath, serializeJson(value));
|
|
620
|
+
}
|
|
621
|
+
async function writeTextFile(filePath, value) {
|
|
622
|
+
const tempPath = path2.join(
|
|
623
|
+
path2.dirname(filePath),
|
|
624
|
+
`.${path2.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
|
|
625
|
+
);
|
|
626
|
+
try {
|
|
627
|
+
await writeFile(tempPath, value);
|
|
628
|
+
await rename(tempPath, filePath);
|
|
629
|
+
} catch (error) {
|
|
630
|
+
await rm2(tempPath, { force: true }).catch(() => void 0);
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// src/shared/server-info.ts
|
|
636
|
+
async function readServerInfo() {
|
|
637
|
+
let raw;
|
|
638
|
+
try {
|
|
639
|
+
raw = await readFile2(globalServerFile(), "utf8");
|
|
640
|
+
} catch (error) {
|
|
641
|
+
if (isFileNotFound(error)) {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
if (isPermissionError(error)) {
|
|
645
|
+
throw new Error(serverInfoPermissionMessage("read", error), { cause: error });
|
|
646
|
+
}
|
|
647
|
+
throw new Error(`Could not read server info at ${globalServerFile()}: ${formatError(error)}`, {
|
|
648
|
+
cause: error
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
return parseJson(raw, isServerInfo, "server info");
|
|
653
|
+
} catch (error) {
|
|
654
|
+
throw new Error(`Invalid server info at ${globalServerFile()}: ${formatError(error)}`, {
|
|
655
|
+
cause: error
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
async function writeServerInfo(info) {
|
|
660
|
+
try {
|
|
661
|
+
await ensureDir(globalStateDir());
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (isPermissionError(error)) {
|
|
664
|
+
throw new Error(serverInfoPermissionMessage("create", error), { cause: error });
|
|
665
|
+
}
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
await writeJsonFile(globalServerFile(), info);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (!isPermissionError(error)) {
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
await assertStateDirWritable();
|
|
675
|
+
try {
|
|
676
|
+
await writeFile2(globalServerFile(), serializeServerInfo(info));
|
|
677
|
+
} catch (directWriteError) {
|
|
678
|
+
throw new Error(serverInfoPermissionMessage("write", directWriteError), {
|
|
679
|
+
cause: directWriteError
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async function removeServerInfoFile() {
|
|
685
|
+
try {
|
|
686
|
+
await rm3(globalServerFile(), { force: true });
|
|
687
|
+
return null;
|
|
688
|
+
} catch (error) {
|
|
689
|
+
return serverInfoPermissionMessage("remove", error);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function serverInfoPermissionMessage(action, error) {
|
|
693
|
+
const stateDir = globalStateDir();
|
|
694
|
+
const source = process.env.GLOSS_STATE_DIR ? `GLOSS_STATE_DIR=${stateDir}` : "GLOSS_STATE_DIR is not set; defaulting to ~/.gloss";
|
|
695
|
+
return [
|
|
696
|
+
`Could not ${action} Gloss server state at ${globalServerFile()}: ${formatError(error)}.`,
|
|
697
|
+
"`server.json` is not a review lock, so there is nothing to unlock after a review.",
|
|
698
|
+
`Check that ${stateDir} and ${globalServerFile()} are owned and writable by your user.`,
|
|
699
|
+
`On macOS, if the file is immutable, run \`chflags nouchg "${globalServerFile()}"\`.`,
|
|
700
|
+
`For sandboxed agents, set GLOSS_STATE_DIR to a writable directory. ${source}.`
|
|
701
|
+
].join(" ");
|
|
702
|
+
}
|
|
703
|
+
function serializeServerInfo(info) {
|
|
704
|
+
return `${JSON.stringify(info, null, 2)}
|
|
705
|
+
`;
|
|
706
|
+
}
|
|
707
|
+
async function assertStateDirWritable() {
|
|
708
|
+
const probePath = path3.join(
|
|
709
|
+
globalStateDir(),
|
|
710
|
+
`.server.json.${process.pid}.${randomUUID2()}.probe`
|
|
711
|
+
);
|
|
712
|
+
try {
|
|
713
|
+
await writeFile2(probePath, "");
|
|
714
|
+
await rm3(probePath, { force: true });
|
|
715
|
+
} catch (error) {
|
|
716
|
+
await rm3(probePath, { force: true }).catch(() => void 0);
|
|
717
|
+
throw new Error(serverInfoPermissionMessage("write", error), { cause: error });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
595
721
|
// src/cli/git.ts
|
|
596
722
|
import { execa } from "execa";
|
|
597
723
|
|
|
598
724
|
// src/shared/language.ts
|
|
599
|
-
import
|
|
725
|
+
import path4 from "path";
|
|
600
726
|
var languageByExtension = {
|
|
601
727
|
cjs: "js",
|
|
602
728
|
css: "css",
|
|
@@ -618,7 +744,7 @@ var languageByExtension = {
|
|
|
618
744
|
yml: "yaml"
|
|
619
745
|
};
|
|
620
746
|
function languageForPath(filePath) {
|
|
621
|
-
const ext =
|
|
747
|
+
const ext = path4.extname(filePath).slice(1).toLowerCase();
|
|
622
748
|
if (!ext) {
|
|
623
749
|
return null;
|
|
624
750
|
}
|
|
@@ -984,67 +1110,15 @@ async function assertGitAvailable() {
|
|
|
984
1110
|
// src/cli/lifecycle.ts
|
|
985
1111
|
import { execFile, spawn } from "child_process";
|
|
986
1112
|
import { closeSync, existsSync, openSync } from "fs";
|
|
987
|
-
import { rm as
|
|
1113
|
+
import { mkdir as mkdir2, readFile as readFile3, rm as rm4, stat, writeFile as writeFile3 } from "fs/promises";
|
|
988
1114
|
import { userInfo } from "os";
|
|
1115
|
+
import path5 from "path";
|
|
989
1116
|
import { fileURLToPath } from "url";
|
|
990
1117
|
import { promisify } from "util";
|
|
991
1118
|
import getPort from "get-port";
|
|
992
1119
|
|
|
993
|
-
// src/shared/server-info.ts
|
|
994
|
-
import { readFile as readFile2 } from "fs/promises";
|
|
995
|
-
|
|
996
|
-
// src/shared/json.ts
|
|
997
|
-
import { randomUUID } from "crypto";
|
|
998
|
-
import { rename, rm as rm2, writeFile } from "fs/promises";
|
|
999
|
-
import path3 from "path";
|
|
1000
|
-
function serializeJson(value) {
|
|
1001
|
-
return `${JSON.stringify(value, null, 2)}
|
|
1002
|
-
`;
|
|
1003
|
-
}
|
|
1004
|
-
async function writeJsonFile(filePath, value) {
|
|
1005
|
-
await writeTextFile(filePath, serializeJson(value));
|
|
1006
|
-
}
|
|
1007
|
-
async function writeTextFile(filePath, value) {
|
|
1008
|
-
const tempPath = path3.join(
|
|
1009
|
-
path3.dirname(filePath),
|
|
1010
|
-
`.${path3.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
|
|
1011
|
-
);
|
|
1012
|
-
try {
|
|
1013
|
-
await writeFile(tempPath, value);
|
|
1014
|
-
await rename(tempPath, filePath);
|
|
1015
|
-
} catch (error) {
|
|
1016
|
-
await rm2(tempPath, { force: true }).catch(() => void 0);
|
|
1017
|
-
throw error;
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// src/shared/server-info.ts
|
|
1022
|
-
async function readServerInfo() {
|
|
1023
|
-
let raw;
|
|
1024
|
-
try {
|
|
1025
|
-
raw = await readFile2(globalServerFile(), "utf8");
|
|
1026
|
-
} catch (error) {
|
|
1027
|
-
if (isFileNotFound(error)) {
|
|
1028
|
-
return null;
|
|
1029
|
-
}
|
|
1030
|
-
throw new Error(`Could not read server info at ${globalServerFile()}: ${formatError(error)}`, {
|
|
1031
|
-
cause: error
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1034
|
-
try {
|
|
1035
|
-
return parseJson(raw, isServerInfo, "server info");
|
|
1036
|
-
} catch (error) {
|
|
1037
|
-
throw new Error(`Invalid server info at ${globalServerFile()}: ${formatError(error)}`, {
|
|
1038
|
-
cause: error
|
|
1039
|
-
});
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
async function writeServerInfo(info) {
|
|
1043
|
-
await ensureDir(globalStateDir());
|
|
1044
|
-
await writeJsonFile(globalServerFile(), info);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
1120
|
// src/cli/server-client.ts
|
|
1121
|
+
var timedWatchAttemptMs = 1e3;
|
|
1048
1122
|
var ServerClient = class {
|
|
1049
1123
|
constructor(baseUrl) {
|
|
1050
1124
|
this.baseUrl = baseUrl;
|
|
@@ -1123,11 +1197,15 @@ var ServerClient = class {
|
|
|
1123
1197
|
throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
|
|
1124
1198
|
}
|
|
1125
1199
|
const controller = new AbortController();
|
|
1126
|
-
const
|
|
1200
|
+
const timeoutMs = remainingMs === null ? null : Math.min(remainingMs, timedWatchAttemptMs);
|
|
1201
|
+
const timeout = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
1127
1202
|
try {
|
|
1128
1203
|
return await this.readReviewEvents(reviewId, controller.signal);
|
|
1129
1204
|
} catch (error) {
|
|
1130
1205
|
if (isAbortError(error)) {
|
|
1206
|
+
if (deadline && Date.now() < deadline) {
|
|
1207
|
+
throw new Error("watch stream ended before completion");
|
|
1208
|
+
}
|
|
1131
1209
|
throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
|
|
1132
1210
|
}
|
|
1133
1211
|
if (!isPrematureWatchEnd(error)) {
|
|
@@ -1172,20 +1250,20 @@ var ServerClient = class {
|
|
|
1172
1250
|
}
|
|
1173
1251
|
}
|
|
1174
1252
|
}
|
|
1175
|
-
async get(
|
|
1176
|
-
const response = await fetch(`${this.baseUrl}${
|
|
1253
|
+
async get(path8, guard, label) {
|
|
1254
|
+
const response = await fetch(`${this.baseUrl}${path8}`);
|
|
1177
1255
|
return parseResponse(response, guard, label);
|
|
1178
1256
|
}
|
|
1179
|
-
async post(
|
|
1180
|
-
const response = await fetch(`${this.baseUrl}${
|
|
1257
|
+
async post(path8, body, guard, label) {
|
|
1258
|
+
const response = await fetch(`${this.baseUrl}${path8}`, {
|
|
1181
1259
|
method: "POST",
|
|
1182
1260
|
headers: { "content-type": "application/json" },
|
|
1183
1261
|
body: JSON.stringify(body)
|
|
1184
1262
|
});
|
|
1185
1263
|
return parseResponse(response, guard, label);
|
|
1186
1264
|
}
|
|
1187
|
-
async delete(
|
|
1188
|
-
const response = await fetch(`${this.baseUrl}${
|
|
1265
|
+
async delete(path8, guard, label) {
|
|
1266
|
+
const response = await fetch(`${this.baseUrl}${path8}`, { method: "DELETE" });
|
|
1189
1267
|
return parseResponse(response, guard, label);
|
|
1190
1268
|
}
|
|
1191
1269
|
};
|
|
@@ -1210,6 +1288,8 @@ async function sleep(milliseconds) {
|
|
|
1210
1288
|
var execFileAsync = promisify(execFile);
|
|
1211
1289
|
var gracefulShutdownTimeoutMs = 2e3;
|
|
1212
1290
|
var forceShutdownTimeoutMs = 1e3;
|
|
1291
|
+
var serverLockTimeoutMs = 8e3;
|
|
1292
|
+
var staleServerLockMs = 3e4;
|
|
1213
1293
|
function serverUrl(info) {
|
|
1214
1294
|
return `http://localhost:${info.port}`;
|
|
1215
1295
|
}
|
|
@@ -1225,17 +1305,26 @@ async function isServerResponsive(info) {
|
|
|
1225
1305
|
}
|
|
1226
1306
|
}
|
|
1227
1307
|
async function ensureServer(options = {}) {
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1308
|
+
return withServerLock(async () => {
|
|
1309
|
+
const existing = await readServerInfo();
|
|
1310
|
+
await reapStaleDaemons(existing);
|
|
1311
|
+
if (existing && await isServerResponsive(existing)) {
|
|
1312
|
+
return existing;
|
|
1313
|
+
}
|
|
1314
|
+
return startServerUnlocked(existing, options);
|
|
1315
|
+
});
|
|
1233
1316
|
}
|
|
1234
1317
|
async function startServer(options = {}) {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1318
|
+
return withServerLock(async () => {
|
|
1319
|
+
const existing = await readServerInfo();
|
|
1320
|
+
await reapStaleDaemons(existing);
|
|
1321
|
+
if (existing && await isServerResponsive(existing)) {
|
|
1322
|
+
return existing;
|
|
1323
|
+
}
|
|
1324
|
+
return startServerUnlocked(existing, options);
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
async function startServerUnlocked(existing, options = {}) {
|
|
1239
1328
|
if (existing) {
|
|
1240
1329
|
await retireServer(existing);
|
|
1241
1330
|
}
|
|
@@ -1274,9 +1363,16 @@ async function launchServer(port) {
|
|
|
1274
1363
|
port,
|
|
1275
1364
|
version: packageVersion,
|
|
1276
1365
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1277
|
-
stateDir: globalStateDir()
|
|
1366
|
+
stateDir: globalStateDir(),
|
|
1367
|
+
cwd: process.cwd(),
|
|
1368
|
+
daemonPath
|
|
1278
1369
|
};
|
|
1279
|
-
|
|
1370
|
+
try {
|
|
1371
|
+
await writeServerInfo(info);
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
await terminatePid(info.pid);
|
|
1374
|
+
throw error;
|
|
1375
|
+
}
|
|
1280
1376
|
const deadline = Date.now() + 8e3;
|
|
1281
1377
|
while (Date.now() < deadline) {
|
|
1282
1378
|
if (await isServerResponsive(info)) {
|
|
@@ -1288,36 +1384,135 @@ async function launchServer(port) {
|
|
|
1288
1384
|
await removeServerInfoForPid(info.pid);
|
|
1289
1385
|
throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
|
|
1290
1386
|
}
|
|
1387
|
+
async function withServerLock(fn) {
|
|
1388
|
+
const release = await acquireServerLock();
|
|
1389
|
+
try {
|
|
1390
|
+
return await fn();
|
|
1391
|
+
} finally {
|
|
1392
|
+
await release();
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
async function acquireServerLock() {
|
|
1396
|
+
await ensureDir(globalStateDir());
|
|
1397
|
+
const lockDir = globalServerLockDir();
|
|
1398
|
+
const ownerFile = serverLockOwnerFile();
|
|
1399
|
+
const deadline = Date.now() + serverLockTimeoutMs;
|
|
1400
|
+
while (true) {
|
|
1401
|
+
try {
|
|
1402
|
+
await mkdir2(lockDir);
|
|
1403
|
+
try {
|
|
1404
|
+
await writeFile3(
|
|
1405
|
+
ownerFile,
|
|
1406
|
+
`${JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
|
|
1407
|
+
`
|
|
1408
|
+
);
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
await rm4(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
return () => rm4(lockDir, { recursive: true, force: true });
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
if (!isAlreadyExistsError(error)) {
|
|
1416
|
+
throw error;
|
|
1417
|
+
}
|
|
1418
|
+
if (await removeStaleServerLock(lockDir, ownerFile)) {
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
if (Date.now() >= deadline) {
|
|
1422
|
+
throw new Error(`Timed out waiting for Gloss server lock at ${lockDir}`);
|
|
1423
|
+
}
|
|
1424
|
+
await sleep2(50);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
async function removeStaleServerLock(lockDir, ownerFile) {
|
|
1429
|
+
const owner = await readServerLockOwner(ownerFile);
|
|
1430
|
+
if (owner?.pid && !isPidAlive(owner.pid)) {
|
|
1431
|
+
await rm4(lockDir, { recursive: true, force: true });
|
|
1432
|
+
return true;
|
|
1433
|
+
}
|
|
1434
|
+
if (!owner && await isOldLockDir(lockDir)) {
|
|
1435
|
+
await rm4(lockDir, { recursive: true, force: true });
|
|
1436
|
+
return true;
|
|
1437
|
+
}
|
|
1438
|
+
return false;
|
|
1439
|
+
}
|
|
1440
|
+
async function readServerLockOwner(ownerFile) {
|
|
1441
|
+
try {
|
|
1442
|
+
const raw = await readFile3(ownerFile, "utf8");
|
|
1443
|
+
const parsed = JSON.parse(raw);
|
|
1444
|
+
return typeof parsed.pid === "number" && Number.isFinite(parsed.pid) ? { pid: parsed.pid } : null;
|
|
1445
|
+
} catch {
|
|
1446
|
+
return null;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
async function isOldLockDir(lockDir) {
|
|
1450
|
+
try {
|
|
1451
|
+
const info = await stat(lockDir);
|
|
1452
|
+
return Date.now() - info.mtimeMs > staleServerLockMs;
|
|
1453
|
+
} catch {
|
|
1454
|
+
return false;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
function serverLockOwnerFile() {
|
|
1458
|
+
return path5.join(globalServerLockDir(), "owner.json");
|
|
1459
|
+
}
|
|
1460
|
+
function isAlreadyExistsError(error) {
|
|
1461
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
1462
|
+
}
|
|
1291
1463
|
async function stopServer(options = {}) {
|
|
1292
1464
|
if (options.all) {
|
|
1293
|
-
const info2 = await
|
|
1294
|
-
const daemonPids = await
|
|
1465
|
+
const { info: info2, warning: readWarning2 } = await readServerInfoForStop();
|
|
1466
|
+
const daemonPids = (await listGlossDaemonProcesses()).map((processInfo) => processInfo.pid);
|
|
1295
1467
|
const stoppedPids = [];
|
|
1296
1468
|
for (const pid of daemonPids) {
|
|
1297
1469
|
if (await terminatePid(pid)) {
|
|
1298
1470
|
stoppedPids.push(pid);
|
|
1299
1471
|
}
|
|
1300
1472
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1473
|
+
return withWarning(
|
|
1474
|
+
{ stopped: stoppedPids.length > 0, info: info2, stoppedPids },
|
|
1475
|
+
combineWarnings(readWarning2, await removeServerInfoFile())
|
|
1476
|
+
);
|
|
1303
1477
|
}
|
|
1304
|
-
const info = await
|
|
1478
|
+
const { info, warning: readWarning } = await readServerInfoForStop();
|
|
1305
1479
|
if (!info) {
|
|
1306
|
-
return { stopped: false, info: null };
|
|
1480
|
+
return withWarning({ stopped: false, info: null }, readWarning);
|
|
1307
1481
|
}
|
|
1308
1482
|
if (!isPidAlive(info.pid)) {
|
|
1309
|
-
await removeServerInfoForPid(info.pid);
|
|
1310
|
-
return { stopped: false, info };
|
|
1483
|
+
return withWarning({ stopped: false, info }, await removeServerInfoForPid(info.pid));
|
|
1311
1484
|
}
|
|
1312
1485
|
if (!await isGlossDaemonPid(info.pid)) {
|
|
1313
|
-
await removeServerInfoForPid(info.pid);
|
|
1314
|
-
return { stopped: false, info };
|
|
1486
|
+
return withWarning({ stopped: false, info }, await removeServerInfoForPid(info.pid));
|
|
1315
1487
|
}
|
|
1316
1488
|
const stopped = await terminatePid(info.pid);
|
|
1489
|
+
let warning = null;
|
|
1317
1490
|
if (stopped) {
|
|
1318
|
-
await removeServerInfoForPid(info.pid);
|
|
1491
|
+
warning = await removeServerInfoForPid(info.pid);
|
|
1492
|
+
}
|
|
1493
|
+
return withWarning({ stopped, info }, warning);
|
|
1494
|
+
}
|
|
1495
|
+
async function reapStaleDaemons(managed) {
|
|
1496
|
+
if (process.env.GLOSS_SKIP_STALE_DAEMON_REAP === "1") {
|
|
1497
|
+
return [];
|
|
1498
|
+
}
|
|
1499
|
+
const processes = await listGlossDaemonProcesses();
|
|
1500
|
+
const managedProcess = managed ? processes.find((processInfo) => processInfo.pid === managed.pid) : null;
|
|
1501
|
+
const managedSource = managedProcess ? daemonSourceKey(managedProcess) : managed?.daemonPath ? daemonPathSourceKey(managed.daemonPath) : null;
|
|
1502
|
+
const reapedPids = [];
|
|
1503
|
+
for (const processInfo of processes) {
|
|
1504
|
+
const shouldReap = isMissingDaemonSource(processInfo) || isStaleHomebrewDaemon(processInfo) || managedSource !== null && processInfo.pid !== managed?.pid && daemonSourceKey(processInfo) === managedSource;
|
|
1505
|
+
if (!shouldReap) {
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (await terminatePid(processInfo.pid)) {
|
|
1509
|
+
reapedPids.push(processInfo.pid);
|
|
1510
|
+
if (processInfo.pid === managed?.pid) {
|
|
1511
|
+
await removeServerInfoForPid(processInfo.pid);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1319
1514
|
}
|
|
1320
|
-
return
|
|
1515
|
+
return reapedPids;
|
|
1321
1516
|
}
|
|
1322
1517
|
function isPidAlive(pid) {
|
|
1323
1518
|
if (pid <= 0) {
|
|
@@ -1361,19 +1556,34 @@ async function waitForPidExit(pid, timeoutMs) {
|
|
|
1361
1556
|
if (!isPidAlive(pid)) {
|
|
1362
1557
|
return true;
|
|
1363
1558
|
}
|
|
1364
|
-
await
|
|
1559
|
+
await sleep2(50);
|
|
1365
1560
|
}
|
|
1366
1561
|
return !isPidAlive(pid);
|
|
1367
1562
|
}
|
|
1368
1563
|
async function removeServerInfoForPid(pid) {
|
|
1369
1564
|
const current = await readServerInfo().catch(() => null);
|
|
1370
1565
|
if (!current || current.pid === pid) {
|
|
1371
|
-
|
|
1566
|
+
return removeServerInfoFile();
|
|
1372
1567
|
}
|
|
1568
|
+
return null;
|
|
1569
|
+
}
|
|
1570
|
+
function withWarning(result, warning) {
|
|
1571
|
+
return warning ? { ...result, warning } : result;
|
|
1572
|
+
}
|
|
1573
|
+
async function readServerInfoForStop() {
|
|
1574
|
+
try {
|
|
1575
|
+
return { info: await readServerInfo(), warning: null };
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
return { info: null, warning: error instanceof Error ? error.message : String(error) };
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
function combineWarnings(...warnings) {
|
|
1581
|
+
const present = warnings.filter((warning) => Boolean(warning));
|
|
1582
|
+
return present.length > 0 ? present.join(" ") : null;
|
|
1373
1583
|
}
|
|
1374
1584
|
async function isGlossDaemonPid(pid) {
|
|
1375
1585
|
const command = await readProcessCommand(pid);
|
|
1376
|
-
return command ?
|
|
1586
|
+
return command ? parseGlossDaemonCommand(command) !== null : false;
|
|
1377
1587
|
}
|
|
1378
1588
|
async function readProcessCommand(pid) {
|
|
1379
1589
|
try {
|
|
@@ -1384,6 +1594,9 @@ async function readProcessCommand(pid) {
|
|
|
1384
1594
|
}
|
|
1385
1595
|
}
|
|
1386
1596
|
async function listGlossDaemonPids() {
|
|
1597
|
+
return (await listGlossDaemonProcesses()).map((processInfo) => processInfo.pid);
|
|
1598
|
+
}
|
|
1599
|
+
async function listGlossDaemonProcesses() {
|
|
1387
1600
|
let stdout;
|
|
1388
1601
|
try {
|
|
1389
1602
|
({ stdout } = await execFileAsync("ps", ["-axo", "pid=,user=,command=", "-ww"]));
|
|
@@ -1391,25 +1604,60 @@ async function listGlossDaemonPids() {
|
|
|
1391
1604
|
return [];
|
|
1392
1605
|
}
|
|
1393
1606
|
const currentUser = userInfo().username;
|
|
1394
|
-
return
|
|
1607
|
+
return parseGlossDaemonProcesses(stdout, currentUser, process.pid);
|
|
1395
1608
|
}
|
|
1396
|
-
function
|
|
1609
|
+
function parseGlossDaemonProcesses(stdout, currentUser, currentPid = process.pid) {
|
|
1397
1610
|
return stdout.split("\n").map((line) => /^\s*(\d+)\s+(\S+)\s+(.+)$/.exec(line)).filter((match) => Boolean(match)).map((match) => ({
|
|
1398
1611
|
pid: Number(match[1]),
|
|
1399
1612
|
user: match[2],
|
|
1400
1613
|
command: match[3]
|
|
1614
|
+
})).map((processInfo) => ({
|
|
1615
|
+
...processInfo,
|
|
1616
|
+
parsed: parseGlossDaemonCommand(processInfo.command)
|
|
1401
1617
|
})).filter(
|
|
1402
|
-
(
|
|
1403
|
-
).map(({ pid }) =>
|
|
1618
|
+
(processInfo) => processInfo.pid !== currentPid && processInfo.user === currentUser && processInfo.parsed !== null
|
|
1619
|
+
).map(({ pid, user, command, parsed }) => ({
|
|
1620
|
+
pid,
|
|
1621
|
+
user,
|
|
1622
|
+
command,
|
|
1623
|
+
daemonPath: parsed.daemonPath,
|
|
1624
|
+
...parsed.homebrewVersion ? { homebrewVersion: parsed.homebrewVersion } : {}
|
|
1625
|
+
}));
|
|
1626
|
+
}
|
|
1627
|
+
function parseGlossDaemonCommand(command) {
|
|
1628
|
+
const match = /(?:^|\s)(?:\S*\/)?node\s+(\S*dist\/server\/daemon\.js)(?:\s|$)/.exec(command);
|
|
1629
|
+
if (!match) {
|
|
1630
|
+
return null;
|
|
1631
|
+
}
|
|
1632
|
+
const daemonPath = match[1];
|
|
1633
|
+
const homebrewVersion = /\/Cellar\/gloss\/([^/]+)\/libexec\/lib\/node_modules\/getgloss\/dist\/server\/daemon\.js$/.exec(
|
|
1634
|
+
daemonPath
|
|
1635
|
+
)?.[1];
|
|
1636
|
+
return {
|
|
1637
|
+
daemonPath,
|
|
1638
|
+
...homebrewVersion ? { homebrewVersion } : {}
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
function isMissingDaemonSource(processInfo) {
|
|
1642
|
+
return !existsSync(processInfo.daemonPath);
|
|
1404
1643
|
}
|
|
1405
|
-
function
|
|
1406
|
-
return
|
|
1644
|
+
function isStaleHomebrewDaemon(processInfo) {
|
|
1645
|
+
return Boolean(processInfo.homebrewVersion && processInfo.homebrewVersion !== packageVersion);
|
|
1646
|
+
}
|
|
1647
|
+
function daemonSourceKey(processInfo) {
|
|
1648
|
+
return daemonPathSourceKey(processInfo.daemonPath);
|
|
1649
|
+
}
|
|
1650
|
+
function daemonPathSourceKey(daemonPath) {
|
|
1651
|
+
return path5.normalize(daemonPath);
|
|
1652
|
+
}
|
|
1653
|
+
async function sleep2(milliseconds) {
|
|
1654
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
1407
1655
|
}
|
|
1408
1656
|
|
|
1409
1657
|
// src/server/store.ts
|
|
1410
1658
|
import { createHash } from "crypto";
|
|
1411
|
-
import { readdir as readdir2, readFile as
|
|
1412
|
-
import
|
|
1659
|
+
import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
|
|
1660
|
+
import path6 from "path";
|
|
1413
1661
|
import { ulid } from "ulid";
|
|
1414
1662
|
|
|
1415
1663
|
// src/shared/comments.ts
|
|
@@ -1880,16 +2128,25 @@ var ReviewStore = class {
|
|
|
1880
2128
|
const reviewLoads = [];
|
|
1881
2129
|
for (const entry of entries) {
|
|
1882
2130
|
if (entry.isDirectory()) {
|
|
1883
|
-
reviewLoads.push(this.
|
|
2131
|
+
reviewLoads.push(this.loadReviewForList(entry.name));
|
|
1884
2132
|
}
|
|
1885
2133
|
}
|
|
1886
2134
|
await Promise.all(reviewLoads);
|
|
1887
2135
|
}
|
|
2136
|
+
async loadReviewForList(id) {
|
|
2137
|
+
try {
|
|
2138
|
+
return await this.loadReview(id);
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
process.stderr.write(`Warning: Skipping corrupt review ${id}: ${formatError(error)}
|
|
2141
|
+
`);
|
|
2142
|
+
return null;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
1888
2145
|
async loadReview(id) {
|
|
1889
2146
|
const metaPath = globalReviewMetaFile(id);
|
|
1890
2147
|
let metaRaw;
|
|
1891
2148
|
try {
|
|
1892
|
-
metaRaw = await
|
|
2149
|
+
metaRaw = await readFile4(metaPath, "utf8");
|
|
1893
2150
|
} catch (error) {
|
|
1894
2151
|
if (isFileNotFound(error)) {
|
|
1895
2152
|
return this.loadReviewFromTurnsOnly(id);
|
|
@@ -1971,8 +2228,8 @@ var ReviewStore = class {
|
|
|
1971
2228
|
let diffRaw;
|
|
1972
2229
|
try {
|
|
1973
2230
|
[metaRaw, diffRaw] = await Promise.all([
|
|
1974
|
-
|
|
1975
|
-
|
|
2231
|
+
readFile4(metaPath, "utf8"),
|
|
2232
|
+
readFile4(diffPath, "utf8")
|
|
1976
2233
|
]);
|
|
1977
2234
|
} catch (error) {
|
|
1978
2235
|
if (isFileNotFound(error)) {
|
|
@@ -2002,7 +2259,7 @@ var ReviewStore = class {
|
|
|
2002
2259
|
const diffPath = globalReviewDiffFile(id);
|
|
2003
2260
|
let diffRaw;
|
|
2004
2261
|
try {
|
|
2005
|
-
diffRaw = await
|
|
2262
|
+
diffRaw = await readFile4(diffPath, "utf8");
|
|
2006
2263
|
} catch (error) {
|
|
2007
2264
|
if (isFileNotFound(error)) {
|
|
2008
2265
|
return null;
|
|
@@ -2182,9 +2439,9 @@ function reconcileTurn(meta, diff, feedback, resolution) {
|
|
|
2182
2439
|
status,
|
|
2183
2440
|
submittedAt: feedback?.timestamp ?? meta.submittedAt,
|
|
2184
2441
|
resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
|
|
2185
|
-
feedbackPath: feedback ? meta.feedbackPath ??
|
|
2186
|
-
markdownPath: feedback ? meta.markdownPath ??
|
|
2187
|
-
resolvedPath: resolution ? meta.resolvedPath ??
|
|
2442
|
+
feedbackPath: feedback ? meta.feedbackPath ?? path6.join(meta.artifactDir, "feedback.json") : void 0,
|
|
2443
|
+
markdownPath: feedback ? meta.markdownPath ?? path6.join(meta.artifactDir, "feedback.md") : void 0,
|
|
2444
|
+
resolvedPath: resolution ? meta.resolvedPath ?? path6.join(meta.artifactDir, "resolved.json") : void 0,
|
|
2188
2445
|
diff,
|
|
2189
2446
|
...feedback ? { feedback } : {},
|
|
2190
2447
|
...resolution ? { resolution } : {}
|
|
@@ -2217,7 +2474,7 @@ function requiredPath(value, label) {
|
|
|
2217
2474
|
async function readOptionalJsonFile(filePath, guard, label) {
|
|
2218
2475
|
let raw;
|
|
2219
2476
|
try {
|
|
2220
|
-
raw = await
|
|
2477
|
+
raw = await readFile4(filePath, "utf8");
|
|
2221
2478
|
} catch (error) {
|
|
2222
2479
|
if (isFileNotFound(error)) {
|
|
2223
2480
|
return void 0;
|
|
@@ -2367,7 +2624,9 @@ program.command("start").description("Start or reuse the background server").opt
|
|
|
2367
2624
|
});
|
|
2368
2625
|
program.command("status").description("Show server and active reviews").action(async () => {
|
|
2369
2626
|
const globals = program.opts();
|
|
2370
|
-
|
|
2627
|
+
let info = await readServerInfo();
|
|
2628
|
+
await reapStaleDaemons(info);
|
|
2629
|
+
info = await readServerInfo();
|
|
2371
2630
|
const responsive = info ? await isServerResponsive(info) : false;
|
|
2372
2631
|
const reviews = await listReviewsForStatus({ responsive, server: info });
|
|
2373
2632
|
const status = { running: responsive, server: info, reviews };
|
|
@@ -2378,9 +2637,7 @@ program.command("status").description("Show server and active reviews").action(a
|
|
|
2378
2637
|
program.command("stop").description("Stop the managed background server").option("--all", "stop all Gloss daemon processes for the current user").action(async (options) => {
|
|
2379
2638
|
const globals = program.opts();
|
|
2380
2639
|
const result = await stopServer({ all: options.all });
|
|
2381
|
-
globals.json ? printJson(result) : printPlain(
|
|
2382
|
-
options.all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running"
|
|
2383
|
-
);
|
|
2640
|
+
globals.json ? printJson(result) : printPlain(formatStopResult(result, options.all === true));
|
|
2384
2641
|
});
|
|
2385
2642
|
program.command("clear").description("Delete old completed review artifacts").option(
|
|
2386
2643
|
"--older-than <days>",
|
|
@@ -2438,11 +2695,19 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
|
|
|
2438
2695
|
detail: error instanceof Error ? error.message : String(error)
|
|
2439
2696
|
});
|
|
2440
2697
|
}
|
|
2441
|
-
|
|
2698
|
+
checks.push(await checkStateDirAccess());
|
|
2699
|
+
checks.push(await checkServerInfoAccess());
|
|
2700
|
+
let info = null;
|
|
2701
|
+
let serverStateError = null;
|
|
2702
|
+
try {
|
|
2703
|
+
info = await readServerInfo();
|
|
2704
|
+
} catch (error) {
|
|
2705
|
+
serverStateError = error;
|
|
2706
|
+
}
|
|
2442
2707
|
checks.push({
|
|
2443
2708
|
name: "server",
|
|
2444
2709
|
ok: info ? await isServerResponsive(info) : false,
|
|
2445
|
-
detail: info ? serverUrl(info) : "not started"
|
|
2710
|
+
detail: info ? serverUrl(info) : serverStateError ? formatError(serverStateError) : "not started"
|
|
2446
2711
|
});
|
|
2447
2712
|
try {
|
|
2448
2713
|
const daemonPids = await listGlossDaemonPids();
|
|
@@ -2487,7 +2752,7 @@ async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, o
|
|
|
2487
2752
|
if (!isReconnectableWatchError(error)) {
|
|
2488
2753
|
throw error;
|
|
2489
2754
|
}
|
|
2490
|
-
await
|
|
2755
|
+
await sleep3(500);
|
|
2491
2756
|
const nextInfo = await ensureServer();
|
|
2492
2757
|
if (nextInfo.port !== info.port) {
|
|
2493
2758
|
await onServerChanged(nextInfo);
|
|
@@ -2496,6 +2761,46 @@ async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, o
|
|
|
2496
2761
|
}
|
|
2497
2762
|
}
|
|
2498
2763
|
}
|
|
2764
|
+
function formatStopResult(result, all) {
|
|
2765
|
+
const status = all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running";
|
|
2766
|
+
return result.warning ? `${status}
|
|
2767
|
+
Warning: ${result.warning}` : status;
|
|
2768
|
+
}
|
|
2769
|
+
async function checkStateDirAccess() {
|
|
2770
|
+
const probePath = path7.join(globalStateDir(), `.doctor-${process.pid}-${randomUUID3()}.tmp`);
|
|
2771
|
+
try {
|
|
2772
|
+
await ensureDir(globalStateDir());
|
|
2773
|
+
await access(globalStateDir(), constants.R_OK | constants.W_OK | constants.X_OK);
|
|
2774
|
+
await writeFile4(probePath, "");
|
|
2775
|
+
await rm5(probePath, { force: true });
|
|
2776
|
+
return { name: "state-dir", ok: true, detail: stateDirDetail() };
|
|
2777
|
+
} catch (error) {
|
|
2778
|
+
await rm5(probePath, { force: true }).catch(() => void 0);
|
|
2779
|
+
return {
|
|
2780
|
+
name: "state-dir",
|
|
2781
|
+
ok: false,
|
|
2782
|
+
detail: `${stateDirDetail()}: ${formatError(error)}. Set GLOSS_STATE_DIR to a writable directory for sandboxed agents.`
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
async function checkServerInfoAccess() {
|
|
2787
|
+
try {
|
|
2788
|
+
await access(globalServerFile(), constants.R_OK | constants.W_OK);
|
|
2789
|
+
return { name: "server-json", ok: true, detail: globalServerFile() };
|
|
2790
|
+
} catch (error) {
|
|
2791
|
+
if (isFileNotFound(error)) {
|
|
2792
|
+
return { name: "server-json", ok: true, detail: "not present" };
|
|
2793
|
+
}
|
|
2794
|
+
return {
|
|
2795
|
+
name: "server-json",
|
|
2796
|
+
ok: false,
|
|
2797
|
+
detail: serverInfoPermissionMessage("access", error)
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
function stateDirDetail() {
|
|
2802
|
+
return process.env.GLOSS_STATE_DIR ? `${globalStateDir()} (from GLOSS_STATE_DIR)` : `${globalStateDir()} (default; set GLOSS_STATE_DIR for a writable sandbox state dir)`;
|
|
2803
|
+
}
|
|
2499
2804
|
async function baseForExistingReview(client, reviewId) {
|
|
2500
2805
|
const record = await client.getReview(reviewId);
|
|
2501
2806
|
return record.diff.scope.mode === "explicit" ? record.diff.scope.requestedBase ?? record.diff.base.ref : null;
|
|
@@ -2526,7 +2831,7 @@ function isWatchTimeout(error) {
|
|
|
2526
2831
|
function isReconnectableWatchError(error) {
|
|
2527
2832
|
return error instanceof Error && !/^watch failed: [45]\d\d /.test(error.message);
|
|
2528
2833
|
}
|
|
2529
|
-
async function
|
|
2834
|
+
async function sleep3(milliseconds) {
|
|
2530
2835
|
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
2531
2836
|
}
|
|
2532
2837
|
program.parseAsync(process.argv).catch((error) => {
|