- features/tasks/: tasks-manager.js (the /tasks page controller) + tasks.css. - shared/task/: the 6 cross-cutting task-kernel files (event-bus, commands, actions, router, global-functions, manager) + task-refresh-coordinator.js — used by tasks AND apps/app-detail/backup, so they go to shared/, not a feature. task-parameter-preserve.js stays at js/ (shared root). - Updated all path strings: system-loader.js task-system + apps-manager components, apps-manager loadTaskSystem(), index.html (refresh-coordinator + tasks.css). Globals (taskEventBus/tasksManager/TaskManager/...) unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
179 lines
6.1 KiB
JavaScript
Executable File
179 lines
6.1 KiB
JavaScript
Executable File
/**
|
|
* 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 += `<span style="${styles.join('; ')}">`;
|
|
else if (reset) result += '</span>';
|
|
cursor = m.index + m[0].length;
|
|
}
|
|
result += text.substring(cursor);
|
|
const open = (result.match(/<span/g) || []).length;
|
|
const close = (result.match(/<\/span>/g) || []).length;
|
|
result += '</span>'.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;
|
|
}
|