4e21ca937a
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Fork goose with custom MCP widgets, platform extensions (aider, git, web, search), MindSpace H5 backend/frontend, Plaza/Ops UIs, and deploy scripts for tkmind.cn. Co-authored-by: Cursor <cursoragent@cursor.com>
202 lines
6.6 KiB
JavaScript
202 lines
6.6 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
const WORKSPACE_TEMP_SKIP = new Set(['.tkmindhints', '.goosehints', '.agents', '.goose']);
|
|
|
|
function candidateId(kind, key) {
|
|
return `${kind}:${crypto.createHash('sha256').update(key).digest('hex').slice(0, 24)}`;
|
|
}
|
|
|
|
async function fileSize(targetPath) {
|
|
try {
|
|
const stat = await fs.stat(targetPath);
|
|
return stat.isFile() ? stat.size : 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async function walkFiles(rootDir, onFile) {
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(rootDir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(rootDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (WORKSPACE_TEMP_SKIP.has(entry.name)) continue;
|
|
await walkFiles(fullPath, onFile);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
await onFile(fullPath);
|
|
}
|
|
}
|
|
|
|
export function createCleanupService(pool, options = {}) {
|
|
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
|
|
const h5Root = path.resolve(options.h5Root ?? process.cwd());
|
|
|
|
const absoluteStoragePath = (storageKey) => {
|
|
const resolved = path.resolve(storageRoot, storageKey);
|
|
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
|
|
throw new Error('存储路径越界');
|
|
}
|
|
return resolved;
|
|
};
|
|
|
|
const listCandidates = async (userId, username) => {
|
|
const candidates = [];
|
|
|
|
const [uploads] = await pool.query(
|
|
`SELECT id, filename, reserved_bytes, temporary_storage_key, status, expires_at, created_at
|
|
FROM h5_upload_sessions
|
|
WHERE user_id = ? AND status IN ('reserved', 'uploaded', 'expired', 'failed')
|
|
ORDER BY created_at DESC
|
|
LIMIT 100`,
|
|
[userId],
|
|
);
|
|
for (const upload of uploads) {
|
|
const storagePath = upload.temporary_storage_key
|
|
? absoluteStoragePath(upload.temporary_storage_key)
|
|
: null;
|
|
const exists = storagePath ? await fileSize(storagePath) : 0;
|
|
if (!exists && upload.status === 'expired') continue;
|
|
candidates.push({
|
|
id: candidateId('upload', upload.id),
|
|
kind: 'stale_upload',
|
|
label: upload.filename,
|
|
path: upload.temporary_storage_key ?? '',
|
|
sizeBytes: exists || Number(upload.reserved_bytes ?? 0),
|
|
createdAt: Number(upload.created_at),
|
|
detail:
|
|
upload.status === 'reserved'
|
|
? '未完成的上传预留'
|
|
: upload.status === 'uploaded'
|
|
? '已上传但未完成入库'
|
|
: '已失效的上传临时文件',
|
|
refId: upload.id,
|
|
});
|
|
}
|
|
|
|
const tmpDir = path.join(storageRoot, 'tmp', userId);
|
|
await walkFiles(tmpDir, async (fullPath) => {
|
|
const key = path.relative(storageRoot, fullPath).split(path.sep).join('/');
|
|
const active = uploads.some(
|
|
(upload) => upload.temporary_storage_key === key && upload.status === 'reserved',
|
|
);
|
|
if (active) return;
|
|
const sizeBytes = await fileSize(fullPath);
|
|
if (!sizeBytes) return;
|
|
candidates.push({
|
|
id: candidateId('tmp', key),
|
|
kind: 'orphan_tmp',
|
|
label: path.basename(fullPath),
|
|
path: key,
|
|
sizeBytes,
|
|
createdAt: null,
|
|
detail: '孤立的临时上传文件',
|
|
refId: key,
|
|
});
|
|
});
|
|
|
|
const workspaceTempDir = path.join(h5Root, 'temp', username);
|
|
await walkFiles(workspaceTempDir, async (fullPath) => {
|
|
const rel = path.relative(workspaceTempDir, fullPath).split(path.sep).join('/');
|
|
const sizeBytes = await fileSize(fullPath);
|
|
if (!sizeBytes) return;
|
|
candidates.push({
|
|
id: candidateId('workspace', rel),
|
|
kind: 'workspace_temp',
|
|
label: rel,
|
|
path: `temp/${username}/${rel}`,
|
|
sizeBytes,
|
|
createdAt: null,
|
|
detail: 'Agent 工作区临时文件',
|
|
refId: rel,
|
|
});
|
|
});
|
|
|
|
return candidates;
|
|
};
|
|
|
|
const runCleanup = async (userId, username, itemIds) => {
|
|
const selected = new Set(itemIds ?? []);
|
|
const candidates = await listCandidates(userId, username);
|
|
const targets = candidates.filter((item) => selected.has(item.id));
|
|
let freedBytes = 0;
|
|
let removedCount = 0;
|
|
|
|
for (const item of targets) {
|
|
if (item.kind === 'stale_upload') {
|
|
const conn = await pool.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
const [rows] = await conn.query(
|
|
`SELECT id, space_id, reserved_bytes, temporary_storage_key, status
|
|
FROM h5_upload_sessions
|
|
WHERE id = ? AND user_id = ? AND status IN ('reserved', 'uploaded', 'expired', 'failed')
|
|
LIMIT 1 FOR UPDATE`,
|
|
[item.refId, userId],
|
|
);
|
|
const upload = rows[0];
|
|
if (upload) {
|
|
await conn.query(
|
|
`UPDATE h5_user_spaces
|
|
SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
|
|
WHERE id = ? AND user_id = ?`,
|
|
[upload.reserved_bytes, Date.now(), upload.space_id, userId],
|
|
);
|
|
await conn.query(
|
|
`UPDATE h5_upload_sessions SET status = 'expired' WHERE id = ? AND user_id = ?`,
|
|
[upload.id, userId],
|
|
);
|
|
await conn.commit();
|
|
if (upload.temporary_storage_key) {
|
|
const target = absoluteStoragePath(upload.temporary_storage_key);
|
|
const sizeBytes = await fileSize(target);
|
|
await fs.rm(target, { force: true });
|
|
freedBytes += sizeBytes;
|
|
}
|
|
removedCount += 1;
|
|
} else {
|
|
await conn.rollback();
|
|
}
|
|
} catch {
|
|
await conn.rollback();
|
|
} finally {
|
|
conn.release();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (item.kind === 'orphan_tmp') {
|
|
const target = absoluteStoragePath(item.refId);
|
|
const sizeBytes = await fileSize(target);
|
|
await fs.rm(target, { force: true });
|
|
freedBytes += sizeBytes;
|
|
removedCount += 1;
|
|
continue;
|
|
}
|
|
|
|
if (item.kind === 'workspace_temp') {
|
|
const target = path.join(h5Root, 'temp', username, item.refId);
|
|
const resolvedRoot = path.resolve(path.join(h5Root, 'temp', username));
|
|
const resolved = path.resolve(target);
|
|
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) continue;
|
|
const sizeBytes = await fileSize(resolved);
|
|
await fs.rm(resolved, { force: true });
|
|
freedBytes += sizeBytes;
|
|
removedCount += 1;
|
|
}
|
|
}
|
|
|
|
return { removedCount, freedBytes };
|
|
};
|
|
|
|
return { listCandidates, runCleanup };
|
|
}
|