From 2005f2c7428558a2e1194de1e8a6963a4372055e Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 17 Jul 2026 23:23:48 +0100 Subject: [PATCH] feat(tasks): retry keeps the failed task (marked "Retried") + jumps to new run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously retry silently created a new task, left the failed one in place with no link, and didn't take you to the retry. Now: - The new task is linked to the failed one via `retryOf`; the server stamps the original `retriedBy` and KEEPS it (its log is the failure record — never deleted), so history survives and there's no confusing bare duplicate. - The failed row shows a muted "↻ Retried" pill, hides its now-stale Retry button, and offers "View retry" to jump to the new run. The new run shows a "Retry of:" backlink to the original. - After retrying, the UI selects the new task and opens its live log so you follow the retry instead of hunting for it. retryOf rides through the existing POST /api/tasks (no new endpoint); createTask gained an optional extra-body arg. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: librelad --- .../libreportal/backend/routes/task-routes.js | 20 ++++++++++++++-- .../frontend/components/tasks/css/tasks.css | 10 ++++++++ .../components/tasks/js/tasks-actions.js | 23 ++++++++++++------- .../components/tasks/js/tasks-list-render.js | 15 +++++++++++- .../frontend/core/tasks/js/task-manager.js | 4 ++-- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/containers/libreportal/backend/routes/task-routes.js b/containers/libreportal/backend/routes/task-routes.js index 4a63543..b2ceac9 100755 --- a/containers/libreportal/backend/routes/task-routes.js +++ b/containers/libreportal/backend/routes/task-routes.js @@ -301,7 +301,7 @@ router.get('/:id', requireAuth, async (req, res) => { // Create a task. router.post('/', requireAuth, async (req, res) => { try { - const { command, type = 'custom', app = null, config = '' } = req.body || {}; + const { command, type = 'custom', app = null, config = '', retryOf = null } = req.body || {}; if (typeof command !== 'string' || !command.trim()) { return res.status(400).json({ error: '`command` is required' }); } @@ -318,7 +318,10 @@ router.post('/', requireAuth, async (req, res) => { completedAt: null, heartbeatAt: null, exitCode: null, - errorMessage: null + errorMessage: null, + // Set when this task is a retry of another — links the two so the UI can + // mark the original "Retried" and jump between them. + retryOf: (typeof retryOf === 'string' && isValidTaskId(retryOf)) ? retryOf : null }; await writeTaskAtomic(id, task); pokeFifo(id); @@ -327,6 +330,19 @@ router.post('/', requireAuth, async (req, res) => { // fallback for this task until it terminates so a missed inotify // event can't strand it as "running" in the UI. armActiveTaskPoll(id); + // If this is a retry, stamp the original with `retriedBy` — we KEEP the + // failed task (its log is the failure record; we never delete it), just + // mark it as superseded. Only a terminal failed/cancelled task is stamped. + if (task.retryOf) { + try { + const orig = await readTask(task.retryOf); + if (orig && (orig.status === 'failed' || orig.status === 'cancelled')) { + const marked = { ...orig, retriedBy: id }; + await writeTaskAtomic(task.retryOf, marked); + sseBroadcast('task.upsert', marked); + } + } catch (_) { /* original gone — nothing to link */ } + } res.status(201).json(task); } catch (err) { res.status(500).json({ error: err.message }); diff --git a/containers/libreportal/frontend/components/tasks/css/tasks.css b/containers/libreportal/frontend/components/tasks/css/tasks.css index b4bfeb2..4267e87 100644 --- a/containers/libreportal/frontend/components/tasks/css/tasks.css +++ b/containers/libreportal/frontend/components/tasks/css/tasks.css @@ -343,6 +343,16 @@ color: #fca5a5; } +/* "Retried" tag — a muted, neutral pill sitting next to a failed status to show + the failure was superseded by a new run (the failed task is kept, not deleted, + as the failure record). Deliberately calm so it doesn't compete with FAILED. */ +.task-status.task-retried { + background: rgba(148, 163, 184, 0.16); + border: 1px solid rgba(148, 163, 184, 0.45); + color: #cbd5e1; + animation: none; +} + .task-command { /* Bright mint to match the .status-running / .status-completed pills. The theme's --status-success (#28a745) reads muddy olive on nebula — diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-actions.js b/containers/libreportal/frontend/components/tasks/js/tasks-actions.js index a6d07d4..eae82bd 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-actions.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-actions.js @@ -9,24 +9,31 @@ Object.assign(TasksManager.prototype, { throw new Error('Task not found'); } - // Create a new task with the same command + // Create a new task with the same command, linked to the failed one via + // `retryOf`. The server keeps the failed task (its log is the failure + // record) and stamps it `retriedBy` so the UI can mark it "Retried". const newTask = await this.taskManager.createTask( task.command, task.type, task.app, - task.config + task.config, + { retryOf: task.id } ); - - //// // console.log(`✅ Task retried: ${newTask.id}`); - - // Refresh tasks to show the new one + + // Refresh tasks to show both the new run and the now-"Retried" original, + // then jump to the new run and open its live log — the user follows the + // retry instead of hunting for it. await this.loadTasks(); - + if (typeof this.selectTask === 'function') { + // Row exists after the re-render; select on the next frame to be safe. + requestAnimationFrame(() => this.selectTask(newTask.id)); + } + if (window.notificationSystem) { // Use the source task's type icon — retrying a backup shows 💾 etc. const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : ''; const customIcon = typeIcon ? `${typeIcon}` : null; - window.notificationSystem.show('Task retried successfully', 'success', null, null, null, customIcon); + window.notificationSystem.show('Task retried — following the new run.', 'success', null, null, null, customIcon); } } catch (error) { console.error('Error retrying task:', error); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js index 3077edb..8fe6bda 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js @@ -175,11 +175,12 @@ Object.assign(TasksManager.prototype, { ${this.renderTaskIcons(task)} ${this.formatCommandForUser(task)} ${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'} + ${task.retriedBy ? '↻ Retried' : ''} ${timeAgo} ${executionTime ? `⏱️ ${executionTime}` : ''}
- ${isFailed ? ` + ${isFailed && !task.retriedBy ? ` ` : ''} + ${task.retriedBy ? ` + + ` : ''}