getgloss 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  // src/server/daemon.ts
2
- import { rm as rm2 } from "fs/promises";
2
+ import { rm as rm3 } from "fs/promises";
3
3
  import { serve } from "@hono/node-server";
4
4
 
5
5
  // src/shared/paths.ts
@@ -10,7 +10,7 @@ import path from "path";
10
10
  // package.json
11
11
  var package_default = {
12
12
  name: "getgloss",
13
- version: "0.8.0",
13
+ version: "0.8.2",
14
14
  description: "Local browser-based diff review for coding-agent loops.",
15
15
  type: "module",
16
16
  packageManager: "pnpm@10.33.2",
@@ -211,6 +211,9 @@ function parseJsonValue(value, guard, label) {
211
211
  function isServerInfo(value) {
212
212
  return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
213
213
  }
214
+ function isClearReviewsRequest(value) {
215
+ return isRecord(value) && isOptionalNonNegativeInteger(value.olderThanDays) && isOptionalBoolean(value.dryRun);
216
+ }
214
217
  function isOpenFileRequest(value) {
215
218
  return isRecord(value) && isString(value.filePath) && isOptionalString(value.turnId);
216
219
  }
@@ -329,6 +332,12 @@ function isNumber(value) {
329
332
  function isOptionalNumber(value) {
330
333
  return value === void 0 || isNumber(value);
331
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
+ }
332
341
  function isNullableNumber(value) {
333
342
  return value === null || isNumber(value);
334
343
  }
@@ -366,7 +375,7 @@ async function writeServerInfo(info) {
366
375
  }
367
376
 
368
377
  // src/server/index.ts
369
- import { readFile as readFile3, realpath, stat } from "fs/promises";
378
+ import { readFile as readFile4, realpath, stat } from "fs/promises";
370
379
  import path5 from "path";
371
380
  import { fileURLToPath } from "url";
372
381
  import { Hono } from "hono";
@@ -601,10 +610,224 @@ async function openLocalPath(filePath) {
601
610
 
602
611
  // src/server/store.ts
603
612
  import { createHash } from "crypto";
604
- import { readdir, readFile as readFile2 } from "fs/promises";
613
+ import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
605
614
  import path4 from "path";
606
615
  import { ulid } from "ulid";
607
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
+
608
831
  // src/shared/review-scope.ts
609
832
  var ALL_REVIEW_SCOPE = { mode: "all" };
610
833
  function normalizeReviewScope(diff, scope = ALL_REVIEW_SCOPE) {
@@ -789,6 +1012,15 @@ var ReviewStore = class {
789
1012
  await this.loadAllReviews();
790
1013
  return Array.from(this.reviews.values()).map((record) => record.meta).toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
791
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;
1023
+ }
792
1024
  async get(id) {
793
1025
  return this.reviews.get(id) ?? await this.loadKnownReview(id);
794
1026
  }
@@ -1017,7 +1249,7 @@ var ReviewStore = class {
1017
1249
  async loadAllReviews() {
1018
1250
  let entries;
1019
1251
  try {
1020
- entries = await readdir(globalReviewsDir(), { withFileTypes: true });
1252
+ entries = await readdir2(globalReviewsDir(), { withFileTypes: true });
1021
1253
  } catch (error) {
1022
1254
  if (isFileNotFound(error)) {
1023
1255
  return;
@@ -1041,7 +1273,7 @@ var ReviewStore = class {
1041
1273
  const metaPath = globalReviewMetaFile(id);
1042
1274
  let metaRaw;
1043
1275
  try {
1044
- metaRaw = await readFile2(metaPath, "utf8");
1276
+ metaRaw = await readFile3(metaPath, "utf8");
1045
1277
  } catch (error) {
1046
1278
  if (isFileNotFound(error)) {
1047
1279
  return this.loadReviewFromTurnsOnly(id);
@@ -1095,7 +1327,7 @@ var ReviewStore = class {
1095
1327
  async loadPersistedTurns(id) {
1096
1328
  let entries;
1097
1329
  try {
1098
- entries = await readdir(globalReviewTurnsDir(id), { withFileTypes: true });
1330
+ entries = await readdir2(globalReviewTurnsDir(id), { withFileTypes: true });
1099
1331
  } catch (error) {
1100
1332
  if (isFileNotFound(error)) {
1101
1333
  return [];
@@ -1123,8 +1355,8 @@ var ReviewStore = class {
1123
1355
  let diffRaw;
1124
1356
  try {
1125
1357
  [metaRaw, diffRaw] = await Promise.all([
1126
- readFile2(metaPath, "utf8"),
1127
- readFile2(diffPath, "utf8")
1358
+ readFile3(metaPath, "utf8"),
1359
+ readFile3(diffPath, "utf8")
1128
1360
  ]);
1129
1361
  } catch (error) {
1130
1362
  if (isFileNotFound(error)) {
@@ -1154,7 +1386,7 @@ var ReviewStore = class {
1154
1386
  const diffPath = globalReviewDiffFile(id);
1155
1387
  let diffRaw;
1156
1388
  try {
1157
- diffRaw = await readFile2(diffPath, "utf8");
1389
+ diffRaw = await readFile3(diffPath, "utf8");
1158
1390
  } catch (error) {
1159
1391
  if (isFileNotFound(error)) {
1160
1392
  return null;
@@ -1369,7 +1601,7 @@ function requiredPath(value, label) {
1369
1601
  async function readOptionalJsonFile(filePath, guard, label) {
1370
1602
  let raw;
1371
1603
  try {
1372
- raw = await readFile2(filePath, "utf8");
1604
+ raw = await readFile3(filePath, "utf8");
1373
1605
  } catch (error) {
1374
1606
  if (isFileNotFound(error)) {
1375
1607
  return void 0;
@@ -1417,6 +1649,15 @@ function createApp(origin2, options = {}) {
1417
1649
  const response = { reviews: await reviewStore.list() };
1418
1650
  return c.json(response);
1419
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
+ });
1420
1661
  app.post("/api/reviews", async (c) => {
1421
1662
  const parsed = await readJsonBody(c, isDiffPayload, "review diff");
1422
1663
  if (!parsed.ok) {
@@ -1745,7 +1986,7 @@ async function serveAsset(c) {
1745
1986
  const normalized = path5.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
1746
1987
  const assetPath = path5.join(webRoot, "assets", normalized);
1747
1988
  try {
1748
- const body = await readFile3(assetPath);
1989
+ const body = await readFile4(assetPath);
1749
1990
  return new Response(body, {
1750
1991
  headers: {
1751
1992
  "content-type": mimeTypes[path5.extname(assetPath)] ?? "application/octet-stream"
@@ -1760,7 +2001,7 @@ async function serveAsset(c) {
1760
2001
  }
1761
2002
  async function serveIndex() {
1762
2003
  try {
1763
- const body = await readFile3(path5.join(webRoot, "index.html"));
2004
+ const body = await readFile4(path5.join(webRoot, "index.html"));
1764
2005
  return new Response(body, {
1765
2006
  headers: { "content-type": "text/html; charset=utf-8" }
1766
2007
  });
@@ -1774,7 +2015,7 @@ async function serveIndex() {
1774
2015
  function serveRootFile(fileName, contentType) {
1775
2016
  return async () => {
1776
2017
  try {
1777
- const body = await readFile3(path5.join(webRoot, fileName));
2018
+ const body = await readFile4(path5.join(webRoot, fileName));
1778
2019
  return new Response(body, {
1779
2020
  headers: { "content-type": contentType }
1780
2021
  });
@@ -1826,6 +2067,30 @@ function statusForStoreError(error) {
1826
2067
  return /not found/i.test(formatError(error)) ? 404 : 409;
1827
2068
  }
1828
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
+ }
2092
+ }
2093
+
1829
2094
  // src/server/daemon.ts
1830
2095
  var port = Number(process.env.GLOSS_PORT ?? "0");
1831
2096
  var idleTimeoutMs = Number(process.env.GLOSS_IDLE_TIMEOUT_MS ?? "120000");
@@ -1857,6 +2122,7 @@ await writeServerInfo({
1857
2122
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1858
2123
  stateDir: globalStateDir()
1859
2124
  });
2125
+ await runStartupCleanup();
1860
2126
  await scheduleIdleShutdown();
1861
2127
  for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
1862
2128
  process.on(signal, () => {
@@ -1914,7 +2180,7 @@ async function shutdown(exitCode) {
1914
2180
  async function removeCurrentServerInfo() {
1915
2181
  const info = await readServerInfo().catch(() => null);
1916
2182
  if (!info || info.pid === process.pid) {
1917
- await rm2(globalServerFile(), { force: true });
2183
+ await rm3(globalServerFile(), { force: true });
1918
2184
  }
1919
2185
  }
1920
2186
  //# sourceMappingURL=daemon.js.map