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
153 changes: 126 additions & 27 deletions ui/src/api/hooks/useBoard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,27 +204,120 @@ export interface CreateBoardBody {
icon?: string
}

// ── Wire-to-normalised task transform ─────────────────────────────────
// ── Wire-shape helpers ────────────────────────────────────────────────
//
// The server may send either camelCase or snake_case for legacy compat;
// normalise everything to camelCase for the UI.
// The Hermes kanban plugin speaks snake_case and returns TWO shapes:
// • GET /board embeds a FLAT task row in each column.
// • GET /tasks/{id} WRAPS the row in an envelope:
// {task, comments, events, attachments, links, runs}
// Timestamps are unix-epoch SECONDS; event/run/comment items use their own
// field names (created_at, payload, outcome, summary…) that differ from what
// the drawer renders (at, json, state, msg). These helpers bridge both wire
// shapes — plus the already-normalised camelCase objects the optimistic-update
// path feeds back through normaliseTask — into the UI's camelCase contract.

const _isObj = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === 'object' && !Array.isArray(v)

/** Unix-epoch (sec or ms) or pre-formatted string → short "20m ago" label. */
function relTime(v: unknown): string | undefined {
if (v == null || v === '') return undefined
if (typeof v === 'string') return v // already a display string / ISO
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined
const ms = v < 1e12 ? v * 1000 : v // < 1e12 ⇒ epoch seconds
const delta = Date.now() - ms
if (delta < 0) return 'just now'
const s = Math.floor(delta / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}

/** started/ended epoch seconds → "1m 57s" duration (open runs measure to now). */
function durLabel(start: unknown, end: unknown): string | undefined {
if (typeof start !== 'number') return undefined
const endS = typeof end === 'number' ? end : Math.floor(Date.now() / 1000)
let s = Math.max(0, Math.floor(endS - start))
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
s = s % 60
if (m < 60) return s ? `${m}m ${s}s` : `${m}m`
const h = Math.floor(m / 60)
return `${h}h ${m % 60}m`
}

function normaliseComment(c: Record<string, unknown>): TaskComment {
return {
author: String(c.author ?? c.created_by ?? ''),
at: (c.at as string) ?? relTime(c.created_at) ?? '',
body: String(c.body ?? ''),
}
}

function normaliseEvent(e: Record<string, unknown>): TaskEvent {
const payload = e.payload
return {
kind: String(e.kind ?? ''),
at: (e.at as string) ?? relTime(e.created_at) ?? '',
json:
(e.json as string) ??
(payload != null ? JSON.stringify(payload) : undefined),
}
}

function normaliseRun(r: Record<string, unknown>): TaskRun {
// Upstream carries both status ("running"/"done") and outcome ("completed").
// The drawer keys its row colour off state ∈ {active, completed, review}.
const rawState = String(r.state ?? r.outcome ?? r.status ?? '')
return {
state: rawState === 'running' ? 'active' : rawState,
profile: String(r.profile ?? r.assignee ?? ''),
dur: (r.dur as string) ?? durLabel(r.started_at, r.ended_at) ?? '',
at: (r.at as string) ?? relTime(r.ended_at ?? r.started_at) ?? '',
msg: String(r.msg ?? r.summary ?? ''),
}
}

// ── Wire-to-normalised task transform ─────────────────────────────────

function normaliseTask(raw: Record<string, unknown>): BoardTask {
// Unwrap the /tasks/{id} envelope: read scalars off the inner row, but pull
// collections (comments/events/runs) and links off the envelope top level.
// GET /board rows and optimistic-update objects are flat, so fall back to the
// row itself for every field.
const env = raw
const t = _isObj(raw.task) ? (raw.task as Record<string, unknown>) : raw

const assignee =
(raw.assignee ?? raw.profile ?? null) as string | null
(t.assignee ?? t.profile ?? null) as string | null
const createdBy =
(raw.created_by ?? raw.createdBy ?? null) as string | null
(t.created_by ?? t.createdBy ?? null) as string | null
const blockReason =
(raw.block_reason ?? raw.blockReason ?? null) as string | null
const body = (raw.body ?? raw.desc ?? null) as string | null
(t.block_reason ?? t.blockReason ?? null) as string | null
const body = (t.body ?? t.desc ?? null) as string | null

const pickArr = (k: string): Record<string, unknown>[] =>
Array.isArray(env[k])
? (env[k] as Record<string, unknown>[])
: Array.isArray(t[k])
? (t[k] as Record<string, unknown>[])
: []
const rawComments = pickArr('comments')
const rawEvents = pickArr('events')
const rawRuns = pickArr('runs')

const commentCount =
typeof (raw.comment_count ?? raw.commentCount) === 'number'
? (raw.comment_count ?? raw.commentCount) as number
: 0
typeof (t.comment_count ?? t.commentCount) === 'number'
? ((t.comment_count ?? t.commentCount) as number)
: rawComments.length
const depCount =
(raw.dep_count ?? raw.depCount ?? null) as string | null
(t.dep_count ?? t.depCount ?? null) as string | null

const rawDeps = (raw.deps ?? {}) as {
// deps: normalised {parents,children}, or the upstream `links` (same shape).
const rawDeps = (env.deps ?? env.links ?? t.deps ?? t.links ?? {}) as {
parents?: string[]
children?: string[]
}
Expand All @@ -234,25 +327,23 @@ function normaliseTask(raw: Record<string, unknown>): BoardTask {
}

return {
id: String(raw.id ?? ''),
title: String(raw.title ?? ''),
status: (raw.status ?? 'triage') as TaskStatus,
id: String(t.id ?? ''),
title: String(t.title ?? ''),
status: (t.status ?? 'triage') as TaskStatus,
assignee,
tenant: raw.tenant as string | undefined,
priority: raw.priority as number | undefined,
workspace: raw.workspace as string | undefined,
tenant: (t.tenant ?? undefined) as string | undefined,
priority: t.priority as number | undefined,
workspace: (t.workspace ?? t.workspace_path) as string | undefined,
createdBy,
created: raw.created as string | undefined,
created: (t.created as string | undefined) ?? relTime(t.created_at),
body,
blockReason,
schedule: raw.schedule as string | undefined,
summary: raw.summary as string | undefined,
schedule: t.schedule as string | undefined,
summary: (t.summary ?? t.latest_summary) as string | undefined,
deps,
comments: Array.isArray(raw.comments)
? (raw.comments as TaskComment[])
: [],
events: Array.isArray(raw.events) ? (raw.events as TaskEvent[]) : [],
runs: Array.isArray(raw.runs) ? (raw.runs as TaskRun[]) : [],
comments: rawComments.map(normaliseComment),
events: rawEvents.map(normaliseEvent),
runs: rawRuns.map(normaliseRun),
commentCount,
depCount,
}
Expand Down Expand Up @@ -479,11 +570,19 @@ export function useBoardTaskLog(
queryFn: async () => {
const qs = tail != null ? `?tail=${tail}` : ''
const raw = await apiGet<
TaskLogEntry[] | { entries: TaskLogEntry[] }
TaskLogEntry[] | { entries?: TaskLogEntry[]; content?: string }
>(`${ENDPOINTS.boardTaskLog(id)}${qs}`)
if (Array.isArray(raw)) return raw
if (raw && Array.isArray((raw as { entries: TaskLogEntry[] }).entries))
return (raw as { entries: TaskLogEntry[] }).entries
// Hermes kanban returns {task_id, path, exists, size_bytes, content}
// where `content` is the raw log text — split into per-line entries so
// the drawer (which joins e.line with "\n") renders it.
const content = (raw as { content?: unknown })?.content
if (typeof content === 'string')
return content.length
? content.split('\n').map((line) => ({ line }))
: []
return []
},
enabled: !!id,
Expand Down
2 changes: 1 addition & 1 deletion ui/src/dash/board/task-drawer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ function TaskDrawer({ task, byId, onClose, onOpenTask }) {
{/* description + block reason */}
<div className="dr-sec">
<div className="dr-sec-h"><h4>description</h4></div>
<div className="dr-desc">{t.desc}</div>
<div className="dr-desc">{t.body ?? t.desc}</div>
{t.blockReason && (
<div className="dr-block">
<div className="bl">block reason</div>
Expand Down
80 changes: 80 additions & 0 deletions ui/tests/e2e/specs/board-drawer-v3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,83 @@ test.describe('TaskDrawer — per-status contract', () => {
})

})

// ── Real Hermes kanban wire shape ───────────────────────────────────────
//
// Regression guard for the "blank drawer" bug: GET /tasks/{id} returns an
// ENVELOPE ({task, comments, events, links, runs}) with snake_case item
// fields (created_at/payload/outcome/summary) and a {content} log — NOT the
// flat camelCase shape the other fixtures mock. normaliseTask must unwrap the
// envelope and remap the item fields, otherwise every scalar falls to its
// default (status→"triage", desc/assignee blank) and events/runs/log render
// empty. See useBoard.ts normaliseTask/normaliseEvent/normaliseRun.

test.describe('TaskDrawer — Hermes envelope wire shape', () => {
test('done task detail: envelope unwrapped → desc, event payload, run state, log all render', async ({ page }) => {
const id = 't_865ad502'
const nowS = Math.floor(Date.now() / 1000)

// GET /tasks/:id → the real wrapped envelope (snake_case throughout).
await page.route(/\/api\/board\/tasks\/[^/]+$/, async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await json(route, {
task: {
id,
title: 'Audit container image digests',
body: 'Check all running containers have pinned digests in slot TOML files.',
assignee: 'admin-agent',
status: 'done',
priority: 2,
created_by: 'operator',
created_at: nowS - 120,
workspace_kind: 'scratch',
workspace_path: '/var/lib/hal0/.hermes/kanban/workspaces/' + id,
tenant: 'network',
},
comments: [{ id: 5, author: 'operator', body: 'looks good', created_at: nowS - 60 }],
events: [
{ id: 88, kind: 'completed', payload: { result_len: 0, summary: 'all 6 verified' }, created_at: nowS - 30, run_id: 8 },
{ id: 87, kind: 'created', payload: { assignee: 'admin-agent', status: 'ready' }, created_at: nowS - 120, run_id: null },
],
attachments: [],
links: { parents: [], children: [] },
runs: [{ id: 8, profile: 'admin-agent', status: 'done', outcome: 'completed', started_at: nowS - 120, ended_at: nowS - 30, summary: 'all 6 containers verified' }],
})
})

// GET /tasks/:id/log → the {content} shape (raw log text, not an array).
await page.route(/\/api\/board\/tasks\/[^/]+\/log/, async (route) => {
await json(route, {
task_id: id,
path: '/var/lib/hal0/.hermes/kanban/logs/' + id + '.log',
exists: true,
size_bytes: 42,
content: 'Query: work kanban task ' + id + '\nBENCH RESULT 42 tok/s',
})
})

await gotoBoardAndWait(page)
await openTask(page, id)

const drawer = page.locator('[data-testid="board-task-drawer"]')

// Scalars off the inner `task` — proves the envelope was unwrapped
// (pre-fix these were blank / status defaulted to "triage").
await expect(drawer.locator('.dr-desc')).toContainText('pinned digests')
await expect(drawer.locator('.dr-meta')).toContainText('@admin-agent')

// Event payload rendered (payload → json) with a real timestamp (created_at → at).
const events = page.locator('[data-testid="board-events"]')
await expect(events).toContainText('completed')
await expect(events).toContainText('result_len')
await expect(events).toContainText('ago')

// Run row: outcome → state ("completed"), summary → msg.
const runs = page.locator('[data-testid="board-runs"]')
await expect(runs).toContainText('completed')
await expect(runs).toContainText('all 6 containers verified')

// Worker log: {content} split into lines and rendered.
await expect(page.locator('[data-testid="board-worklog"]')).toContainText('BENCH RESULT 42 tok/s')
})
})