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:
Aurora
2026-06-05 03:55:58 -04:00
parent 58b5cba680
commit 611359b943
6 changed files with 231 additions and 25 deletions

View File

@@ -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

View File

@@ -1032,6 +1032,7 @@ export function DashboardScreen() {
<OpsStrip
status={overview?.status ?? null}
cron={overview?.cron ?? null}
kanban={overview?.kanban ?? null}
platforms={overview?.platforms ?? []}
/>

View File

@@ -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)' }}>

View File

@@ -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()

View File

@@ -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,

View File

@@ -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