@link-assistant/hive-mind 2.1.0 → 2.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 26e3410: Report estimated reclaimable space for `hive-cleanup --dry-run` system cleanup
8
+ commands and await system-cleanup logging so dry-run output stays in order.
9
+
3
10
  ## 2.1.0
4
11
 
5
12
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/cleanup.mjs CHANGED
@@ -422,8 +422,8 @@ async function main() {
422
422
 
423
423
  // 8. System / Ubuntu cleanup (opt-in).
424
424
  if (options.apt || options.journal || options.docker || options.npm) {
425
- await log('\n🧓 System cleanup:');
426
- runSystemCleanup({
425
+ await log(options.dryRun ? '\n🧓 System cleanup (dry-run, estimated reclaim):' : '\n🧓 System cleanup:');
426
+ await runSystemCleanup({
427
427
  apt: options.apt,
428
428
  journal: options.journal,
429
429
  docker: options.docker,
@@ -21,6 +21,7 @@ import { execFileSync } from 'node:child_process';
21
21
 
22
22
  import { extractTaskRefsFromCommand, isDockerIsolationSessionName, parseDockerContainerExitCode, parseRemoteUrl } from './cleanup.lib.mjs';
23
23
  import { correlateProcesses, parseStartCommandLogMetadata, redactProcessText } from './process-debug.lib.mjs';
24
+ import { buildSystemCleanupPlan, estimateSystemCleanupPlan, formatSystemCleanupEstimateLine, formatSystemCleanupTotalLine } from './system-cleanup-estimates.lib.mjs';
24
25
 
25
26
  /** Run a command, returning trimmed stdout or null on any failure. */
26
27
  function tryExec(cmd, args, options = {}) {
@@ -867,7 +868,7 @@ export function removeDockerContainer(containerName) {
867
868
 
868
869
  /**
869
870
  * System / Ubuntu cleanup actions. Each is opt-in. In dry-run mode the commands
870
- * are only described, never executed.
871
+ * are estimated and described, never executed.
871
872
  *
872
873
  * @param {Object} options
873
874
  * @param {boolean} [options.apt] - apt-get clean / autoclean / autoremove
@@ -877,41 +878,40 @@ export function removeDockerContainer(containerName) {
877
878
  * @param {string} [options.journalVacuumTime='2weeks']
878
879
  * @param {boolean} [options.dryRun]
879
880
  * @param {boolean} [options.useSudo] - prefix package commands with sudo
880
- * @param {(msg: string) => void} [options.logFn]
881
- * @returns {Array<{command: string, executed: boolean, ok: boolean|null}>}
881
+ * @param {(msg: string) => void|Promise<void>} [options.logFn]
882
+ * @returns {Promise<Array<{command: string, executed: boolean, ok: boolean|null, estimatedBytes?: number|null}>>}
882
883
  */
883
- export function runSystemCleanup(options = {}) {
884
- const { apt = false, journal = false, docker = false, npm = false, journalVacuumTime = '2weeks', dryRun = false, useSudo = false, logFn = () => {} } = options;
884
+ export async function runSystemCleanup(options = {}) {
885
+ const { apt = false, journal = false, docker = false, npm = false, journalVacuumTime = '2weeks', dryRun = false, useSudo = false, logFn = () => {}, execFn = tryExec } = options;
886
+ const plan = buildSystemCleanupPlan({ apt, journal, docker, npm, journalVacuumTime, useSudo });
887
+ const results = [];
885
888
 
886
- const plan = [];
887
- const sudo = useSudo ? ['sudo'] : [];
888
- if (apt) {
889
- plan.push([...sudo, 'apt-get', 'clean']);
890
- plan.push([...sudo, 'apt-get', 'autoclean', '-y']);
891
- plan.push([...sudo, 'apt-get', 'autoremove', '-y']);
892
- }
893
- if (journal) {
894
- plan.push([...sudo, 'journalctl', `--vacuum-time=${journalVacuumTime}`]);
895
- }
896
- if (docker) {
897
- plan.push(['docker', 'system', 'prune', '-f']);
898
- }
899
- if (npm) {
900
- plan.push(['npm', 'cache', 'clean', '--force']);
889
+ if (dryRun) {
890
+ const estimates = estimateSystemCleanupPlan(plan, {
891
+ execFn,
892
+ journalFiles: options.journalFiles,
893
+ now: options.now || new Date(),
894
+ });
895
+ for (const estimate of estimates) {
896
+ await logFn(formatSystemCleanupEstimateLine(estimate));
897
+ results.push({
898
+ command: estimate.command,
899
+ executed: false,
900
+ ok: null,
901
+ estimatedBytes: estimate.estimatedBytes,
902
+ detail: estimate.detail,
903
+ });
904
+ }
905
+ await logFn(formatSystemCleanupTotalLine(estimates));
906
+ return results;
901
907
  }
902
908
 
903
- const results = [];
904
- for (const argv of plan) {
905
- const display = argv.join(' ');
906
- if (dryRun) {
907
- logFn(` [dry-run] would run: ${display}`);
908
- results.push({ command: display, executed: false, ok: null });
909
- continue;
910
- }
911
- logFn(` running: ${display}`);
912
- const out = tryExec(argv[0], argv.slice(1), { timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
909
+ for (const item of plan) {
910
+ const display = item.argv.join(' ');
911
+ await logFn(` running: ${display}`);
912
+ const out = execFn(item.argv[0], item.argv.slice(1), { timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
913
913
  const ok = out !== null;
914
- logFn(ok ? ` āœ“ ${display}` : ` āœ— ${display} (failed or unavailable)`);
914
+ await logFn(ok ? ` āœ“ ${display}` : ` āœ— ${display} (failed or unavailable)`);
915
915
  results.push({ command: display, executed: true, ok });
916
916
  }
917
917
  return results;
@@ -0,0 +1,256 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { formatBytes } from './cleanup.lib.mjs';
5
+
6
+ const APT_ARCHIVES_PATH = '/var/cache/apt/archives';
7
+ const JOURNAL_ROOTS = ['/var/log/journal', '/run/log/journal'];
8
+
9
+ const UNIT_MULTIPLIERS = new Map([
10
+ ['', 1],
11
+ ['B', 1],
12
+ ['BYTE', 1],
13
+ ['BYTES', 1],
14
+ ['K', 1024],
15
+ ['KB', 1024],
16
+ ['KIB', 1024],
17
+ ['M', 1024 ** 2],
18
+ ['MB', 1024 ** 2],
19
+ ['MIB', 1024 ** 2],
20
+ ['G', 1024 ** 3],
21
+ ['GB', 1024 ** 3],
22
+ ['GIB', 1024 ** 3],
23
+ ['T', 1024 ** 4],
24
+ ['TB', 1024 ** 4],
25
+ ['TIB', 1024 ** 4],
26
+ ['P', 1024 ** 5],
27
+ ['PB', 1024 ** 5],
28
+ ['PIB', 1024 ** 5],
29
+ ]);
30
+
31
+ export function parseHumanBytes(value) {
32
+ const match = String(value ?? '')
33
+ .trim()
34
+ .match(/^([0-9][0-9,]*(?:\.[0-9]+)?)\s*([KMGTPE]?i?B?|bytes?)?/i);
35
+ if (!match) return null;
36
+ const number = Number(match[1].replace(/,/g, ''));
37
+ if (!Number.isFinite(number)) return null;
38
+ const unit = String(match[2] || 'B')
39
+ .toUpperCase()
40
+ .replace(/IB$/, 'IB');
41
+ const multiplier = UNIT_MULTIPLIERS.get(unit);
42
+ if (!multiplier) return null;
43
+ return Math.round(number * multiplier);
44
+ }
45
+
46
+ export function parseDuBytes(output, blockSize = 1) {
47
+ const token = String(output ?? '')
48
+ .trim()
49
+ .split(/\s+/)[0];
50
+ const value = Number(token?.replace(/,/g, ''));
51
+ return Number.isFinite(value) ? Math.round(value * blockSize) : null;
52
+ }
53
+
54
+ export function parseAptAutoremoveFreedBytes(output) {
55
+ const text = String(output || '');
56
+ const freed = text.match(/After this operation,\s+([0-9][0-9,]*(?:\.[0-9]+)?\s*[KMGTPE]?i?B?)\s+disk space will be freed/i);
57
+ if (freed) return parseHumanBytes(freed[1]);
58
+ if (/\b0\s+to\s+remove\b/i.test(text)) return 0;
59
+ return null;
60
+ }
61
+
62
+ export function parseJournalDiskUsageBytes(output) {
63
+ const match = String(output || '').match(/\btake up\s+([0-9][0-9,]*(?:\.[0-9]+)?\s*[KMGTPE]?i?B?)\b/i);
64
+ return match ? parseHumanBytes(match[1]) : null;
65
+ }
66
+
67
+ export function parseDockerSystemDf(output) {
68
+ const items = [];
69
+ for (const line of String(output ?? '').split('\n')) {
70
+ const trimmed = line.trim();
71
+ if (!trimmed || /^TYPE\s+/i.test(trimmed)) continue;
72
+ const parts = trimmed.split(/\s{2,}/).filter(Boolean);
73
+ if (parts.length < 5) continue;
74
+ const reclaimable = parts[4];
75
+ const reclaimableBytes = parseHumanBytes(reclaimable);
76
+ if (reclaimableBytes == null) continue;
77
+ items.push({
78
+ type: parts[0],
79
+ total: parts[1],
80
+ active: parts[2],
81
+ size: parts[3],
82
+ reclaimable,
83
+ reclaimableBytes,
84
+ });
85
+ }
86
+ return {
87
+ items,
88
+ totalReclaimableBytes: items.reduce((sum, item) => sum + item.reclaimableBytes, 0),
89
+ };
90
+ }
91
+
92
+ export function buildSystemCleanupPlan(options = {}) {
93
+ const { apt = false, journal = false, docker = false, npm = false, journalVacuumTime = '2weeks', useSudo = false } = options;
94
+ const sudo = useSudo ? ['sudo'] : [];
95
+ const plan = [];
96
+ if (apt) {
97
+ plan.push({ action: 'apt-clean', category: 'apt', argv: [...sudo, 'apt-get', 'clean'] });
98
+ plan.push({ action: 'apt-autoclean', category: 'apt', argv: [...sudo, 'apt-get', 'autoclean', '-y'] });
99
+ plan.push({ action: 'apt-autoremove', category: 'apt', argv: [...sudo, 'apt-get', 'autoremove', '-y'] });
100
+ }
101
+ if (journal) {
102
+ plan.push({
103
+ action: 'journal-vacuum',
104
+ category: 'journal',
105
+ argv: [...sudo, 'journalctl', `--vacuum-time=${journalVacuumTime}`],
106
+ journalVacuumTime,
107
+ });
108
+ }
109
+ if (docker) plan.push({ action: 'docker-prune', category: 'docker', argv: ['docker', 'system', 'prune', '-f'] });
110
+ if (npm) plan.push({ action: 'npm-cache-clean', category: 'npm', argv: ['npm', 'cache', 'clean', '--force'] });
111
+ return plan;
112
+ }
113
+
114
+ function commandDisplay(argv) {
115
+ return argv.join(' ');
116
+ }
117
+
118
+ function measurePathBytes(targetPath, execFn) {
119
+ const exact = parseDuBytes(execFn('du', ['-sb', targetPath]));
120
+ if (exact != null) return exact;
121
+ const kib = parseDuBytes(execFn('du', ['-sk', targetPath]), 1024);
122
+ if (kib != null) return kib;
123
+ try {
124
+ return fs.statSync(targetPath).size;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function parseDurationMs(spec) {
131
+ let total = 0;
132
+ const pattern = /([0-9]+(?:\.[0-9]+)?)\s*(microseconds?|usec|milliseconds?|msec|ms|seconds?|secs?|sec|s|minutes?|mins?|min|m|hours?|hrs?|hr|h|days?|d|weeks?|w|months?|years?|yrs?|yr|y)/gi;
133
+ let match;
134
+ while ((match = pattern.exec(String(spec || ''))) !== null) {
135
+ const value = Number(match[1]);
136
+ if (!Number.isFinite(value)) continue;
137
+ const unit = match[2].toLowerCase();
138
+ if (unit.startsWith('micro') || unit === 'usec') total += value / 1000;
139
+ else if (unit.startsWith('milli') || unit === 'msec' || unit === 'ms') total += value;
140
+ else if (unit === 'm' || unit.startsWith('min')) total += value * 60 * 1000;
141
+ else if (unit === 'h' || unit.startsWith('h')) total += value * 60 * 60 * 1000;
142
+ else if (unit === 'd' || unit.startsWith('day')) total += value * 24 * 60 * 60 * 1000;
143
+ else if (unit === 'w' || unit.startsWith('week')) total += value * 7 * 24 * 60 * 60 * 1000;
144
+ else if (unit.startsWith('month')) total += value * 30 * 24 * 60 * 60 * 1000;
145
+ else if (unit === 'y' || unit.startsWith('yr') || unit.startsWith('year')) total += value * 365 * 24 * 60 * 60 * 1000;
146
+ else total += value * 1000;
147
+ }
148
+ return total > 0 ? total : null;
149
+ }
150
+
151
+ function listJournalFiles(roots = JOURNAL_ROOTS) {
152
+ const files = [];
153
+ const stack = [...roots];
154
+ while (stack.length > 0) {
155
+ const current = stack.pop();
156
+ let stat;
157
+ try {
158
+ stat = fs.statSync(current);
159
+ } catch {
160
+ continue;
161
+ }
162
+ if (stat.isDirectory()) {
163
+ let entries;
164
+ try {
165
+ entries = fs.readdirSync(current, { withFileTypes: true });
166
+ } catch {
167
+ continue;
168
+ }
169
+ for (const entry of entries) stack.push(path.join(current, entry.name));
170
+ } else if (stat.isFile() && /\.journal~?$/.test(current)) {
171
+ files.push({ path: current, size: stat.size, mtimeMs: stat.mtimeMs });
172
+ }
173
+ }
174
+ return files;
175
+ }
176
+
177
+ function estimateJournalBytes({ execFn, journalVacuumTime, journalFiles, now }) {
178
+ const currentBytes = parseJournalDiskUsageBytes(execFn('journalctl', ['--disk-usage']));
179
+ const durationMs = parseDurationMs(journalVacuumTime);
180
+ const files = journalFiles ?? listJournalFiles();
181
+ if (!durationMs) {
182
+ return {
183
+ estimatedBytes: null,
184
+ detail: currentBytes == null ? `unable to parse --vacuum-time=${journalVacuumTime}` : `current journal usage ${formatBytes(currentBytes)}`,
185
+ };
186
+ }
187
+ if (files.length === 0 && currentBytes > 0 && journalFiles == null) {
188
+ return {
189
+ estimatedBytes: null,
190
+ detail: `current journal usage ${formatBytes(currentBytes)}; journal files not readable`,
191
+ };
192
+ }
193
+ const cutoff = new Date(now).getTime() - durationMs;
194
+ const estimatedBytes = files.filter(file => Number(file.mtimeMs) < cutoff).reduce((sum, file) => sum + (Number(file.size) || 0), 0);
195
+ const detail = currentBytes == null ? `journal files older than ${journalVacuumTime}` : `journal files older than ${journalVacuumTime}; current usage ${formatBytes(currentBytes)}`;
196
+ return { estimatedBytes, detail };
197
+ }
198
+
199
+ function estimateDockerBytes(execFn) {
200
+ const parsed = parseDockerSystemDf(execFn('docker', ['system', 'df']));
201
+ if (parsed.items.length === 0) return { estimatedBytes: null, detail: 'docker system df unavailable' };
202
+ const nonZero = parsed.items.filter(item => item.reclaimableBytes > 0);
203
+ const detail = nonZero.length === 0 ? 'docker system df reclaimable: 0B' : `docker system df reclaimable: ${nonZero.map(item => `${item.type} ${formatBytes(item.reclaimableBytes)}`).join(', ')}`;
204
+ return { estimatedBytes: parsed.totalReclaimableBytes, detail };
205
+ }
206
+
207
+ export function estimateSystemCleanupCommand(item, options = {}) {
208
+ const execFn = options.execFn || (() => null);
209
+ const command = commandDisplay(item.argv);
210
+
211
+ if (item.action === 'apt-clean') {
212
+ return { ...item, command, estimatedBytes: measurePathBytes(APT_ARCHIVES_PATH, execFn), detail: APT_ARCHIVES_PATH };
213
+ }
214
+ if (item.action === 'apt-autoclean') {
215
+ return { ...item, command, estimatedBytes: 0, detail: 'already covered by apt-get clean' };
216
+ }
217
+ if (item.action === 'apt-autoremove') {
218
+ const estimatedBytes = parseAptAutoremoveFreedBytes(execFn('apt-get', ['-s', 'autoremove']));
219
+ return { ...item, command, estimatedBytes, detail: estimatedBytes == null ? 'apt-get -s autoremove unavailable' : 'apt-get -s autoremove' };
220
+ }
221
+ if (item.action === 'journal-vacuum') {
222
+ return { ...item, command, ...estimateJournalBytes({ ...options, journalVacuumTime: item.journalVacuumTime }) };
223
+ }
224
+ if (item.action === 'docker-prune') {
225
+ return { ...item, command, ...estimateDockerBytes(execFn) };
226
+ }
227
+ if (item.action === 'npm-cache-clean') {
228
+ const cachePath = execFn('npm', ['config', 'get', 'cache']);
229
+ const trimmedPath = cachePath ? cachePath.trim() : '';
230
+ const estimatedBytes = trimmedPath ? measurePathBytes(trimmedPath, execFn) : null;
231
+ return {
232
+ ...item,
233
+ command,
234
+ estimatedBytes,
235
+ detail: trimmedPath || 'npm cache path unavailable',
236
+ };
237
+ }
238
+ return { ...item, command, estimatedBytes: null, detail: 'estimate unavailable' };
239
+ }
240
+
241
+ export function estimateSystemCleanupPlan(plan, options = {}) {
242
+ return plan.map(item => estimateSystemCleanupCommand(item, options));
243
+ }
244
+
245
+ export function formatSystemCleanupEstimateLine(item) {
246
+ const estimate = item.estimatedBytes == null ? '?' : `~${formatBytes(item.estimatedBytes)}`;
247
+ const detail = item.detail ? ` (${item.detail})` : '';
248
+ return ` ${item.command.padEnd(34)} ${estimate.padStart(8)}${detail}`;
249
+ }
250
+
251
+ export function formatSystemCleanupTotalLine(items) {
252
+ const knownItems = items.filter(item => item.estimatedBytes != null);
253
+ const total = knownItems.reduce((sum, item) => sum + item.estimatedBytes, 0);
254
+ const unknown = knownItems.length < items.length ? ' (known estimates only)' : '';
255
+ return ` estimated system reclaim: ~${formatBytes(total)}${unknown}`;
256
+ }