6ee6fd64dd
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>
319 lines
11 KiB
JavaScript
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 };
|
|
}
|