librelad b4649cd713 refactor(webui): relocate tasks page + shared task kernel
- 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>
2026-05-30 02:00:59 +01:00

155 lines
5.3 KiB
JavaScript
Executable File

/**
* TaskEventBus — single SSE connection for the page.
*
* Connects to /api/tasks/events. Translates the server's SSE events
* (task.upsert, task.deleted, task.log) into the existing window-level
* CustomEvents that the rest of the UI already listens for:
* - taskCreated (when a brand-new task appears)
* - taskUpdated (status change while still active)
* - taskCompleted (status -> completed | failed | cancelled)
* - taskLog (new log lines for a running task)
* - taskDeleted (task removed)
*
* The bus also exposes a `tasks` Map keyed by id holding the latest known
* task object — components can read this synchronously instead of fetching.
*/
class TaskEventBus {
constructor() {
this.tasks = new Map(); // id -> latest task object
this.eventSource = null;
this.reconnectTimer = null;
this.connected = false;
// Track previous status per task so we can decide created vs updated vs completed.
this._lastStatus = new Map();
}
start() {
if (this.eventSource) return;
this._open();
}
stop() {
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.eventSource) { this.eventSource.close(); this.eventSource = null; }
this.connected = false;
}
// Convenience accessors used by UI components.
getTask(id) { return this.tasks.get(id) || null; }
getRunningTasks() {
const out = [];
for (const t of this.tasks.values()) {
if (t.status === 'running' || t.status === 'queued' || t.status === 'pending') out.push(t);
}
return out;
}
getRunningForApp(appName) {
return this.getRunningTasks().filter(t => t.app === appName);
}
// ---- internals --------------------------------------------------------
_open() {
try {
this.eventSource = new EventSource('/api/tasks/events');
} catch (err) {
this._scheduleReconnect();
return;
}
this.eventSource.addEventListener('ready', () => {
this.connected = true;
window.dispatchEvent(new CustomEvent('taskBusReady'));
});
this.eventSource.addEventListener('task.upsert', (e) => {
let task; try { task = JSON.parse(e.data); } catch { return; }
if (!task || !task.id) return;
this._handleUpsert(task);
});
this.eventSource.addEventListener('task.deleted', (e) => {
let payload; try { payload = JSON.parse(e.data); } catch { return; }
if (!payload || !payload.id) return;
this.tasks.delete(payload.id);
this._lastStatus.delete(payload.id);
window.dispatchEvent(new CustomEvent('taskDeleted', { detail: { id: payload.id } }));
});
this.eventSource.addEventListener('task.log', (e) => {
let payload; try { payload = JSON.parse(e.data); } catch { return; }
if (!payload || !payload.id || typeof payload.chunk !== 'string') return;
window.dispatchEvent(new CustomEvent('taskLog', {
detail: { id: payload.id, chunk: payload.chunk }
}));
});
this.eventSource.onerror = () => {
// Browser will auto-retry, but we want a deterministic backoff on top
// so we don't hammer the server during a long outage.
this.connected = false;
this.eventSource && this.eventSource.close();
this.eventSource = null;
this._scheduleReconnect();
};
}
_scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._open();
}, 3000);
}
_handleUpsert(task) {
const prevStatus = this._lastStatus.get(task.id);
const isNew = !this.tasks.has(task.id);
this.tasks.set(task.id, task);
this._lastStatus.set(task.id, task.status);
const detail = {
taskId: task.id,
appName: task.app || null,
action: task.type || 'unknown',
status: task.status,
task,
timestamp: Date.now()
};
const isTerminal = task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled';
const wasTerminal = prevStatus === 'completed' || prevStatus === 'failed' || prevStatus === 'cancelled';
// Bootstrap silently. When this is the first time we've ever seen a task
// AND it's already in a terminal state, we have NO evidence it
// transitioned just now — it might have completed seconds or hours ago.
// The backend re-broadcasts the file's contents on any inode change (e.g.
// the periodic ownership/permission sweep stats every task file), so
// firing taskCompleted here would surface a "ghost" completion
// notification for a task the user already saw finish 28 minutes ago.
// Just record it in our map and move on.
if (isNew && isTerminal) {
return;
}
if (isNew) {
window.dispatchEvent(new CustomEvent('taskCreated', { detail }));
}
// Genuine transition to terminal: we had a prior non-terminal record and
// the current upsert flips that to terminal. This is what users expect
// to be notified about.
if (isTerminal && !wasTerminal) {
window.dispatchEvent(new CustomEvent('taskCompleted', { detail }));
} else if (!isNew) {
window.dispatchEvent(new CustomEvent('taskUpdated', { detail }));
}
}
}
// One instance per page.
window.taskEventBus = window.taskEventBus || new TaskEventBus();
window.TaskEventBus = TaskEventBus;