import fs from 'node:fs/promises'; import path from 'node:path'; export class JobStore { constructor() { this.jobs = new Map(); this.nextId = 1; } create(type, task) { const id = String(this.nextId++); const job = { id, type, status: 'queued', progress: 0, message: '', error: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), cancelRequested: false }; this.jobs.set(id, job); queueMicrotask(async () => { await this.run(job, task); }); return this.publicJob(job); } get(id) { const job = this.jobs.get(String(id)); return job ? this.publicJob(job) : null; } cancel(id) { const job = this.jobs.get(String(id)); if (!job) { return null; } job.cancelRequested = true; this.touch(job, 'cancel_requested', job.progress, 'Cancel requested'); return this.publicJob(job); } async run(job, task) { try { this.touch(job, 'running', 1, 'Running'); await task({ isCanceled: () => job.cancelRequested, progress: (progress, message = job.message) => this.touch(job, 'running', progress, message) }); if (job.cancelRequested) { this.touch(job, 'canceled', job.progress, 'Canceled'); } else { this.touch(job, 'completed', 100, 'Completed'); } } catch (error) { this.touch(job, job.cancelRequested ? 'canceled' : 'failed', job.progress, error.message); job.error = error.message; } } touch(job, status, progress, message) { job.status = status; job.progress = Math.max(0, Math.min(100, Math.round(progress))); job.message = message; job.updatedAt = new Date().toISOString(); } publicJob(job) { const { cancelRequested, ...publicFields } = job; return publicFields; } } export async function movePath(source, destination, ctx) { if (ctx.isCanceled()) { throw new Error('Canceled'); } ctx.progress(10, 'Moving'); await fs.mkdir(path.dirname(destination), { recursive: true }); await fs.rename(source, destination); ctx.progress(95, 'Move finished'); } export async function copyPath(source, destination, ctx) { if (ctx.isCanceled()) { throw new Error('Canceled'); } ctx.progress(5, 'Preparing copy'); await fs.mkdir(path.dirname(destination), { recursive: true }); await copyRecursive(source, destination, ctx); ctx.progress(95, 'Copy finished'); } async function copyRecursive(source, destination, ctx) { if (ctx.isCanceled()) { throw new Error('Canceled'); } const stat = await fs.lstat(source); if (stat.isSymbolicLink()) { throw new Error('Copying symlinks is not allowed'); } if (stat.isDirectory()) { await fs.mkdir(destination, { recursive: true, mode: stat.mode }); const entries = await fs.readdir(source); let done = 0; for (const entry of entries) { await copyRecursive(path.join(source, entry), path.join(destination, entry), ctx); done += 1; ctx.progress(10 + (done / Math.max(entries.length, 1)) * 80, 'Copying directory'); } return; } if (!stat.isFile()) { throw new Error('Only files and directories can be copied'); } await fs.copyFile(source, destination); ctx.progress(85, 'Copying file'); }