回收 411 MB(470 M → 58.8 M),全部经回收站,可恢复: - 待清理/(146.2 M,含 relay 分片 128 M 与 42 项过程目录) - tmp/(32.4 M,按接续棒命名的过程临时区) - .workbuddy/tmp/(39.5 M) - 4 份 workbuddy.db 冗余副本(101 M,09-23 事故的坏副本 / 抢救产物) - tmp/im16/gw/centrifugo 二进制(63.9 M,可重下)+ 缓存残留 入库范围:常驻规则(CODEBUDDY.md / README.md / state.py)、在途接续入口与 接续包、docs/、交付物/、交接单/、归档/、scripts/、.codebuddy/、 .workbuddy/memory/;共 398 件,其中 >60 KB 的 26 件全为文档。 排除(.gitignore):tmp/、待清理/、运行态日志与缓存、*.db 与 DB 备份整目录、 打包二进制(*.tar.gz / *.tgz)、记忆修复前备份。
94 lines
5.2 KiB
JavaScript
94 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ws-cleanup.cjs —— 用户工作区(ws)定期清理 + 磁盘画像(档案 28)
|
||
*
|
||
* 分三档(**判定依据只有"路径/文件名模式 + 年龄"**,因为无法可靠区分"AI 写的"):
|
||
* T1 明确垃圾/平台产物 → 直接清(白名单精确匹配)
|
||
* T2 临时脚本(一次性、无复用价值)→ 超过 N 天未修改才清(默认 90 天)
|
||
* T3 其余一切(文档/表格/图片/视频/数据/目录)→ **永不自动删**,只统计
|
||
*
|
||
* 安全:默认 dry-run;--apply 时一律 `mv` 到 <userRoot>/trash/<日期>-ws-cleanup/(保留 30 天可恢复);
|
||
* 仅当该用户 ws ≥ --threshold MB 才动手(默认 2048 MB)。
|
||
*
|
||
* 用法:
|
||
* node ws-cleanup.cjs # dry-run + 画像
|
||
* node ws-cleanup.cjs --apply # 执行(含阈值判断)
|
||
* node ws-cleanup.cjs --apply --threshold 1024 --days 60
|
||
* node ws-cleanup.cjs --user-id <uuid>
|
||
*/
|
||
const { execFileSync } = require('node:child_process')
|
||
const { existsSync, mkdirSync, readdirSync, statSync } = require('node:fs')
|
||
const { join, extname, basename } = require('node:path')
|
||
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||
|
||
const argv = process.argv.slice(2)
|
||
const APPLY = argv.includes('--apply')
|
||
const num = (flag, dflt) => { const i = argv.indexOf(flag); const v = i >= 0 ? Number(argv[i + 1]) : NaN; return Number.isFinite(v) ? v : dflt }
|
||
const THRESHOLD_MB = num('--threshold', 2048)
|
||
const DAYS = num('--days', 90)
|
||
const idx = argv.indexOf('--user-id')
|
||
const ONLY = idx >= 0 ? argv[idx + 1] : ''
|
||
const STAMP = new Date().toISOString().slice(0, 10)
|
||
const CUTOFF = Date.now() - DAYS * 86400_000
|
||
|
||
// T1:平台产物/明确垃圾(精确名或后缀);T2:一次性脚本(仅"顶层脚本文件"才算,避免误伤项目目录里的源码)
|
||
const T1_DIRS = ['.local', '.cache', '.poc-backup', 'poc', '__pycache__', '.pytest_cache', 'node_modules', '.ipynb_checkpoints']
|
||
const T1_SUFFIX = ['.tgz', '.tmp', '.log', '.bak', '.part', '.crdownload']
|
||
const T2_EXT = new Set(['.py', '.js', '.mjs', '.cjs', '.sh', '.bash', '.ps1', '.psm1', '.bat', '.cmd', '.ts', '.ipynb', '.sql'])
|
||
|
||
const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } }
|
||
const fmt = (b) => (b >= 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' : b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
|
||
|
||
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
|
||
const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all().filter((u) => ONLY === '' || u.id === ONLY)
|
||
|
||
for (const u of users) {
|
||
const ws = join(u.home_dir, '..', 'ws')
|
||
if (!existsSync(ws)) { console.log(` ${u.username}: NO_WS`); continue }
|
||
const wsBytes = duKB(ws)
|
||
const t1 = [], t2 = [] // T2 可被 ws/.keep 豁免
|
||
let otherBytes = 0, otherCount = 0
|
||
|
||
for (const entry of readdirSync(ws)) {
|
||
const p = join(ws, entry)
|
||
let st
|
||
try { st = statSync(p) } catch { continue }
|
||
if (st.isDirectory()) {
|
||
if (T1_DIRS.includes(entry)) { t1.push({ p, size: duKB(p) }); continue }
|
||
otherBytes += duKB(p); otherCount += 1 // 目录一律算用户资产(T3)
|
||
continue
|
||
}
|
||
const ext = extname(entry).toLowerCase()
|
||
if (T1_SUFFIX.includes(ext)) { t1.push({ p, size: st.size }); continue }
|
||
if (T2_EXT.has(ext) && st.mtimeMs < CUTOFF) { t2.push({ p, size: st.size, mtime: st.mtime }); continue }
|
||
otherBytes += st.size; otherCount += 1
|
||
}
|
||
|
||
// .keep 豁免(档案 28):ws 顶层放一个 .keep 文件 → 该用户**跳过 T2**(一次性脚本不清理),T1 仍清
|
||
const keepAll = existsSync(join(ws, '.keep'))
|
||
if (keepAll && t2.length > 0) {
|
||
console.log(` (.keep 已存在 → T2 保留 ${t2.length} 项,不清理)`)
|
||
t2.length = 0
|
||
}
|
||
const t1B = t1.reduce((a, c) => a + c.size, 0), t2B = t2.reduce((a, c) => a + c.size, 0)
|
||
const over = wsBytes >= THRESHOLD_MB * 1048576
|
||
console.log(` ${u.username}: ws=${fmt(wsBytes)} (${over ? '≥' : '<'} 阈值 ${THRESHOLD_MB}MB) | T1=${t1.length}项/${fmt(t1B)} | T2=${t2.length}项(>${DAYS}天)/${fmt(t2B)} | 资产=${otherCount}项/${fmt(otherBytes)}`)
|
||
for (const c of t1) console.log(` T1 ${basename(c.p)} ${fmt(c.size)}`)
|
||
for (const c of t2) console.log(` T2 ${basename(c.p)} ${fmt(c.size)} (mtime ${c.mtime.toISOString().slice(0, 10)})`)
|
||
|
||
if (APPLY && over && (t1.length + t2.length) > 0) {
|
||
const trash = join(u.home_dir, '..', 'trash', `${STAMP}-ws-cleanup`)
|
||
mkdirSync(trash, { recursive: true })
|
||
for (const c of [...t1, ...t2]) {
|
||
const dest = join(trash, c.p.slice(ws.length + 1).replace(/\//g, '__'))
|
||
try { execFileSync('mv', [c.p, dest]); execFileSync('chown', ['-R', `${u.uid}:${u.uid}`, dest]) }
|
||
catch (e) { console.log(` ! 失败 ${c.p}: ${String(e.message).split('\n')[0]}`) }
|
||
}
|
||
console.log(` → 已清 ${t1.length + t2.length} 项 / ${fmt(t1B + t2B)} → trash/${STAMP}-ws-cleanup(保留 30 天)`)
|
||
} else if (APPLY && !over) {
|
||
console.log(` (未超阈值,跳过清理)`)
|
||
}
|
||
}
|
||
db.close()
|
||
console.log(APPLY ? 'done(已按阈值执行)' : 'done(dry-run,未改动)')
|