From 16428b504d21e1c983c51ead3fb3c042af3487e6 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 3 Jul 2026 05:31:16 -0400 Subject: [PATCH] fix(board): unwrap Hermes kanban task-detail envelope so the drawer renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator-board task drawer showed a blank/mis-populated detail view (status defaulting to "triage", no description/assignee, event rows with no timestamp or payload, empty worker log, bare run rows) while the Hermes dashboard showed the same task in full. Root cause is a wire-shape mismatch in the UI normaliser, not the proxy (hal0-api forwards /tasks/{id} verbatim). The Hermes kanban plugin returns TWO shapes: GET /board embeds a flat task row per column, but GET /tasks/{id} WRAPS the row in an envelope `{task, comments, events, attachments, links, runs}` with snake_case item fields (created_at/payload/outcome/summary) and epoch-second timestamps. normaliseTask() read scalars off the top level, so every field under `.task` fell to its default and only the top-level arrays survived — with the wrong per-item field names. - normaliseTask: unwrap `raw.task` for scalars; pull comments/events/runs and `links`→deps off the envelope (falling back to the flat row + already- normalised camelCase objects the optimistic-update path re-feeds). - Add normaliseEvent/normaliseRun/normaliseComment + relTime/durLabel helpers: created_at→at (as "20m ago"), payload→json, outcome/status→state, started/ended→dur, summary→msg, workspace_path→workspace, created_at→created. - useBoardTaskLog: accept the `{content: ""}` log shape (split into per-line entries), not just array/`{entries}` — this is why the worker log was always "no worker log yet". - task-drawer: read `t.body` (the normaliser never emitted `t.desc`). - e2e: add a regression test that drives the drawer with the REAL envelope wire shape (the existing fixtures encode the flat shape the plugin never emits, which is why CI stayed green while production was blank). Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/src/api/hooks/useBoard.ts | 153 +++++++++++++++++---- ui/src/dash/board/task-drawer.jsx | 2 +- ui/tests/e2e/specs/board-drawer-v3.spec.ts | 80 +++++++++++ 3 files changed, 207 insertions(+), 28 deletions(-) diff --git a/ui/src/api/hooks/useBoard.ts b/ui/src/api/hooks/useBoard.ts index 6a07e2d7..a2474433 100644 --- a/ui/src/api/hooks/useBoard.ts +++ b/ui/src/api/hooks/useBoard.ts @@ -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 => + !!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): 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): 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): 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): 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) : 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[] => + Array.isArray(env[k]) + ? (env[k] as Record[]) + : Array.isArray(t[k]) + ? (t[k] as Record[]) + : [] + 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[] } @@ -234,25 +327,23 @@ function normaliseTask(raw: Record): 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, } @@ -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, diff --git a/ui/src/dash/board/task-drawer.jsx b/ui/src/dash/board/task-drawer.jsx index 878e796d..feaba683 100644 --- a/ui/src/dash/board/task-drawer.jsx +++ b/ui/src/dash/board/task-drawer.jsx @@ -219,7 +219,7 @@ function TaskDrawer({ task, byId, onClose, onOpenTask }) { {/* description + block reason */}

description

-
{t.desc}
+
{t.body ?? t.desc}
{t.blockReason && (
block reason
diff --git a/ui/tests/e2e/specs/board-drawer-v3.spec.ts b/ui/tests/e2e/specs/board-drawer-v3.spec.ts index 2c599abb..d5167c71 100644 --- a/ui/tests/e2e/specs/board-drawer-v3.spec.ts +++ b/ui/tests/e2e/specs/board-drawer-v3.spec.ts @@ -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') + }) +})