Compare commits

..

No commits in common. "da645d2591a11a4a31747139587a765ae8eeb895" and "692e4a408a8ea6d78eb3f71f6bf2fe2c603337d9" have entirely different histories.

5 changed files with 13 additions and 59 deletions

View File

@ -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 = '', retryOf = null } = req.body || {}; const { command, type = 'custom', app = null, config = '' } = 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,10 +318,7 @@ 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);
@ -330,19 +327,6 @@ 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 });

View File

@ -343,16 +343,6 @@
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

View File

@ -9,31 +9,24 @@ Object.assign(TasksManager.prototype, {
throw new Error('Task not found'); throw new Error('Task not found');
} }
// Create a new task with the same command, linked to the failed one via // Create a new task with the same command
// `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 }
); );
// Refresh tasks to show both the new run and the now-"Retried" original, //// // console.log(`✅ Task retried: ${newTask.id}`);
// then jump to the new run and open its live log — the user follows the
// retry instead of hunting for it. // Refresh tasks to show the new one
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 — following the new run.', 'success', null, null, null, customIcon); window.notificationSystem.show('Task retried successfully', 'success', null, null, null, customIcon);
} }
} catch (error) { } catch (error) {
console.error('Error retrying task:', error); console.error('Error retrying task:', error);

View File

@ -175,12 +175,11 @@ 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 && !task.retriedBy ? ` ${isFailed ? `
<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"/>
@ -188,14 +187,6 @@ 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>
@ -227,10 +218,6 @@ 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>

View File

@ -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 = '', extra = {}) { async createTask(command, type = 'custom', app = null, config = '') {
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, ...extra }) body: JSON.stringify({ command, type, app, config })
}); });
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();