Files
tkmind_go/ui/h5/mindspace-cleanup.mjs
john 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
Add TKMind platform extensions, H5/MindSpace stack, and deployment tooling.
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>
2026-06-14 21:30:20 +08:00

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 };
}