feat(tasks): retry keeps the failed task (marked "Retried") + jumps to new run
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) <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
692e4a408a
commit
2005f2c742
@ -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 });
|
||||
|
||||
@ -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 —
|
||||
|
||||
@ -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 ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : 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);
|
||||
|
||||
@ -175,11 +175,12 @@ Object.assign(TasksManager.prototype, {
|
||||
${this.renderTaskIcons(task)}
|
||||
<span class="task-title">${this.formatCommandForUser(task)}</span>
|
||||
<span class="task-status ${statusClass}">${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}</span>
|
||||
${task.retriedBy ? '<span class="task-status task-retried" title="Superseded by a newer run">↻ Retried</span>' : ''}
|
||||
<span class="task-time">${timeAgo}</span>
|
||||
${executionTime ? `<span class="task-duration">⏱️ ${executionTime}</span>` : ''}
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
${isFailed ? `
|
||||
${isFailed && !task.retriedBy ? `
|
||||
<button class="task-btn retry" onclick="event.stopPropagation(); retryTask('${task.id}')" title="Retry Task">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
|
||||
@ -187,6 +188,14 @@ Object.assign(TasksManager.prototype, {
|
||||
Retry
|
||||
</button>
|
||||
` : ''}
|
||||
${task.retriedBy ? `
|
||||
<button class="task-btn" onclick="event.stopPropagation(); window.tasksManager && tasksManager.selectTask('${task.retriedBy}')" title="View the retry of this task">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
|
||||
</svg>
|
||||
<span class="task-btn-label">View retry</span>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="task-btn toggle-details" onclick="event.stopPropagation(); toggleTaskDetails('${task.id}')" title="Toggle Task Details">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6,9 12,15 18,9"></polyline>
|
||||
@ -218,6 +227,10 @@ Object.assign(TasksManager.prototype, {
|
||||
<div class="meta-item">
|
||||
<strong>Type:</strong> ${task.type || 'unknown'}
|
||||
</div>
|
||||
${task.retryOf ? `
|
||||
<div class="meta-item">
|
||||
<strong>Retry of:</strong> <a href="#" onclick="event.preventDefault(); event.stopPropagation(); window.tasksManager && tasksManager.selectTask('${task.retryOf}')" class="task-id-link">${task.retryOf}</a>
|
||||
</div>` : ''}
|
||||
<div class="meta-item">
|
||||
<strong>App:</strong> ${task.app ? `<a href="/app/${task.app}" class="task-app-link" data-app-name="${task.app}">${task.app}</a>` : 'system'}
|
||||
</div>
|
||||
|
||||
@ -15,11 +15,11 @@ class TaskManager {
|
||||
* 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 = '') {
|
||||
async createTask(command, type = 'custom', app = null, config = '', extra = {}) {
|
||||
const res = await fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command, type, app, config })
|
||||
body: JSON.stringify({ command, type, app, config, ...extra })
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to create task: HTTP ${res.status}`);
|
||||
const task = await res.json();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user