Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Run Duration",
"colAction": "Action",
"statusUnknown": "unknown",
"skipReasonLabel": "تم التخطي:",
"errorLabel": "خطأ:",
"triggering": "Triggering...",
"triggerNow": "Trigger Now",
"runHistoryTitle": "Run History – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Laufdauer",
"colAction": "Aktion",
"statusUnknown": "unbekannt",
"skipReasonLabel": "Übersprungen:",
"errorLabel": "Fehler:",
"triggering": "Wird ausgelöst...",
"triggerNow": "Jetzt auslösen",
"runHistoryTitle": "Laufverlauf – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8039,6 +8039,8 @@
"colRunDuration": "Run Duration",
"colAction": "Action",
"statusUnknown": "unknown",
"skipReasonLabel": "Skipped:",
"errorLabel": "Error:",
"triggering": "Triggering...",
"triggerNow": "Trigger Now",
"runHistoryTitle": "Run History – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Duración de la ejecución",
"colAction": "Acción",
"statusUnknown": "desconocido",
"skipReasonLabel": "Omitido:",
"errorLabel": "Error:",
"triggering": "Activando...",
"triggerNow": "Activar ahora",
"runHistoryTitle": "Historial de ejecuciones – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Run Duration",
"colAction": "Action",
"statusUnknown": "unknown",
"skipReasonLabel": "نادیده گرفته شد:",
"errorLabel": "خطا:",
"triggering": "Triggering...",
"triggerNow": "Trigger Now",
"runHistoryTitle": "Run History – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Durée d'exécution",
"colAction": "Action",
"statusUnknown": "inconnu",
"skipReasonLabel": "Ignoré :",
"errorLabel": "Erreur :",
"triggering": "Déclenchement...",
"triggerNow": "Déclencher maintenant",
"runHistoryTitle": "Historique des exécutions – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/he.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Run Duration",
"colAction": "Action",
"statusUnknown": "unknown",
"skipReasonLabel": "דולג:",
"errorLabel": "שגיאה:",
"triggering": "Triggering...",
"triggerNow": "Trigger Now",
"runHistoryTitle": "Run History – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/lv.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Run Duration",
"colAction": "Action",
"statusUnknown": "unknown",
"skipReasonLabel": "Izlaists:",
"errorLabel": "Kļūda:",
"triggering": "Triggering...",
"triggerNow": "Trigger Now",
"runHistoryTitle": "Run History – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Run Duration",
"colAction": "Action",
"statusUnknown": "unknown",
"skipReasonLabel": "Pominięto:",
"errorLabel": "Błąd:",
"triggering": "Triggering...",
"triggerNow": "Trigger Now",
"runHistoryTitle": "Run History – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Duração da execução",
"colAction": "Ação",
"statusUnknown": "desconhecido",
"skipReasonLabel": "Ignorado:",
"errorLabel": "Erro:",
"triggering": "Acionando...",
"triggerNow": "Acionar agora",
"runHistoryTitle": "Histórico de execuções – {name}",
Expand Down
2 changes: 2 additions & 0 deletions autobot-frontend/src/i18n/locales/ur.json
Original file line number Diff line number Diff line change
Expand Up @@ -8051,6 +8051,8 @@
"colRunDuration": "Run Duration",
"colAction": "Action",
"statusUnknown": "unknown",
"skipReasonLabel": "چھوڑ دیا گیا:",
"errorLabel": "خرابی:",
"triggering": "Triggering...",
"triggerNow": "Trigger Now",
"runHistoryTitle": "Run History – {name}",
Expand Down
33 changes: 33 additions & 0 deletions autobot-frontend/src/views/llc/HeartbeatMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@
{{ computeDuration(run.started_at, run.finished_at) }}
</span>
</div>
<div v-if="run.error" class="run-reason" :class="`reason-${run.status}`">
<span class="reason-label">
{{
run.status === 'skipped'
? $t('llc.heartbeat.skipReasonLabel')
: $t('llc.heartbeat.errorLabel')
}}
</span>
<span class="reason-text">{{ run.error }}</span>
</div>
<div class="run-actions">
<button class="toggle-payload" @click="toggleRun(run.id)">
{{ expandedRuns.has(run.id) ? $t('llc.heartbeat.hideContext') : $t('llc.heartbeat.showContext') }}
Expand Down Expand Up @@ -180,6 +190,8 @@ interface Agent {
interface AgentRun {
id: string
status: string
/** Skip reason for SKIPPED runs, failure text for FAILED ones (#12681). */
error?: string | null
started_at: string
finished_at?: string
completed_at?: string
Expand Down Expand Up @@ -512,6 +524,8 @@ onUnmounted(() => {
.status-failed.status-dot { background: var(--color-error); }
.status-timed_out { color: var(--color-error); }
.status-timed_out.status-dot { background: var(--color-error); }
.status-skipped { color: var(--color-warning); }
.status-skipped.status-dot { background: var(--color-warning); }
.status-unknown { color: var(--text-secondary); }
.status-unknown.status-dot { background: var(--border-default); }

Expand Down Expand Up @@ -597,6 +611,25 @@ onUnmounted(() => {
font-size: 0.875rem;
}

.run-reason {
display: flex;
align-items: baseline;
gap: 0.4rem;
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--text-secondary);
overflow-wrap: anywhere;
}

.run-reason .reason-label {
font-weight: 600;
white-space: nowrap;
}

.run-reason.reason-failed .reason-label {
color: var(--color-error);
}

.run-date,
.run-duration {
font-size: 0.8rem;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright 2025-2026 mrveiss
// SPDX-License-Identifier: Apache-2.0
// AutoBot - AI-Powered Automation Platform
// Author: mrveiss

// #12681: a heartbeat that never dispatched records SKIPPED and stores the
// reason in `error`, which `GET /api/llc/agents/{id}/runs` has always returned.
// The monitor rendered the status and dropped the reason, so "skipped" looked
// identical whether the CLI was missing, a cooldown was active, or the agent
// had no work — an operator had to read the database to tell them apart.
//
// These mount the component rather than asserting on the response shape: the
// field being present in the payload proves nothing while the template never
// reads it, which is exactly how this stayed invisible.

import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import en from '@/i18n/locales/en.json'

const get = vi.fn()

vi.mock('@/plugins/api', () => ({
useApiClient: () => ({ get, post: vi.fn() }),
}))

vi.mock('@/utils/debugUtils', () => ({
createLogger: () => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }),
}))

vi.mock('@/utils/fetchWithAuth', () => ({ fetchWithAuth: vi.fn() }))
vi.mock('@/config/ssot-config', () => ({ getApiBase: () => '' }))

import HeartbeatMonitor from '../HeartbeatMonitor.vue'

const AGENT = { id: 'a1', name: 'Agent One', heartbeat_enabled: true }
const i18n = createI18n({ legacy: false, locale: 'en', fallbackLocale: 'en', messages: { en } })
const LABELS = en.llc.heartbeat

// The component starts a 15s auto-refresh interval on mount, cleared only by
// onUnmounted. Without this the handle outlives every test in the file.
let mounted: ReturnType<typeof mount> | null = null

/** Mount the monitor and open one agent's run history. */
async function mountWithRuns(runs: Record<string, unknown>[]) {
get.mockImplementation((url: string) =>
url.includes('/runs') ? Promise.resolve(runs) : Promise.resolve([AGENT]),
)
const wrapper = mount(HeartbeatMonitor, {
props: { companyId: 'c1' },
global: { plugins: [i18n] },
})
await flushPromises()
await wrapper.find('.agent-row').trigger('click')
await flushPromises()
mounted = wrapper
return wrapper
}

describe('HeartbeatMonitor surfaces why a run was skipped (#12681)', () => {
beforeEach(() => {
get.mockReset()
})

afterEach(() => {
mounted?.unmount()
mounted = null
})

it('renders the persisted reason for a skipped run', async () => {
const wrapper = await mountWithRuns([
{
id: 'r1',
status: 'skipped',
started_at: '2026-01-01T00:00:00Z',
error: 'adapter binary not available',
},
])

const reason = wrapper.find('.run-reason')
expect(reason.exists()).toBe(true)
expect(reason.text()).toContain('adapter binary not available')
expect(reason.text()).toContain(LABELS.skipReasonLabel)
})

it('labels a failed run as an error rather than a skip', async () => {
const wrapper = await mountWithRuns([
{ id: 'r2', status: 'failed', started_at: '2026-01-01T00:00:00Z', error: 'adapter crashed' },
])

const reason = wrapper.find('.run-reason')
expect(reason.exists()).toBe(true)
expect(reason.text()).toContain(LABELS.errorLabel)
expect(reason.text()).not.toContain(LABELS.skipReasonLabel)
})

// The control: without it the first two would still pass if the row were
// rendered unconditionally, which would put an empty label on every run.
it('renders no reason row when the run carries none', async () => {
const wrapper = await mountWithRuns([
{ id: 'r3', status: 'completed', started_at: '2026-01-01T00:00:00Z', error: null },
])

expect(wrapper.find('.run-item').exists()).toBe(true)
expect(wrapper.find('.run-reason').exists()).toBe(false)
})
})
Loading