@link-assistant/hive-mind 1.73.8 → 1.74.0
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 +58 -0
- package/README.hi.md +36 -0
- package/README.md +37 -0
- package/README.ru.md +37 -0
- package/README.zh.md +35 -0
- package/package.json +2 -1
- package/src/cleanup.lib.mjs +359 -0
- package/src/cleanup.mjs +288 -0
- package/src/cleanup.os.lib.mjs +404 -0
- package/src/session-monitor.lib.mjs +72 -0
- package/src/telegram-solve-queue.helpers.lib.mjs +108 -0
- package/src/telegram-solve-queue.lib.mjs +14 -9
package/src/cleanup.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `cleanup` — free disk space by removing stale hive-mind temporary
|
|
4
|
+
* directories/files while preserving folders that belong to currently-running
|
|
5
|
+
* (active) tasks, protected system paths and any work that is not yet pushed.
|
|
6
|
+
*
|
|
7
|
+
* This is the standalone command requested in issue #1848. It reproduces, in a
|
|
8
|
+
* safe and automated way, the manual workflow the maintainer used to reclaim
|
|
9
|
+
* space without restarting the server:
|
|
10
|
+
* - list temp entries (like `du -sh /tmp/*`),
|
|
11
|
+
* - figure out which clones belong to active solve tasks (by branch name, the
|
|
12
|
+
* same way solve.mjs derives branches), keeping those,
|
|
13
|
+
* - keep protected paths such as `/tmp/start-command/`,
|
|
14
|
+
* - delete the rest.
|
|
15
|
+
*
|
|
16
|
+
* Modes:
|
|
17
|
+
* --dry-run show kept + deleted lists, delete nothing
|
|
18
|
+
* --keep-active-tasks-folders keep folders of running tasks (default: on)
|
|
19
|
+
* --force / -f skip the confirmation prompt
|
|
20
|
+
* --all also consider non-hive-mind temp entries
|
|
21
|
+
* --force-start-command allow deleting /tmp/start-command
|
|
22
|
+
* --include-system also consider system-owned temp entries
|
|
23
|
+
* --no-keep-dirty allow deleting clones with unpushed changes
|
|
24
|
+
* --apt --journal --docker --npm Ubuntu/system cleanup (opt-in)
|
|
25
|
+
* --system shorthand for --apt --journal --npm
|
|
26
|
+
* --sudo prefix package-manager commands with sudo
|
|
27
|
+
* --verbose / -v
|
|
28
|
+
*
|
|
29
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1848
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { promises as fsp } from 'node:fs';
|
|
34
|
+
import { execSync } from 'node:child_process';
|
|
35
|
+
|
|
36
|
+
import { classifyEntries, summarize, formatBytes, describeReason, buildActiveMatchers, DEFAULT_PROTECTED_NAMES } from './cleanup.lib.mjs';
|
|
37
|
+
import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, removePath, runSystemCleanup } from './cleanup.os.lib.mjs';
|
|
38
|
+
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
|
|
41
|
+
function hasFlag(...names) {
|
|
42
|
+
return names.some(n => args.includes(n));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Early --version / --help handling (no heavy imports).
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
if (hasFlag('--version')) {
|
|
49
|
+
const { getVersion } = await import('./version.lib.mjs');
|
|
50
|
+
try {
|
|
51
|
+
console.log(await getVersion());
|
|
52
|
+
} catch {
|
|
53
|
+
console.error('Error: Unable to determine version');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (hasFlag('--help', '-h')) {
|
|
60
|
+
console.log(`Usage: cleanup [options]
|
|
61
|
+
|
|
62
|
+
Free disk space by removing stale hive-mind temporary directories/files while
|
|
63
|
+
keeping folders that belong to active tasks and protected system paths.
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
--dry-run, -n Show what would be kept and deleted, delete nothing
|
|
67
|
+
--keep-active-tasks-folders Keep folders of currently-running tasks [default: on]
|
|
68
|
+
--no-keep-active-tasks-folders
|
|
69
|
+
Disable active-task detection (only protected paths kept)
|
|
70
|
+
--force, -f Delete without the interactive confirmation prompt
|
|
71
|
+
--all Also consider non-hive-mind temp entries for deletion
|
|
72
|
+
--include-system Also consider system-owned temp entries (.X11-unix, …)
|
|
73
|
+
--force-start-command Allow deleting /tmp/start-command (kept by default)
|
|
74
|
+
--no-keep-dirty Allow deleting clones with uncommitted/unpushed changes
|
|
75
|
+
--no-sessions Do not query '$ --status' for active sessions
|
|
76
|
+
--no-resolve-branches Do not resolve PR head branches via gh
|
|
77
|
+
|
|
78
|
+
System / Ubuntu cleanup (opt-in):
|
|
79
|
+
--apt apt-get clean / autoclean / autoremove
|
|
80
|
+
--journal journalctl --vacuum-time=2weeks
|
|
81
|
+
--docker docker system prune -f
|
|
82
|
+
--npm npm cache clean --force
|
|
83
|
+
--system Shorthand for --apt --journal --npm
|
|
84
|
+
--sudo Prefix package-manager commands with sudo
|
|
85
|
+
|
|
86
|
+
--verbose, -v Verbose logging
|
|
87
|
+
--version Show version number
|
|
88
|
+
--help, -h Show this help
|
|
89
|
+
`);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Options.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
const options = {
|
|
97
|
+
dryRun: hasFlag('--dry-run', '-n'),
|
|
98
|
+
keepActiveTasks: !hasFlag('--no-keep-active-tasks-folders'),
|
|
99
|
+
force: hasFlag('--force', '-f'),
|
|
100
|
+
includeAll: hasFlag('--all'),
|
|
101
|
+
includeSystem: hasFlag('--include-system'),
|
|
102
|
+
forceStartCommand: hasFlag('--force-start-command'),
|
|
103
|
+
keepDirty: !hasFlag('--no-keep-dirty'),
|
|
104
|
+
useSessions: !hasFlag('--no-sessions'),
|
|
105
|
+
resolveBranches: !hasFlag('--no-resolve-branches'),
|
|
106
|
+
verbose: hasFlag('--verbose', '-v'),
|
|
107
|
+
apt: hasFlag('--apt', '--system'),
|
|
108
|
+
journal: hasFlag('--journal', '--system'),
|
|
109
|
+
docker: hasFlag('--docker'),
|
|
110
|
+
npm: hasFlag('--npm', '--system'),
|
|
111
|
+
sudo: hasFlag('--sudo'),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
115
|
+
const scriptDir = path.dirname(process.argv[1]);
|
|
116
|
+
const logFile = path.join(scriptDir, `cleanup-${timestamp}.log`);
|
|
117
|
+
|
|
118
|
+
async function log(message, { level = 'info' } = {}) {
|
|
119
|
+
await fsp.appendFile(logFile, `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}\n`).catch(() => {});
|
|
120
|
+
if (level === 'error') console.error(message);
|
|
121
|
+
else if (level === 'warn' || level === 'warning') console.warn(message);
|
|
122
|
+
else console.log(message);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function vlog(message) {
|
|
126
|
+
if (options.verbose) return log(message);
|
|
127
|
+
return fsp.appendFile(logFile, `[${new Date().toISOString()}] [DEBUG] ${message}\n`).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compute the set of absolute top-level tmp entries that the cleanup process
|
|
132
|
+
* itself depends on, so we never delete our own running clone.
|
|
133
|
+
*/
|
|
134
|
+
function computeSelfPaths(tempRoot) {
|
|
135
|
+
const selfPaths = new Set();
|
|
136
|
+
const normalizedRoot = tempRoot.endsWith(path.sep) ? tempRoot : tempRoot + path.sep;
|
|
137
|
+
const add = candidate => {
|
|
138
|
+
if (candidate && (candidate === tempRoot || candidate.startsWith(normalizedRoot))) {
|
|
139
|
+
const first = candidate.slice(normalizedRoot.length).split(path.sep)[0];
|
|
140
|
+
if (first) selfPaths.add(path.join(tempRoot, first));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
add(process.cwd());
|
|
144
|
+
add(path.resolve(scriptDir));
|
|
145
|
+
add(path.resolve(process.argv[1] || ''));
|
|
146
|
+
return selfPaths;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function main() {
|
|
150
|
+
await fsp.writeFile(logFile, `# Cleanup Log - ${new Date().toISOString()}\n\n`).catch(() => {});
|
|
151
|
+
|
|
152
|
+
const tempRoot = getTempRoot();
|
|
153
|
+
await log('🧹 hive-mind cleanup');
|
|
154
|
+
await log('====================\n');
|
|
155
|
+
await log(`📂 Temp root: ${tempRoot}`);
|
|
156
|
+
if (options.dryRun) await log('📝 DRY RUN — nothing will be deleted\n');
|
|
157
|
+
else if (options.force) await log('⚠️ FORCE — deleting without confirmation\n');
|
|
158
|
+
|
|
159
|
+
// 1. Enumerate candidate entries.
|
|
160
|
+
const entries = listTempEntries(tempRoot);
|
|
161
|
+
await log(`🔍 Found ${entries.length} entries under ${tempRoot}`);
|
|
162
|
+
|
|
163
|
+
// 2. Gather signals for active-task detection.
|
|
164
|
+
const heldPaths = listProcessHeldPaths(tempRoot);
|
|
165
|
+
await vlog(`Process-held paths: ${[...heldPaths].join(', ') || '(none)'}`);
|
|
166
|
+
|
|
167
|
+
let matchers = [];
|
|
168
|
+
if (options.keepActiveTasks) {
|
|
169
|
+
const activeTasks = await getActiveTasks({ useSessions: options.useSessions, resolveBranches: options.resolveBranches });
|
|
170
|
+
matchers = buildActiveMatchers(activeTasks);
|
|
171
|
+
if (activeTasks.length > 0) {
|
|
172
|
+
await log(`🏃 Active tasks detected: ${activeTasks.length}`);
|
|
173
|
+
for (const t of activeTasks) {
|
|
174
|
+
await log(` • ${t.owner}/${t.repo} ${t.type} #${t.number}${t.branch ? ` (branch ${t.branch})` : ''}`);
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
await log('🏃 No active tasks detected from running processes/sessions');
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
await log('⚠️ Active-task detection disabled (--no-keep-active-tasks-folders)');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3. Read git info for directory entries (used by branch / dirty matching).
|
|
184
|
+
const gitInfoByPath = new Map();
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (!entry.isDirectory) continue;
|
|
187
|
+
const info = readFolderGitInfo(entry.path);
|
|
188
|
+
if (info) gitInfoByPath.set(entry.path, info);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const selfPaths = computeSelfPaths(tempRoot);
|
|
192
|
+
await vlog(`Self paths: ${[...selfPaths].join(', ') || '(none)'}`);
|
|
193
|
+
|
|
194
|
+
// 4. Classify.
|
|
195
|
+
const ctx = {
|
|
196
|
+
protectedNames: DEFAULT_PROTECTED_NAMES,
|
|
197
|
+
forceStartCommand: options.forceStartCommand,
|
|
198
|
+
includeSystem: options.includeSystem,
|
|
199
|
+
includeAll: options.includeAll,
|
|
200
|
+
keepDirty: options.keepDirty,
|
|
201
|
+
selfPaths,
|
|
202
|
+
heldPaths,
|
|
203
|
+
matchers,
|
|
204
|
+
gitInfoByPath,
|
|
205
|
+
};
|
|
206
|
+
const classified = classifyEntries(entries, ctx);
|
|
207
|
+
|
|
208
|
+
// 5. Compute sizes (only for what we report, to keep it reasonably fast).
|
|
209
|
+
for (const item of [...classified.keep, ...classified.remove]) {
|
|
210
|
+
item.size = getPathSize(item.path);
|
|
211
|
+
}
|
|
212
|
+
const totals = summarize(classified);
|
|
213
|
+
|
|
214
|
+
// 6. Report.
|
|
215
|
+
await log('\n🟢 KEPT folders/files:');
|
|
216
|
+
if (classified.keep.length === 0) await log(' (none)');
|
|
217
|
+
for (const item of classified.keep.sort((a, b) => (b.size || 0) - (a.size || 0))) {
|
|
218
|
+
await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeReason(item.reason)}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await log(`\n🗑️ ${options.dryRun ? 'WOULD DELETE' : 'TO DELETE'} folders/files:`);
|
|
222
|
+
if (classified.remove.length === 0) await log(' (none)');
|
|
223
|
+
for (const item of classified.remove.sort((a, b) => (b.size || 0) - (a.size || 0))) {
|
|
224
|
+
await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeReason(item.reason)}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await log(`\n📊 Summary: keep ${totals.keepCount} (${formatBytes(totals.keepBytes)}), remove ${totals.removeCount} (${formatBytes(totals.removeBytes)})`);
|
|
228
|
+
|
|
229
|
+
// 7. Execute deletion (unless dry-run).
|
|
230
|
+
if (options.dryRun) {
|
|
231
|
+
await log('\n✅ Dry run complete. Re-run without --dry-run to delete.');
|
|
232
|
+
} else if (classified.remove.length === 0) {
|
|
233
|
+
await log('\n✅ Nothing to delete.');
|
|
234
|
+
} else {
|
|
235
|
+
if (!options.force) {
|
|
236
|
+
console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}).`);
|
|
237
|
+
console.log('Type "yes" to confirm, or Ctrl+C to cancel:');
|
|
238
|
+
process.stdout.write('> ');
|
|
239
|
+
let answer = '';
|
|
240
|
+
try {
|
|
241
|
+
answer = execSync('read answer && echo $answer', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], shell: '/bin/bash' }).trim();
|
|
242
|
+
} catch {
|
|
243
|
+
await log('\n❌ Cancelled');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (answer.toLowerCase() !== 'yes') {
|
|
247
|
+
await log('\n❌ Cancelled');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await log('\n🗑️ Deleting...');
|
|
253
|
+
let deleted = 0;
|
|
254
|
+
let failed = 0;
|
|
255
|
+
for (const item of classified.remove) {
|
|
256
|
+
const ok = removePath(item.path);
|
|
257
|
+
if (ok) {
|
|
258
|
+
deleted++;
|
|
259
|
+
await vlog(` removed ${item.path}`);
|
|
260
|
+
} else {
|
|
261
|
+
failed++;
|
|
262
|
+
await log(` ⚠️ failed to remove ${item.path}`, { level: 'warn' });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
await log(`\n✅ Deleted ${deleted} entries${failed ? `, ${failed} failed` : ''}.`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 8. System / Ubuntu cleanup (opt-in).
|
|
269
|
+
if (options.apt || options.journal || options.docker || options.npm) {
|
|
270
|
+
await log('\n🧴 System cleanup:');
|
|
271
|
+
runSystemCleanup({
|
|
272
|
+
apt: options.apt,
|
|
273
|
+
journal: options.journal,
|
|
274
|
+
docker: options.docker,
|
|
275
|
+
npm: options.npm,
|
|
276
|
+
dryRun: options.dryRun,
|
|
277
|
+
useSudo: options.sudo,
|
|
278
|
+
logFn: msg => log(msg),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await log(`\n📁 Log file: ${logFile}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
main().catch(async error => {
|
|
286
|
+
await log(`❌ Error: ${error.message}`, { level: 'error' });
|
|
287
|
+
process.exit(1);
|
|
288
|
+
});
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-interaction layer for the `cleanup` command (issue #1848).
|
|
3
|
+
*
|
|
4
|
+
* Everything that touches the real filesystem, the process table (/proc),
|
|
5
|
+
* isolation session state (`$ --status` from start-command), git metadata of a
|
|
6
|
+
* clone, GitHub (`gh`) and system package caches lives here. The pure
|
|
7
|
+
* classification logic lives in `cleanup.lib.mjs` and is unit-tested without any
|
|
8
|
+
* of this.
|
|
9
|
+
*
|
|
10
|
+
* Implemented with `node:` built-ins + `node:child_process` so it does not
|
|
11
|
+
* depend on `use-m` / `command-stream` being reachable, except for the optional
|
|
12
|
+
* `$ --status` session query which reuses isolation-runner.lib.mjs.
|
|
13
|
+
*
|
|
14
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1848
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
import { extractTaskRefsFromCommand, parseRemoteUrl } from './cleanup.lib.mjs';
|
|
23
|
+
|
|
24
|
+
/** Run a command, returning trimmed stdout or null on any failure. */
|
|
25
|
+
function tryExec(cmd, args, options = {}) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync(cmd, args, {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
timeout: options.timeout ?? 20000,
|
|
31
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
32
|
+
...options,
|
|
33
|
+
}).trim();
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The tmp root cleanup operates on (honours TMPDIR via os.tmpdir()). */
|
|
40
|
+
export function getTempRoot() {
|
|
41
|
+
return os.tmpdir();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* List immediate children of the tmp root as candidate entries.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} tempRoot
|
|
48
|
+
* @returns {Array<{name: string, path: string, isDirectory: boolean}>}
|
|
49
|
+
*/
|
|
50
|
+
export function listTempEntries(tempRoot) {
|
|
51
|
+
let dirents;
|
|
52
|
+
try {
|
|
53
|
+
dirents = fs.readdirSync(tempRoot, { withFileTypes: true });
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return dirents.map(d => {
|
|
58
|
+
const full = path.join(tempRoot, d.name);
|
|
59
|
+
let isDirectory = d.isDirectory();
|
|
60
|
+
// Resolve symlinks defensively (don't follow into them for deletion though).
|
|
61
|
+
if (d.isSymbolicLink()) {
|
|
62
|
+
try {
|
|
63
|
+
isDirectory = fs.statSync(full).isDirectory();
|
|
64
|
+
} catch {
|
|
65
|
+
isDirectory = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { name: d.name, path: full, isDirectory };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Size of a path in bytes. Uses `du -sk` (fast, handles dirs) with a small
|
|
74
|
+
* fs.statSync fallback for plain files.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} targetPath
|
|
77
|
+
* @returns {number|null}
|
|
78
|
+
*/
|
|
79
|
+
export function getPathSize(targetPath) {
|
|
80
|
+
const out = tryExec('du', ['-sk', targetPath]);
|
|
81
|
+
if (out) {
|
|
82
|
+
const kb = parseInt(out.split(/\s+/)[0], 10);
|
|
83
|
+
if (!Number.isNaN(kb)) return kb * 1024;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
return fs.statSync(targetPath).size;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read the git branch, remotes and dirty state of a clone directory.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} dir
|
|
96
|
+
* @returns {{branch: string|null, remotes: Array<{owner, repo, url}>, dirty: boolean}|null}
|
|
97
|
+
*/
|
|
98
|
+
export function readFolderGitInfo(dir) {
|
|
99
|
+
// Cheap check: is it a git work tree?
|
|
100
|
+
const isRepo = tryExec('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree']);
|
|
101
|
+
if (isRepo !== 'true') return null;
|
|
102
|
+
|
|
103
|
+
const branch = tryExec('git', ['-C', dir, 'branch', '--show-current']) || null;
|
|
104
|
+
|
|
105
|
+
const remotesRaw = tryExec('git', ['-C', dir, 'remote', '-v']) || '';
|
|
106
|
+
const remotes = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const line of remotesRaw.split('\n')) {
|
|
109
|
+
const m = line.match(/^\S+\s+(\S+)\s+\((?:fetch|push)\)/);
|
|
110
|
+
if (!m) continue;
|
|
111
|
+
const parsed = parseRemoteUrl(m[1]);
|
|
112
|
+
if (parsed) {
|
|
113
|
+
const key = `${parsed.owner}/${parsed.repo}`.toLowerCase();
|
|
114
|
+
if (!seen.has(key)) {
|
|
115
|
+
seen.add(key);
|
|
116
|
+
remotes.push({ ...parsed, url: m[1] });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Dirty if there are uncommitted changes OR commits not present on any remote.
|
|
122
|
+
const status = tryExec('git', ['-C', dir, 'status', '--porcelain']);
|
|
123
|
+
let dirty = Boolean(status && status.length > 0);
|
|
124
|
+
if (!dirty && branch) {
|
|
125
|
+
// Unpushed local commits: branch exists but has no upstream, or is ahead.
|
|
126
|
+
const upstream = tryExec('git', ['-C', dir, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
|
|
127
|
+
if (!upstream) {
|
|
128
|
+
// No upstream tracking: check whether the branch commit exists on a remote.
|
|
129
|
+
const head = tryExec('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
130
|
+
const onRemote = head ? tryExec('git', ['-C', dir, 'branch', '-r', '--contains', head]) : null;
|
|
131
|
+
dirty = !onRemote;
|
|
132
|
+
} else {
|
|
133
|
+
const counts = tryExec('git', ['-C', dir, 'rev-list', '--left-right', '--count', `${upstream}...HEAD`]);
|
|
134
|
+
if (counts) {
|
|
135
|
+
const ahead = parseInt(counts.split(/\s+/)[1] || '0', 10);
|
|
136
|
+
dirty = ahead > 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { branch, remotes, dirty };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Scan /proc to find paths under tempRoot that are the cwd of, or an open fd /
|
|
146
|
+
* mapped file of, a running process. Linux-only; returns an empty set elsewhere.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} tempRoot
|
|
149
|
+
* @returns {Set<string>} absolute top-level entry paths under tempRoot
|
|
150
|
+
*/
|
|
151
|
+
export function listProcessHeldPaths(tempRoot) {
|
|
152
|
+
const held = new Set();
|
|
153
|
+
let pids;
|
|
154
|
+
try {
|
|
155
|
+
pids = fs.readdirSync('/proc').filter(name => /^\d+$/.test(name));
|
|
156
|
+
} catch {
|
|
157
|
+
return held; // Not Linux / no procfs.
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const normalizedRoot = tempRoot.endsWith(path.sep) ? tempRoot : tempRoot + path.sep;
|
|
161
|
+
const recordIfUnderRoot = target => {
|
|
162
|
+
if (!target) return;
|
|
163
|
+
if (target === tempRoot || target.startsWith(normalizedRoot)) {
|
|
164
|
+
// Reduce to the top-level entry directly under tempRoot.
|
|
165
|
+
const rest = target.slice(normalizedRoot.length);
|
|
166
|
+
const first = rest.split(path.sep)[0];
|
|
167
|
+
if (first) held.add(path.join(tempRoot, first));
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
for (const pid of pids) {
|
|
172
|
+
// cwd of the process (covers git/claude children that chdir into the clone).
|
|
173
|
+
try {
|
|
174
|
+
recordIfUnderRoot(fs.readlinkSync(`/proc/${pid}/cwd`));
|
|
175
|
+
} catch {
|
|
176
|
+
/* process gone or permission denied */
|
|
177
|
+
}
|
|
178
|
+
// open file descriptors.
|
|
179
|
+
try {
|
|
180
|
+
for (const fd of fs.readdirSync(`/proc/${pid}/fd`)) {
|
|
181
|
+
try {
|
|
182
|
+
recordIfUnderRoot(fs.readlinkSync(`/proc/${pid}/fd/${fd}`));
|
|
183
|
+
} catch {
|
|
184
|
+
/* fd vanished */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
/* no fd dir / permission */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return held;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Collect task references (owner/repo/number/type) from running solve/hive
|
|
196
|
+
* processes by scanning /proc/<pid>/cmdline.
|
|
197
|
+
*
|
|
198
|
+
* @returns {Array<{owner, repo, type, number}>}
|
|
199
|
+
*/
|
|
200
|
+
export function listActiveTaskRefsFromProc() {
|
|
201
|
+
const refs = [];
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
let pids;
|
|
204
|
+
try {
|
|
205
|
+
pids = fs.readdirSync('/proc').filter(name => /^\d+$/.test(name));
|
|
206
|
+
} catch {
|
|
207
|
+
return refs;
|
|
208
|
+
}
|
|
209
|
+
for (const pid of pids) {
|
|
210
|
+
let cmdline;
|
|
211
|
+
try {
|
|
212
|
+
cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!cmdline || !/github\.com/.test(cmdline)) continue;
|
|
217
|
+
for (const ref of extractTaskRefsFromCommand(cmdline)) {
|
|
218
|
+
const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
|
|
219
|
+
if (!seen.has(key)) {
|
|
220
|
+
seen.add(key);
|
|
221
|
+
refs.push(ref);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return refs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Discover currently-running isolation session UUIDs from start-command's live
|
|
230
|
+
* session managers (screen / tmux). These names are the session UUIDs.
|
|
231
|
+
*
|
|
232
|
+
* @returns {string[]}
|
|
233
|
+
*/
|
|
234
|
+
export function listLiveSessionIds() {
|
|
235
|
+
const ids = new Set();
|
|
236
|
+
|
|
237
|
+
const screenOut = tryExec('screen', ['-ls']);
|
|
238
|
+
if (screenOut) {
|
|
239
|
+
for (const m of screenOut.matchAll(/^\s*\d+\.([0-9a-f-]{8,})\s/gim)) {
|
|
240
|
+
ids.add(m[1]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const tmuxOut = tryExec('tmux', ['ls', '-F', '#{session_name}']);
|
|
245
|
+
if (tmuxOut) {
|
|
246
|
+
for (const line of tmuxOut.split('\n')) {
|
|
247
|
+
const name = line.trim();
|
|
248
|
+
if (/^[0-9a-f-]{8,}$/i.test(name)) ids.add(name);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return [...ids];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Query `$ --status <uuid>` for each live session and extract task references
|
|
257
|
+
* from executing sessions' command lines. Optional; reuses isolation-runner.
|
|
258
|
+
*
|
|
259
|
+
* @param {string[]} sessionIds
|
|
260
|
+
* @returns {Promise<Array<{owner, repo, type, number}>>}
|
|
261
|
+
*/
|
|
262
|
+
export async function listActiveTaskRefsFromSessions(sessionIds) {
|
|
263
|
+
if (!sessionIds || sessionIds.length === 0) return [];
|
|
264
|
+
let querySessionStatus;
|
|
265
|
+
let isTerminalSessionStatus;
|
|
266
|
+
try {
|
|
267
|
+
({ querySessionStatus, isTerminalSessionStatus } = await import('./isolation-runner.lib.mjs'));
|
|
268
|
+
} catch {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
const refs = [];
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
for (const id of sessionIds) {
|
|
274
|
+
let status;
|
|
275
|
+
try {
|
|
276
|
+
status = await querySessionStatus(id);
|
|
277
|
+
} catch {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!status || !status.exists) continue;
|
|
281
|
+
if (status.status && isTerminalSessionStatus(status.status)) continue;
|
|
282
|
+
if (!status.command) continue;
|
|
283
|
+
for (const ref of extractTaskRefsFromCommand(status.command)) {
|
|
284
|
+
const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
|
|
285
|
+
if (!seen.has(key)) {
|
|
286
|
+
seen.add(key);
|
|
287
|
+
refs.push(ref);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return refs;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Resolve the head branch of a PR via `gh pr view`. Returns null on failure
|
|
296
|
+
* (offline, no gh, not found) — callers fall back to issue-prefix matching.
|
|
297
|
+
*
|
|
298
|
+
* @param {{owner, repo, number}} ref
|
|
299
|
+
* @returns {string|null}
|
|
300
|
+
*/
|
|
301
|
+
export function resolvePrHeadBranch(ref) {
|
|
302
|
+
const out = tryExec('gh', ['pr', 'view', String(ref.number), '--repo', `${ref.owner}/${ref.repo}`, '--json', 'headRefName', '--jq', '.headRefName']);
|
|
303
|
+
return out || null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Build the full active-task list, resolving PR head branches where possible.
|
|
308
|
+
*
|
|
309
|
+
* @param {Object} [options]
|
|
310
|
+
* @param {boolean} [options.useSessions=true] - also query `$ --status`
|
|
311
|
+
* @param {boolean} [options.resolveBranches=true] - resolve PR head branches via gh
|
|
312
|
+
* @returns {Promise<Array<{owner, repo, type, number, branch: string|null}>>}
|
|
313
|
+
*/
|
|
314
|
+
export async function getActiveTasks(options = {}) {
|
|
315
|
+
const { useSessions = true, resolveBranches = true } = options;
|
|
316
|
+
const refs = [...listActiveTaskRefsFromProc()];
|
|
317
|
+
const seen = new Set(refs.map(r => `${r.owner}/${r.repo}#${r.number}:${r.type}`));
|
|
318
|
+
|
|
319
|
+
if (useSessions) {
|
|
320
|
+
const sessionRefs = await listActiveTaskRefsFromSessions(listLiveSessionIds());
|
|
321
|
+
for (const ref of sessionRefs) {
|
|
322
|
+
const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
|
|
323
|
+
if (!seen.has(key)) {
|
|
324
|
+
seen.add(key);
|
|
325
|
+
refs.push(ref);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return refs.map(ref => {
|
|
331
|
+
let branch = null;
|
|
332
|
+
if (ref.type === 'pull' && resolveBranches) {
|
|
333
|
+
branch = resolvePrHeadBranch(ref);
|
|
334
|
+
}
|
|
335
|
+
return { ...ref, branch };
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Permanently remove a path (recursive, force). Returns true on success.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} targetPath
|
|
343
|
+
* @returns {boolean}
|
|
344
|
+
*/
|
|
345
|
+
export function removePath(targetPath) {
|
|
346
|
+
try {
|
|
347
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
348
|
+
return true;
|
|
349
|
+
} catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* System / Ubuntu cleanup actions. Each is opt-in. In dry-run mode the commands
|
|
356
|
+
* are only described, never executed.
|
|
357
|
+
*
|
|
358
|
+
* @param {Object} options
|
|
359
|
+
* @param {boolean} [options.apt] - apt-get clean / autoclean / autoremove
|
|
360
|
+
* @param {boolean} [options.journal] - journalctl --vacuum-time
|
|
361
|
+
* @param {boolean} [options.docker] - docker system prune
|
|
362
|
+
* @param {boolean} [options.npm] - npm cache clean --force
|
|
363
|
+
* @param {string} [options.journalVacuumTime='2weeks']
|
|
364
|
+
* @param {boolean} [options.dryRun]
|
|
365
|
+
* @param {boolean} [options.useSudo] - prefix package commands with sudo
|
|
366
|
+
* @param {(msg: string) => void} [options.logFn]
|
|
367
|
+
* @returns {Array<{command: string, executed: boolean, ok: boolean|null}>}
|
|
368
|
+
*/
|
|
369
|
+
export function runSystemCleanup(options = {}) {
|
|
370
|
+
const { apt = false, journal = false, docker = false, npm = false, journalVacuumTime = '2weeks', dryRun = false, useSudo = false, logFn = () => {} } = options;
|
|
371
|
+
|
|
372
|
+
const plan = [];
|
|
373
|
+
const sudo = useSudo ? ['sudo'] : [];
|
|
374
|
+
if (apt) {
|
|
375
|
+
plan.push([...sudo, 'apt-get', 'clean']);
|
|
376
|
+
plan.push([...sudo, 'apt-get', 'autoclean', '-y']);
|
|
377
|
+
plan.push([...sudo, 'apt-get', 'autoremove', '-y']);
|
|
378
|
+
}
|
|
379
|
+
if (journal) {
|
|
380
|
+
plan.push([...sudo, 'journalctl', `--vacuum-time=${journalVacuumTime}`]);
|
|
381
|
+
}
|
|
382
|
+
if (docker) {
|
|
383
|
+
plan.push(['docker', 'system', 'prune', '-f']);
|
|
384
|
+
}
|
|
385
|
+
if (npm) {
|
|
386
|
+
plan.push(['npm', 'cache', 'clean', '--force']);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const results = [];
|
|
390
|
+
for (const argv of plan) {
|
|
391
|
+
const display = argv.join(' ');
|
|
392
|
+
if (dryRun) {
|
|
393
|
+
logFn(` [dry-run] would run: ${display}`);
|
|
394
|
+
results.push({ command: display, executed: false, ok: null });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
logFn(` running: ${display}`);
|
|
398
|
+
const out = tryExec(argv[0], argv.slice(1), { timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
399
|
+
const ok = out !== null;
|
|
400
|
+
logFn(ok ? ` ✓ ${display}` : ` ✗ ${display} (failed or unavailable)`);
|
|
401
|
+
results.push({ command: display, executed: true, ok });
|
|
402
|
+
}
|
|
403
|
+
return results;
|
|
404
|
+
}
|