/** * TaskManager — thin client over /api/tasks. * * Replaces the old direct-file-operation approach. We never touch task files * or the queue from the browser anymore — the server owns them. State updates * arrive via SSE through TaskEventBus; this class just performs CRUD. */ class TaskManager { constructor() {} /** * Create a new task. Returns the server's authoritative task object. * Side effect: a `taskCreated` window event will fire from the SSE bus * within milliseconds — but the POST response also contains the task, * so callers that need the id immediately can use the return value. */ async createTask(command, type = 'custom', app = null, config = '') { const res = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command, type, app, config }) }); if (!res.ok) throw new Error(`Failed to create task: HTTP ${res.status}`); const task = await res.json(); if (window.tasksManager) window.tasksManager.highlightedTaskId = task.id; return task; } async getTask(taskId) { if (window.taskEventBus) { const cached = window.taskEventBus.getTask(taskId); if (cached) return cached; } const res = await fetch(`/api/tasks/${taskId}`); if (res.status === 404) return null; if (!res.ok) return null; return res.json(); } async getTaskSummary(taskId) { const task = await this.getTask(taskId); if (!task) return null; return { id: task.id, command: task.command, type: task.type, app: task.app, config: task.config, status: task.status, createdAt: task.created_at || task.createdAt, startedAt: task.started_at || task.startedAt, completedAt: task.completed_at || task.completedAt, hasOutput: false, hasError: !!task.error_message, outputLength: 0, errorLength: task.error_message ? task.error_message.length : 0 }; } async listTasks() { const res = await fetch('/api/tasks'); if (!res.ok) return []; return res.json(); } async cancelTask(taskId) { const res = await fetch(`/api/tasks/${taskId}/cancel`, { method: 'POST' }); if (!res.ok) throw new Error(`Cancel failed: HTTP ${res.status}`); return res.json(); } async deleteTask(taskId, { force = false } = {}) { const url = force ? `/api/tasks/${taskId}?force=1` : `/api/tasks/${taskId}`; const res = await fetch(url, { method: 'DELETE' }); if (!res.ok) throw new Error(`Delete failed: HTTP ${res.status}`); return true; } /** * Read the task log. Returns the last `maxLines` lines as an array. * The SSE bus delivers incremental log chunks via `taskLog` events; this * is for "open the modal and dump the full output" use-cases. */ async readTaskLog(taskId, maxLines = 1000) { try { const res = await fetch(`/api/tasks/${taskId}/log`); if (!res.ok) return []; const text = await res.text(); const lines = text.split('\n'); return lines.length > maxLines ? lines.slice(-maxLines) : lines; } catch { return []; } } async readFullTaskLog(taskId) { try { const res = await fetch(`/api/tasks/${taskId}/log`); if (!res.ok) return ''; return res.text(); } catch { return ''; } } /** * Lightweight log streamer for callers that want a callback per chunk. * Backed by SSE: registers a window listener for `taskLog` events that * match the requested taskId. */ streamTaskLog(taskId, onNewLines, onError) { let stopped = false; const handler = (event) => { if (stopped) return; const detail = event.detail || {}; if (detail.id !== taskId || typeof detail.chunk !== 'string') return; try { const lines = detail.chunk.split('\n').filter(l => l.length > 0); if (lines.length > 0) onNewLines(lines); } catch (err) { onError && onError(err); } }; window.addEventListener('taskLog', handler); return { stop: () => { stopped = true; window.removeEventListener('taskLog', handler); }, isStreaming: () => !stopped }; } /** * ANSI -> HTML colour parser (kept from the previous TaskManager so log * rendering doesn't lose formatting). */ parseAnsiColors(text) { const ansiRegex = /\x1b\[[0-9;]*m/g; const colorMap = { '30':'black','31':'red','32':'green','33':'yellow','34':'blue','35':'magenta','36':'cyan','37':'white', '90':'darkgray','91':'lightred','92':'lightgreen','93':'lightyellow','94':'lightblue','95':'lightmagenta','96':'lightcyan','97':'lightwhite' }; const bgColorMap = { '40':'black','41':'red','42':'green','43':'yellow','44':'blue','45':'magenta','46':'cyan','47':'white', '100':'darkgray','101':'lightred','102':'lightgreen','103':'lightyellow','104':'lightblue','105':'lightmagenta','106':'lightcyan','107':'lightwhite' }; let result = ''; let cursor = 0; let m; while ((m = ansiRegex.exec(text)) !== null) { result += text.substring(cursor, m.index); const codes = m[0].slice(2, -1).split(';'); const styles = []; let reset = false; for (const code of codes) { if (code === '0' || code === '') reset = true; else if (code === '1') styles.push('font-weight: bold'); else if (code === '4') styles.push('text-decoration: underline'); else if (colorMap[code]) styles.push(`color: ${colorMap[code]}`); else if (bgColorMap[code]) styles.push(`background-color: ${bgColorMap[code]}`); } if (styles.length) result += ``; else if (reset) result += ''; cursor = m.index + m[0].length; } result += text.substring(cursor); const open = (result.match(//g) || []).length; result += ''.repeat(Math.max(0, open - close)); return result; } generateTaskId() { return `task_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; } } if (typeof module !== 'undefined' && module.exports) { module.exports = TaskManager; } else { window.TaskManager = TaskManager; }