Files
memind/mindspace-cleanup.mjs
T
John 6ee6fd64dd Add MindSpace page live edit, chat skills, and H5 deploy tooling.
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 22:09:38 -07:00

319 lines
11 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',
'.tkmind-profile.json',
'.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,
});
});
const [staleVersions] = await pool.query(
`SELECT pv.id AS version_id, pv.page_id, pv.version_no, pv.content_asset_id, pv.bundle_asset_id,
p.title, a.size_bytes, a.display_name, pv.created_at,
COALESCE(bundle.size_bytes, 0) AS bundle_size_bytes
FROM h5_page_versions pv
JOIN h5_page_records p ON p.id = pv.page_id AND p.user_id = ? AND p.status <> 'deleted'
JOIN h5_assets a ON a.id = pv.content_asset_id AND a.user_id = ? AND a.status <> 'deleted'
LEFT JOIN h5_assets bundle
ON bundle.id = pv.bundle_asset_id AND bundle.user_id = ? AND bundle.status <> 'deleted'
WHERE pv.id <> p.current_version_id
AND NOT EXISTS (
SELECT 1 FROM h5_publish_records pr
WHERE pr.page_version_id = pv.id AND pr.user_id = ?
)
ORDER BY pv.created_at ASC
LIMIT 500`,
[userId, userId, userId, userId],
);
for (const row of staleVersions) {
const contentBytes = Number(row.size_bytes ?? 0);
const bundleBytes = Number(row.bundle_size_bytes ?? 0);
candidates.push({
id: candidateId('page_version', row.version_id),
kind: 'stale_page_version',
label: `${row.title} · v${row.version_no}`,
path: `page/${row.page_id}/version/${row.version_no}`,
sizeBytes: contentBytes + bundleBytes,
createdAt: Number(row.created_at),
detail: '页面历史版本(非当前版本)',
refId: row.version_id,
});
}
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;
continue;
}
if (item.kind === 'stale_page_version') {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT pv.id, pv.page_id, pv.content_asset_id, pv.bundle_asset_id,
p.current_version_id, p.space_id
FROM h5_page_versions pv
JOIN h5_page_records p ON p.id = pv.page_id
WHERE pv.id = ? AND p.user_id = ?
LIMIT 1 FOR UPDATE`,
[item.refId, userId],
);
const version = rows[0];
if (!version || version.id === version.current_version_id) {
await conn.rollback();
continue;
}
const [pubRows] = await conn.query(
`SELECT id FROM h5_publish_records WHERE page_version_id = ? AND user_id = ? LIMIT 1`,
[version.id, userId],
);
if (pubRows.length) {
await conn.rollback();
continue;
}
const now = Date.now();
let freed = 0;
const assetsToDelete = [version.content_asset_id].filter(Boolean);
if (version.bundle_asset_id) {
const [bundleUse] = await conn.query(
`SELECT COUNT(*) AS cnt FROM h5_page_versions
WHERE bundle_asset_id = ? AND id <> ?`,
[version.bundle_asset_id, version.id],
);
if (Number(bundleUse[0]?.cnt ?? 0) === 0) {
assetsToDelete.push(version.bundle_asset_id);
}
}
for (const assetId of assetsToDelete) {
const [assetRows] = await conn.query(
`SELECT id, size_bytes FROM h5_assets
WHERE id = ? AND user_id = ? AND status <> 'deleted'
LIMIT 1 FOR UPDATE`,
[assetId, userId],
);
const asset = assetRows[0];
if (!asset) continue;
await conn.query(
`UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[now, now, assetId, userId],
);
freed += Number(asset.size_bytes ?? 0);
}
await conn.query(`DELETE FROM h5_page_versions WHERE id = ?`, [version.id]);
if (freed > 0) {
await conn.query(
`UPDATE h5_user_spaces
SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ?
WHERE id = ? AND user_id = ?`,
[freed, now, version.space_id, userId],
);
}
await conn.commit();
freedBytes += freed;
removedCount += 1;
} catch {
await conn.rollback();
} finally {
conn.release();
}
}
}
return { removedCount, freedBytes };
};
return { listCandidates, runCleanup };
}