@tiens.nguyen/gu-cli 1.0.787 → 1.0.789

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/clear-logs.mjs ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * "clear logs" (#329) — what is taking the disk under ~/.gonext, and a way to give it back.
3
+ *
4
+ * Asked for after this Mac's disk filled three times in one day and took the local MongoDB down
5
+ * with it (#328). A click on the notice row's "clear logs" (or /clear-logs) DELETES NOTHING at
6
+ * first: it measures, shows the logs beside the two things that are actually big — the RAG indexes
7
+ * and the downloaded API versions — with a total, and then asks about each of the three.
8
+ *
9
+ * THREE DIFFERENT KINDS OF "CLEAR", because losing each costs something different:
10
+ *
11
+ * logs nothing. But the worker and the local API hold their log OPEN: a deleted open file frees
12
+ * no space on macOS/Linux until the holder closes it (and the holder keeps writing into
13
+ * the unlinked file), and on Windows the delete fails outright. So the live "<name>.log"
14
+ * is TRUNCATED IN PLACE and only rotations and finished run-logs are deleted — the same
15
+ * rule rotateIfLarge (log-policy.mjs) already lives by.
16
+ * rag minutes of embedding per folder: an index is rebuilt the next time its folder is used.
17
+ * api nothing, PROVIDED the version in use stays. One folder per installed version, none ever
18
+ * removed; deleting the running one would break every open terminal's turn. So "clear
19
+ * api" only ever means the OLDER versions.
20
+ *
21
+ * SELECTED BY AN EXPLICIT LIST, never by a "*.log*" / "*.jsonl" sweep of the home: sessions/archive
22
+ * is conversation history (#278) and rag/ shards are also .jsonl. Anything not named here is not
23
+ * touched, whatever the answers.
24
+ *
25
+ * Everything takes `home` so the tests run against a real temp folder, not this machine's.
26
+ */
27
+ import { readdir, readFile, rm, rmdir, stat, truncate, unlink } from "node:fs/promises";
28
+ import { existsSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { pickUsableVersion, compareVersions } from "./version-check.mjs";
32
+
33
+ /** The logs gu writes at the top of its home. `<name>` is live; `<name>.N` are its rotations. */
34
+ export const LIVE_LOGS = ["worker.log", "local-api.log", "model.log", "embed.log"];
35
+ /** Folders that hold nothing but finished log files. */
36
+ export const LOG_DIRS = ["run-logs", "logs"];
37
+ /** The link appears once the logs pass this — below it the row stays quiet. */
38
+ export const LOGS_NOTICE_BYTES = 50 * 1024 * 1024;
39
+
40
+ const guHome = (home = homedir()) => join(home, ".gonext");
41
+
42
+ /** "740 KB", "15 MB", "4.9 GB" — two significant figures, the way `du -h` reads. */
43
+ export function humanSize(bytes) {
44
+ const n = Math.max(0, Number(bytes) || 0);
45
+ const units = [["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]];
46
+ for (const [unit, size] of units) {
47
+ if (n < size) continue;
48
+ const v = n / size;
49
+ // 9.96 would print as "10.0" with one decimal — let it round up to the integer form instead.
50
+ return `${v < 9.95 ? v.toFixed(1) : Math.round(v)} ${unit}`;
51
+ }
52
+ return `${Math.round(n)} B`;
53
+ }
54
+
55
+ const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
56
+
57
+ /**
58
+ * What a file takes ON DISK — its blocks, which is what clearing gives back and what `du` reports.
59
+ * The byte length undercounts thousands of small files (an API version read as 85 MB here against
60
+ * du's 120 MB). Windows reports no blocks; the length is the best there is.
61
+ */
62
+ const onDisk = (s) => (s.blocks > 0 ? s.blocks * 512 : s.size);
63
+
64
+ async function fileSize(p) {
65
+ try {
66
+ const s = await stat(p);
67
+ return s.isFile() ? onDisk(s) : -1;
68
+ } catch {
69
+ return -1;
70
+ }
71
+ }
72
+
73
+ /** Bytes under a folder. Children are walked together: an API version is thousands of files. */
74
+ async function dirSize(p) {
75
+ let entries;
76
+ try {
77
+ entries = await readdir(p, { withFileTypes: true });
78
+ } catch {
79
+ return 0;
80
+ }
81
+ const sizes = await Promise.all(
82
+ entries.map(async (e) => {
83
+ const full = join(p, e.name);
84
+ if (e.isDirectory()) return dirSize(full);
85
+ if (e.isSymbolicLink()) return 0; // never followed: a link out of the home is not ours to count
86
+ try {
87
+ return onDisk(await stat(full));
88
+ } catch {
89
+ return 0;
90
+ }
91
+ })
92
+ );
93
+ return sizes.reduce((a, b) => a + b, 0);
94
+ }
95
+
96
+ /**
97
+ * The logs: each live file and its rotations by name, each log folder as a count.
98
+ * → { items: [{ name, bytes, kind: "live" | "rotation" | "dir", files?, paths? }], bytes, files }
99
+ */
100
+ export async function surveyLogs({ home = homedir() } = {}) {
101
+ const root = guHome(home);
102
+ let names = [];
103
+ try {
104
+ names = await readdir(root);
105
+ } catch {
106
+ /* no home yet — nothing to report */
107
+ }
108
+ const items = [];
109
+ for (const live of LIVE_LOGS) {
110
+ const bytes = await fileSize(join(root, live));
111
+ if (bytes >= 0) items.push({ name: live, bytes, kind: "live", path: join(root, live) });
112
+ const rotations = names
113
+ .filter((n) => n.startsWith(live + ".") && /^\d+$/.test(n.slice(live.length + 1)))
114
+ .sort((a, b) => Number(a.slice(live.length + 1)) - Number(b.slice(live.length + 1)));
115
+ for (const r of rotations) {
116
+ const b = await fileSize(join(root, r));
117
+ if (b >= 0) items.push({ name: r, bytes: b, kind: "rotation", path: join(root, r) });
118
+ }
119
+ }
120
+ for (const dir of LOG_DIRS) {
121
+ let files = [];
122
+ try {
123
+ files = (await readdir(join(root, dir), { withFileTypes: true })).filter(
124
+ (e) => e.isFile() && /\.log(\.\d+)?$/.test(e.name)
125
+ );
126
+ } catch {
127
+ continue;
128
+ }
129
+ const paths = files.map((e) => join(root, dir, e.name));
130
+ let bytes = 0;
131
+ for (const p of paths) bytes += Math.max(0, await fileSize(p));
132
+ if (paths.length) items.push({ name: dir, bytes, kind: "dir", files: paths.length, paths });
133
+ }
134
+ return {
135
+ items,
136
+ bytes: items.reduce((n, i) => n + i.bytes, 0),
137
+ files: items.reduce((n, i) => n + (i.kind === "dir" ? i.files : 1), 0),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Every local index: ~/.gonext/rag/<folder hash>/<source hash>/. The hashes say nothing to a
143
+ * person, so the name comes from the manifest the agent wrote (`sourceUrl`). `missing` = that
144
+ * source was a folder on this machine and is gone — the index can never be used again.
145
+ */
146
+ export async function surveyRag({ home = homedir() } = {}) {
147
+ const root = join(guHome(home), "rag");
148
+ const indexes = [];
149
+ let folders = [];
150
+ try {
151
+ folders = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory());
152
+ } catch {
153
+ return { indexes, bytes: 0 };
154
+ }
155
+ for (const f of folders) {
156
+ let sources = [];
157
+ try {
158
+ sources = (await readdir(join(root, f.name), { withFileTypes: true })).filter((e) => e.isDirectory());
159
+ } catch {
160
+ continue;
161
+ }
162
+ for (const s of sources) {
163
+ const dir = join(root, f.name, s.name);
164
+ let source = "";
165
+ try {
166
+ source = String(JSON.parse(await readFile(join(dir, "manifest.json"), "utf8"))?.sourceUrl ?? "");
167
+ } catch {
168
+ /* no manifest — an interrupted index; still counted, still clearable */
169
+ }
170
+ const isPath = source.startsWith("/") || /^[A-Za-z]:[\\/]/.test(source);
171
+ indexes.push({
172
+ dir,
173
+ parent: join(root, f.name),
174
+ source,
175
+ label: source ? source.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || source : `${s.name.slice(0, 8)}…`,
176
+ missing: isPath && !existsSync(source),
177
+ bytes: await dirSize(dir),
178
+ });
179
+ }
180
+ }
181
+ indexes.sort((a, b) => b.bytes - a.bytes);
182
+ return { indexes, bytes: indexes.reduce((n, i) => n + i.bytes, 0) };
183
+ }
184
+
185
+ /**
186
+ * The installed API versions, and which must stay.
187
+ *
188
+ * KEPT: the version answering on the port right now (`runningVersion`, from /api/health), the one
189
+ * `gu-cli api start` would pick (newest that has its dependencies — #181), and the newest folder
190
+ * even when it is not runnable yet (an upgrade in progress). Everything else is "old".
191
+ */
192
+ export async function surveyApi({ home = homedir(), runningVersion = "" } = {}) {
193
+ const root = join(guHome(home), "api");
194
+ let names = [];
195
+ try {
196
+ names = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
197
+ } catch {
198
+ return { versions: [], bytes: 0, oldBytes: 0, old: [], inUse: "" };
199
+ }
200
+ names.sort((a, b) => compareVersions(a, b));
201
+ const usable = pickUsableVersion(names, (v) => existsSync(join(root, v, "node_modules", "mongodb", "package.json")));
202
+ const running = names.includes(runningVersion) ? runningVersion : "";
203
+ const keep = new Set([running, usable, names[names.length - 1]].filter(Boolean));
204
+ const sizes = await Promise.all(names.map((v) => dirSize(join(root, v))));
205
+ const versions = names.map((version, i) => ({ version, dir: join(root, version), bytes: sizes[i], keep: keep.has(version) }));
206
+ const old = versions.filter((v) => !v.keep);
207
+ return {
208
+ versions,
209
+ old,
210
+ bytes: versions.reduce((n, v) => n + v.bytes, 0),
211
+ oldBytes: old.reduce((n, v) => n + v.bytes, 0),
212
+ inUse: running || usable || "",
213
+ };
214
+ }
215
+
216
+ /** All three, measured together. */
217
+ export async function surveyDisk({ home = homedir(), runningVersion = "" } = {}) {
218
+ const [logs, rag, api] = await Promise.all([surveyLogs({ home }), surveyRag({ home }), surveyApi({ home, runningVersion })]);
219
+ return { logs, rag, api, total: logs.bytes + rag.bytes + api.bytes };
220
+ }
221
+
222
+ /** Lay `parts` out after `lead`, breaking only between parts; later rows start at `indent`. */
223
+ function wrapParts(lead, parts, width, { sep = " · ", indent = lead.length } = {}) {
224
+ const pad = " ".repeat(indent);
225
+ const rows = [];
226
+ let cur = lead;
227
+ let empty = true;
228
+ for (const part of parts) {
229
+ if (!empty && cur.length + sep.length + part.length > width) {
230
+ rows.push(cur);
231
+ cur = pad + part;
232
+ } else {
233
+ cur += (empty ? "" : sep) + part;
234
+ }
235
+ empty = false;
236
+ }
237
+ rows.push(cur.trimEnd());
238
+ // a single part longer than the row is cut, never wrapped — a wrapped row moves everything under it
239
+ return rows.map((r) => (r.length > width ? r.slice(0, Math.max(1, width - 1)) + "…" : r));
240
+ }
241
+
242
+ /**
243
+ * What the click shows before it asks anything: the logs with their files, RAG and API with what
244
+ * they are, and the total. Plain text rows that fit `width` — the caller paints and indents them.
245
+ * A part that is not on this machine (no api/ outside Local mode) has no row.
246
+ */
247
+ export function summaryRows(survey, { width = 76, top = 3 } = {}) {
248
+ const w = Math.max(30, width | 0);
249
+ const { logs, rag, api } = survey;
250
+ const parts = [
251
+ { name: "Logs", bytes: logs.bytes, detail: logs.items.map((i) => i.kind === "dir"
252
+ ? `${i.name} ${plural(i.files, "file")} ${humanSize(i.bytes)}`
253
+ : `${i.name} ${humanSize(i.bytes)}`), none: "none" },
254
+ ];
255
+ if (rag.indexes.length) {
256
+ const shown = rag.indexes.slice(0, top);
257
+ const rest = rag.indexes.slice(top);
258
+ const detail = shown.map((i) => `${i.label} ${humanSize(i.bytes)}`);
259
+ if (rest.length) detail.push(`${rest.length} more ${humanSize(rest.reduce((n, i) => n + i.bytes, 0))}`);
260
+ parts.push({ name: "RAG", bytes: rag.bytes, intro: plural(rag.indexes.length, "folder index", "folder indexes") + ":", detail });
261
+ }
262
+ if (api.versions.length) {
263
+ const detail = [plural(api.versions.length, "version")];
264
+ if (api.inUse) detail.push(`${api.inUse} in use`);
265
+ if (api.old.length) detail.push(`${plural(api.old.length, "older one")} ${humanSize(api.oldBytes)}`);
266
+ parts.push({ name: "API", bytes: api.bytes, detail });
267
+ }
268
+ const nameW = Math.max(5, ...parts.map((p) => p.name.length));
269
+ const sizeW = Math.max(humanSize(survey.total).length, ...parts.map((p) => humanSize(p.bytes).length));
270
+ const lead = (name, bytes) => `${name.padEnd(nameW)} ${humanSize(bytes).padStart(sizeW)} `;
271
+ const rows = [];
272
+ for (const p of parts) {
273
+ const head = lead(p.name, p.bytes);
274
+ // "22 folder indexes:" introduces the list — the list wraps under the first index, the
275
+ // other parts under their own first word
276
+ rows.push(...wrapParts(head + (p.intro ? p.intro + " " : ""), p.detail.length ? p.detail : [p.none ?? ""], w, { indent: head.length }));
277
+ }
278
+ rows.push(lead("Total", survey.total).trimEnd());
279
+ return rows;
280
+ }
281
+
282
+ /**
283
+ * Clear the logs `surveyLogs` found: truncate the live ones, delete rotations and log folders'
284
+ * files. → { freed, truncated, deleted, failed: [name] } — `freed` is what really went, so a file
285
+ * Windows refused to let go of is reported, not counted.
286
+ */
287
+ export async function clearLogs(logs) {
288
+ const out = { freed: 0, truncated: 0, deleted: 0, failed: [] };
289
+ for (const item of logs?.items ?? []) {
290
+ if (item.kind === "live") {
291
+ try {
292
+ const before = Math.max(0, await fileSize(item.path));
293
+ await truncate(item.path, 0);
294
+ out.freed += before;
295
+ out.truncated += 1;
296
+ } catch {
297
+ out.failed.push(item.name);
298
+ }
299
+ continue;
300
+ }
301
+ for (const p of item.kind === "dir" ? item.paths : [item.path]) {
302
+ try {
303
+ const before = Math.max(0, await fileSize(p));
304
+ await unlink(p);
305
+ out.freed += before;
306
+ out.deleted += 1;
307
+ } catch (e) {
308
+ if (e?.code === "ENOENT") continue; // pruned meanwhile — gone is what was wanted
309
+ if (!out.failed.includes(item.name)) out.failed.push(item.name);
310
+ }
311
+ }
312
+ }
313
+ return out;
314
+ }
315
+
316
+ /** Remove the given indexes (surveyRag's rows). An emptied folder-hash directory goes with them. */
317
+ export async function clearRagIndexes(indexes) {
318
+ const out = { freed: 0, removed: 0, failed: [] };
319
+ for (const idx of indexes ?? []) {
320
+ try {
321
+ await rm(idx.dir, { recursive: true, force: true });
322
+ out.freed += idx.bytes;
323
+ out.removed += 1;
324
+ await rmdir(idx.parent).catch(() => {}); // only succeeds when nothing else lives there
325
+ } catch {
326
+ out.failed.push(idx.label);
327
+ }
328
+ }
329
+ return out;
330
+ }
331
+
332
+ /** Remove the OLD API versions (surveyApi's `old`). A row marked keep is refused, whoever asks. */
333
+ export async function clearOldApiVersions(versions) {
334
+ const out = { freed: 0, removed: 0, failed: [] };
335
+ for (const v of versions ?? []) {
336
+ if (v.keep) continue;
337
+ try {
338
+ await rm(v.dir, { recursive: true, force: true });
339
+ out.freed += v.bytes;
340
+ out.removed += 1;
341
+ } catch {
342
+ out.failed.push(v.version);
343
+ }
344
+ }
345
+ return out;
346
+ }
347
+
348
+ /** The notice-row item's text, or "" while the logs are too small to be worth a row. */
349
+ export function clearLogsNoticeText(logBytes, { threshold = LOGS_NOTICE_BYTES } = {}) {
350
+ const n = Number(logBytes) || 0;
351
+ return n >= threshold ? `clear logs · ${humanSize(n)} (click)` : "";
352
+ }