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.
|
// Create a task.
|
||||||
router.post('/', requireAuth, async (req, res) => {
|
router.post('/', requireAuth, async (req, res) => {
|
||||||
try {
|
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()) {
|
if (typeof command !== 'string' || !command.trim()) {
|
||||||
return res.status(400).json({ error: '`command` is required' });
|
return res.status(400).json({ error: '`command` is required' });
|
||||||
}
|
}
|
||||||
@ -318,7 +318,10 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
completedAt: null,
|
completedAt: null,
|
||||||
heartbeatAt: null,
|
heartbeatAt: null,
|
||||||
exitCode: 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);
|
await writeTaskAtomic(id, task);
|
||||||
pokeFifo(id);
|
pokeFifo(id);
|
||||||
@ -327,6 +330,19 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
// fallback for this task until it terminates so a missed inotify
|
// fallback for this task until it terminates so a missed inotify
|
||||||
// event can't strand it as "running" in the UI.
|
// event can't strand it as "running" in the UI.
|
||||||
armActiveTaskPoll(id);
|
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);
|
res.status(201).json(task);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
|
|||||||
@ -343,6 +343,16 @@
|
|||||||
color: #fca5a5;
|
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 {
|
.task-command {
|
||||||
/* Bright mint to match the .status-running / .status-completed pills.
|
/* Bright mint to match the .status-running / .status-completed pills.
|
||||||
The theme's --status-success (#28a745) reads muddy olive on nebula —
|
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');
|
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(
|
const newTask = await this.taskManager.createTask(
|
||||||
task.command,
|
task.command,
|
||||||
task.type,
|
task.type,
|
||||||
task.app,
|
task.app,
|
||||||
task.config
|
task.config,
|
||||||
|
{ retryOf: task.id }
|
||||||
);
|
);
|
||||||
|
|
||||||
//// // console.log(`✅ Task retried: ${newTask.id}`);
|
// 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
|
||||||
// Refresh tasks to show the new one
|
// retry instead of hunting for it.
|
||||||
await this.loadTasks();
|
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) {
|
if (window.notificationSystem) {
|
||||||
// Use the source task's type icon — retrying a backup shows 💾 etc.
|
// Use the source task's type icon — retrying a backup shows 💾 etc.
|
||||||
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '';
|
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '';
|
||||||
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
|
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) {
|
} catch (error) {
|
||||||
console.error('Error retrying task:', error);
|
console.error('Error retrying task:', error);
|
||||||
|
|||||||
@ -175,11 +175,12 @@ Object.assign(TasksManager.prototype, {
|
|||||||
${this.renderTaskIcons(task)}
|
${this.renderTaskIcons(task)}
|
||||||
<span class="task-title">${this.formatCommandForUser(task)}</span>
|
<span class="task-title">${this.formatCommandForUser(task)}</span>
|
||||||
<span class="task-status ${statusClass}">${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}</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>
|
<span class="task-time">${timeAgo}</span>
|
||||||
${executionTime ? `<span class="task-duration">⏱️ ${executionTime}</span>` : ''}
|
${executionTime ? `<span class="task-duration">⏱️ ${executionTime}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="task-actions">
|
<div class="task-actions">
|
||||||
${isFailed ? `
|
${isFailed && !task.retriedBy ? `
|
||||||
<button class="task-btn retry" onclick="event.stopPropagation(); retryTask('${task.id}')" title="Retry Task">
|
<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">
|
<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"/>
|
<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
|
Retry
|
||||||
</button>
|
</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">
|
<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">
|
<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>
|
<polyline points="6,9 12,15 18,9"></polyline>
|
||||||
@ -218,6 +227,10 @@ Object.assign(TasksManager.prototype, {
|
|||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<strong>Type:</strong> ${task.type || 'unknown'}
|
<strong>Type:</strong> ${task.type || 'unknown'}
|
||||||
</div>
|
</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">
|
<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'}
|
<strong>App:</strong> ${task.app ? `<a href="/app/${task.app}" class="task-app-link" data-app-name="${task.app}">${task.app}</a>` : 'system'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,11 +15,11 @@ class TaskManager {
|
|||||||
* within milliseconds — but the POST response also contains the task,
|
* within milliseconds — but the POST response also contains the task,
|
||||||
* so callers that need the id immediately can use the return value.
|
* 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', {
|
const res = await fetch('/api/tasks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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}`);
|
if (!res.ok) throw new Error(`Failed to create task: HTTP ${res.status}`);
|
||||||
const task = await res.json();
|
const task = await res.json();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user