PR #550: surface kanban state in workspace dashboard (janishohbergs85-star) — addresses #570; restored null-guards in normalizeCron that PR removed
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DashboardOverview } from '@/server/dashboard-aggregator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function formatPulse(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
@@ -84,10 +84,12 @@ function formatNextRun(iso: string | null): {
|
||||
export function OpsStrip({
|
||||
status,
|
||||
cron,
|
||||
kanban,
|
||||
platforms,
|
||||
}: {
|
||||
status: DashboardOverview['status']
|
||||
cron: DashboardOverview['cron']
|
||||
kanban: DashboardOverview['kanban']
|
||||
platforms: DashboardOverview['platforms']
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
@@ -217,6 +219,42 @@ export function OpsStrip({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{kanban ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: '/swarm2' })}
|
||||
className="inline-flex items-center gap-2 rounded border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors hover:bg-[var(--theme-card)]/80"
|
||||
style={{
|
||||
borderColor:
|
||||
kanban.blocked > 0
|
||||
? 'color-mix(in srgb, var(--theme-warning) 35%, transparent)'
|
||||
: 'var(--theme-border)',
|
||||
background:
|
||||
kanban.blocked > 0
|
||||
? 'color-mix(in srgb, var(--theme-warning) 10%, transparent)'
|
||||
: 'transparent',
|
||||
color: 'var(--theme-muted)',
|
||||
}}
|
||||
title="Open Kanban board"
|
||||
>
|
||||
<span>board</span>
|
||||
<span style={{ color: 'var(--theme-text)' }}>{kanban.total}</span>
|
||||
{kanban.ready > 0 ? (
|
||||
<span style={{ color: 'var(--theme-text)' }}>· {kanban.ready} ready</span>
|
||||
) : null}
|
||||
{kanban.running > 0 ? (
|
||||
<span style={{ color: 'var(--theme-success)' }}>
|
||||
· {kanban.running} running
|
||||
</span>
|
||||
) : null}
|
||||
{kanban.blocked > 0 ? (
|
||||
<span style={{ color: 'var(--theme-warning)' }}>
|
||||
· {kanban.blocked} blocked
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{cron ? (() => {
|
||||
const isStale = next?.text === 'stale'
|
||||
const isWarn = next?.text === 'overdue' || isStale
|
||||
|
||||
@@ -1032,6 +1032,7 @@ export function DashboardScreen() {
|
||||
<OpsStrip
|
||||
status={overview?.status ?? null}
|
||||
cron={overview?.cron ?? null}
|
||||
kanban={overview?.kanban ?? null}
|
||||
platforms={overview?.platforms ?? []}
|
||||
/>
|
||||
|
||||
|
||||
@@ -164,7 +164,6 @@ export function PlaygroundHud({
|
||||
<div className="text-[11px] font-black uppercase tracking-[0.14em]" style={{ color: HUD.parchment }}>
|
||||
{playerProfile.displayName || 'Builder'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 max-w-[126px] truncate text-[9px] uppercase tracking-[0.18em]" style={{ color: HUD.stone }}>{title}</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="h-1.5 w-[88px] overflow-hidden rounded-full" style={{ background: 'rgba(244,233,211,.12)', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.45)' }}>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildDashboardOverview,
|
||||
type DashboardFetcher,
|
||||
} from './dashboard-aggregator'
|
||||
import { buildDashboardOverview } from './dashboard-aggregator'
|
||||
import type { DashboardFetcher } from './dashboard-aggregator'
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
@@ -31,6 +29,7 @@ describe('buildDashboardOverview', () => {
|
||||
expect(overview.status).toBeNull()
|
||||
expect(overview.platforms).toEqual([])
|
||||
expect(overview.cron).toBeNull()
|
||||
expect(overview.kanban).toBeNull()
|
||||
expect(overview.achievements).toBeNull()
|
||||
expect(overview.modelInfo).toBeNull()
|
||||
expect(overview.analytics).toBeNull()
|
||||
@@ -131,9 +130,7 @@ describe('buildDashboardOverview', () => {
|
||||
name: 'Daily roll-up',
|
||||
lastError: 'connection refused',
|
||||
})
|
||||
const cronIncident = overview.incidents.find(
|
||||
(i) => i.id === 'cron-fail-a',
|
||||
)
|
||||
const cronIncident = overview.incidents.find((i) => i.id === 'cron-fail-a')
|
||||
expect(cronIncident?.severity).toBe('error')
|
||||
expect(cronIncident?.detail).toBe('connection refused')
|
||||
})
|
||||
@@ -396,6 +393,75 @@ describe('buildDashboardOverview', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('summarises dashboard kanban board state and blocked cards', async () => {
|
||||
const fetcher = makeFetcher({
|
||||
'/api/plugins/kanban/board': {
|
||||
columns: [
|
||||
{
|
||||
name: 'todo',
|
||||
tasks: [
|
||||
{
|
||||
id: 't_1',
|
||||
title: 'Draft plan',
|
||||
status: 'todo',
|
||||
assignee: 'planner',
|
||||
},
|
||||
{ id: 't_2', title: 'Queued follow-up', status: 'queued' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ready',
|
||||
tasks: [{ id: 't_3', title: 'Implement UI' }],
|
||||
},
|
||||
{
|
||||
name: 'running',
|
||||
tasks: [{ id: 't_4', title: 'Worker active', status: 'claimed' }],
|
||||
},
|
||||
{
|
||||
name: 'blocked',
|
||||
tasks: [
|
||||
{ id: 't_5', title: 'Needs credentials', assignee: 'ops' },
|
||||
{ id: 't_6', title: 'Needs review' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'done',
|
||||
tasks: [{ id: 't_7', title: 'Ship it', status: 'completed' }],
|
||||
},
|
||||
{
|
||||
name: 'custom',
|
||||
tasks: [{ id: 't_8', title: 'Unknown state' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const overview = await buildDashboardOverview({ fetcher })
|
||||
expect(overview.kanban).toEqual({
|
||||
total: 8,
|
||||
triage: 0,
|
||||
todo: 2,
|
||||
ready: 1,
|
||||
running: 1,
|
||||
blocked: 2,
|
||||
done: 1,
|
||||
other: 1,
|
||||
topBlocked: [
|
||||
{ id: 't_5', title: 'Needs credentials', assignee: 'ops' },
|
||||
{ id: 't_6', title: 'Needs review', assignee: null },
|
||||
],
|
||||
})
|
||||
expect(
|
||||
overview.insights.some((i) => i.text.includes('2 blocked kanban tasks')),
|
||||
).toBe(false)
|
||||
expect(
|
||||
overview.incidents.find((i) => i.id === 'kanban-blocked'),
|
||||
).toMatchObject({
|
||||
severity: 'warn',
|
||||
label: '2 kanban tasks blocked',
|
||||
href: '/swarm2',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses log tail with error/warn detection', async () => {
|
||||
const fetcher = makeFetcher({
|
||||
'/api/logs': {
|
||||
@@ -441,6 +507,7 @@ describe('buildDashboardOverview', () => {
|
||||
expect(overview.status?.version).toBeNull()
|
||||
expect(overview.status?.configVersion).toBeNull()
|
||||
expect(overview.cron?.total).toBe(1)
|
||||
expect(overview.kanban).toBeNull()
|
||||
expect(overview.achievements).toBeNull()
|
||||
expect(overview.modelInfo).toBeNull()
|
||||
expect(overview.analytics).toBeNull()
|
||||
|
||||
@@ -20,6 +20,7 @@ export type DashboardOverview = {
|
||||
status: DashboardStatusSection | null
|
||||
platforms: Array<DashboardPlatformEntry>
|
||||
cron: DashboardCronSection | null
|
||||
kanban: DashboardKanbanSection | null
|
||||
achievements: DashboardAchievementsSection | null
|
||||
modelInfo: DashboardModelInfoSection | null
|
||||
analytics: DashboardAnalyticsSection | null
|
||||
@@ -53,7 +54,7 @@ export type DashboardInsight = {
|
||||
export type DashboardIncident = {
|
||||
id: string
|
||||
severity: 'info' | 'warn' | 'error'
|
||||
source: 'cron' | 'platform' | 'log' | 'config' | 'gateway'
|
||||
source: 'cron' | 'kanban' | 'platform' | 'log' | 'config' | 'gateway'
|
||||
label: string
|
||||
detail: string
|
||||
href: string | null
|
||||
@@ -78,7 +79,7 @@ export type DashboardStatusSection = {
|
||||
activeSessions: number
|
||||
/**
|
||||
* Canonical "currently running" number from gateway runtime status
|
||||
* (`/health/detailed` -> `active_agents`). Falls back to legacy
|
||||
* (`/health/detailed` -> `active_agents`). Falls back to legacy
|
||||
* `active_sessions` when `/health/detailed` is unreachable.
|
||||
*/
|
||||
activeAgents: number
|
||||
@@ -120,6 +121,22 @@ export type DashboardCronSection = {
|
||||
}>
|
||||
}
|
||||
|
||||
export type DashboardKanbanSection = {
|
||||
total: number
|
||||
triage: number
|
||||
todo: number
|
||||
ready: number
|
||||
running: number
|
||||
blocked: number
|
||||
done: number
|
||||
other: number
|
||||
topBlocked: Array<{
|
||||
id: string
|
||||
title: string
|
||||
assignee: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export type DashboardAchievementUnlock = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -349,7 +366,7 @@ function normalizeCron(raw: unknown): DashboardCronSection | null {
|
||||
typeof j.last_error === 'string'
|
||||
? j.last_error
|
||||
: typeof j.last_delivery_error === 'string'
|
||||
? (j.last_delivery_error as string)
|
||||
? (j.last_delivery_error)
|
||||
: null
|
||||
const isFailure =
|
||||
lastStatus === 'failed' ||
|
||||
@@ -367,9 +384,9 @@ function normalizeCron(raw: unknown): DashboardCronSection | null {
|
||||
typeof j.next_run_at === 'string' ? Date.parse(j.next_run_at) : NaN,
|
||||
typeof j.next_run === 'string' ? Date.parse(j.next_run) : NaN,
|
||||
typeof j.next_run_at === 'number'
|
||||
? (j.next_run_at as number) * 1000
|
||||
? (j.next_run_at) * 1000
|
||||
: NaN,
|
||||
].filter((v) => Number.isFinite(v)) as Array<number>
|
||||
].filter((v) => Number.isFinite(v))
|
||||
for (const ts of candidates) {
|
||||
if (nextRunMs === null || ts < nextRunMs) nextRunMs = ts
|
||||
}
|
||||
@@ -384,6 +401,62 @@ function normalizeCron(raw: unknown): DashboardCronSection | null {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKanban(raw: unknown): DashboardKanbanSection | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const columnsRaw = Array.isArray(r.columns) ? r.columns : null
|
||||
if (!columnsRaw) return null
|
||||
|
||||
const out: DashboardKanbanSection = {
|
||||
total: 0,
|
||||
triage: 0,
|
||||
todo: 0,
|
||||
ready: 0,
|
||||
running: 0,
|
||||
blocked: 0,
|
||||
done: 0,
|
||||
other: 0,
|
||||
topBlocked: [],
|
||||
}
|
||||
|
||||
const bucketFor = (status: string): keyof Pick<
|
||||
DashboardKanbanSection,
|
||||
'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'other'
|
||||
> => {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'triage') return 'triage'
|
||||
if (s === 'todo' || s === 'queued') return 'todo'
|
||||
if (s === 'ready') return 'ready'
|
||||
if (s === 'running' || s === 'claimed' || s === 'in_progress') return 'running'
|
||||
if (s === 'blocked') return 'blocked'
|
||||
if (s === 'done' || s === 'completed' || s === 'complete') return 'done'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
for (const column of columnsRaw) {
|
||||
if (!column || typeof column !== 'object') continue
|
||||
const c = column as Record<string, unknown>
|
||||
const columnName = readString(c.name || c.id || c.status)
|
||||
const tasks = Array.isArray(c.tasks) ? c.tasks : []
|
||||
for (const task of tasks) {
|
||||
if (!task || typeof task !== 'object') continue
|
||||
const t = task as Record<string, unknown>
|
||||
const bucket = bucketFor(readString(t.status) || columnName)
|
||||
out.total += 1
|
||||
out[bucket] += 1
|
||||
if (bucket === 'blocked' && out.topBlocked.length < 5) {
|
||||
out.topBlocked.push({
|
||||
id: readString(t.id) || 'unknown',
|
||||
title: readString(t.title) || readString(t.name) || 'Untitled task',
|
||||
assignee: readOptionalString(t.assignee),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeAchievementUnlock(
|
||||
raw: unknown,
|
||||
): DashboardAchievementUnlock | null {
|
||||
@@ -400,7 +473,7 @@ function normalizeAchievementUnlock(
|
||||
icon: readString(r.icon) || 'Star',
|
||||
tier: typeof r.tier === 'string' ? r.tier : null,
|
||||
unlockedAt:
|
||||
typeof r.unlocked_at === 'number' ? (r.unlocked_at as number) : null,
|
||||
typeof r.unlocked_at === 'number' ? (r.unlocked_at) : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +548,7 @@ function normalizeSkillsUsage(
|
||||
percentage: readNumber(e.percentage),
|
||||
lastUsedAt:
|
||||
typeof e.last_used_at === 'number'
|
||||
? (e.last_used_at as number)
|
||||
? (e.last_used_at)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
@@ -718,7 +791,7 @@ function formatTokensCompact(n: number): string {
|
||||
*/
|
||||
function shortSkillName(raw: string): string {
|
||||
if (!raw) return raw
|
||||
const segments = raw.split(/[:\/]/)
|
||||
const segments = raw.split(/[:/]/)
|
||||
return segments[segments.length - 1] || raw
|
||||
}
|
||||
|
||||
@@ -743,6 +816,7 @@ function computeInsights(
|
||||
cron: DashboardCronSection | null,
|
||||
status: DashboardStatusSection | null,
|
||||
skills: DashboardSkillsUsageSection | null,
|
||||
kanban: DashboardKanbanSection | null,
|
||||
): Array<DashboardInsight> {
|
||||
const out: Array<DashboardInsight> = []
|
||||
if (!analytics || analytics.source !== 'analytics') return out
|
||||
@@ -761,8 +835,10 @@ function computeInsights(
|
||||
}
|
||||
}
|
||||
if (peakVal > 0) {
|
||||
const top = analytics.topModels[0]
|
||||
const driver = top ? `, driven by ${shortModelName(top.id)}` : ''
|
||||
const driver =
|
||||
analytics.topModels.length > 0
|
||||
? `, driven by ${shortModelName(analytics.topModels[0].id)}`
|
||||
: ''
|
||||
const peakDay = analytics.daily[peakIdx].day
|
||||
const todayIso = new Date().toISOString().slice(0, 10)
|
||||
peakIsToday = peakDay === todayIso
|
||||
@@ -818,6 +894,11 @@ function computeInsights(
|
||||
ops.push('no active runs')
|
||||
}
|
||||
if (status?.restartRequested) ops.push('restart pending')
|
||||
if (kanban && kanban.blocked > 0) {
|
||||
ops.push(
|
||||
`${kanban.blocked} blocked kanban task${kanban.blocked === 1 ? '' : 's'}`,
|
||||
)
|
||||
}
|
||||
if (ops.length > 0) {
|
||||
out.push({
|
||||
tone: ops.length >= 2 ? 'warn' : 'info',
|
||||
@@ -845,6 +926,7 @@ function computeIncidents(
|
||||
platforms: Array<DashboardPlatformEntry>,
|
||||
cron: DashboardCronSection | null,
|
||||
logs: DashboardLogsSection | null,
|
||||
kanban: DashboardKanbanSection | null,
|
||||
): Array<DashboardIncident> {
|
||||
const out: Array<DashboardIncident> = []
|
||||
// Cron failures
|
||||
@@ -887,6 +969,17 @@ function computeIncidents(
|
||||
})
|
||||
}
|
||||
}
|
||||
// Kanban blockers
|
||||
if (kanban && kanban.blocked > 0) {
|
||||
out.push({
|
||||
id: 'kanban-blocked',
|
||||
severity: 'warn',
|
||||
source: 'kanban',
|
||||
label: `${kanban.blocked} kanban task${kanban.blocked === 1 ? '' : 's'} blocked`,
|
||||
detail: kanban.topBlocked.map((t) => t.title).join(' · ') || 'blocked cards need attention',
|
||||
href: '/swarm2',
|
||||
})
|
||||
}
|
||||
// Platform errors
|
||||
for (const p of platforms) {
|
||||
const s = p.state.toLowerCase()
|
||||
@@ -1008,6 +1101,7 @@ export async function buildDashboardOverview(
|
||||
achAllRaw,
|
||||
modelInfoRaw,
|
||||
analyticsRaw,
|
||||
kanbanRaw,
|
||||
logsRaw,
|
||||
] = await Promise.all([
|
||||
safeJson<unknown>(fetcher, '/api/status'),
|
||||
@@ -1025,6 +1119,7 @@ export async function buildDashboardOverview(
|
||||
fetcher,
|
||||
`/api/analytics/usage?days=${analyticsWindowDays}`,
|
||||
),
|
||||
safeJson<unknown>(fetcher, '/api/plugins/kanban/board'),
|
||||
safeJson<unknown>(fetcher, `/api/logs?lines=${logsLimit}`),
|
||||
])
|
||||
|
||||
@@ -1032,15 +1127,17 @@ export async function buildDashboardOverview(
|
||||
const platforms = normalizePlatforms(statusRaw)
|
||||
const cron = normalizeCron(cronRaw)
|
||||
const analytics = normalizeAnalytics(analyticsRaw, analyticsWindowDays)
|
||||
const kanban = normalizeKanban(kanbanRaw)
|
||||
const logs = normalizeLogs(logsRaw, logsLimit)
|
||||
const skillsUsage = normalizeSkillsUsage(analyticsRaw)
|
||||
const insights = computeInsights(analytics, cron, status, skillsUsage)
|
||||
const incidents = computeIncidents(status, platforms, cron, logs)
|
||||
const insights = computeInsights(analytics, cron, status, skillsUsage, kanban)
|
||||
const incidents = computeIncidents(status, platforms, cron, logs, kanban)
|
||||
|
||||
return {
|
||||
status,
|
||||
platforms,
|
||||
cron,
|
||||
kanban,
|
||||
achievements: normalizeAchievements(
|
||||
achRecentRaw,
|
||||
achAllRaw,
|
||||
|
||||
@@ -635,14 +635,18 @@ const config = defineConfig(({ mode, command }) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-start hermes-agent when dev server launches
|
||||
if (command === 'serve') {
|
||||
// Auto-start hermes-agent when dev server launches.
|
||||
// Skip when launchd manages the gateway (HERMES_WORKSPACE_AUTO_START_AGENT=false)
|
||||
// to avoid SIGTERM cycle on close that nukes the launchd-managed process.
|
||||
const autoStartAgent =
|
||||
process.env.HERMES_WORKSPACE_AUTO_START_AGENT !== 'false'
|
||||
if (command === 'serve' && autoStartAgent) {
|
||||
void startClaudeAgent()
|
||||
}
|
||||
|
||||
// Shutdown hermes-agent when dev server stops
|
||||
// Shutdown hermes-agent when dev server stops — only if we spawned it.
|
||||
server.httpServer?.on('close', () => {
|
||||
if (claudeAgentChild) {
|
||||
if (claudeAgentChild && autoStartAgent) {
|
||||
console.log('[hermes-agent] Stopping...')
|
||||
claudeAgentChild.kill('SIGTERM')
|
||||
claudeAgentChild = null
|
||||
|
||||
Reference in New Issue
Block a user