Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user