feat(hub): Version + Build-Commit im Header und in /health (v0.11.0)

Zwei Maschinen liessen sich nicht auseinanderhalten. Der Watchdog-Fix von
6fa9411 ging ohne Versions-Bump raus, also meldeten beide Hubs 0.10.2,
waehrend nur einer den Fix hatte. Zusaetzlich faehrt der Hub aus dist/ —
ein Neustart ohne Build zieht still den alten Code wieder hoch. Beides
zusammen hat heute eine Diagnose in die falsche Richtung geschickt.

- Version auf 0.11.0 (Feature + Fix seit 0.10.2).
- version.ts liefert zusaetzlich buildRef(): den kurzen Commit, aus dem der
  laufende Code stammt. Bewusst LAZY und gecacht — VERSION wird von jedem
  CLI-Aufruf importiert, ein git-Aufruf beim Import wuerde Befehle
  verteuern, die ihn nie anzeigen. Bei Nicht-git-Installs faellt
  versionLabel() sauber auf die blosse Version zurueck.
- Board-Header (appHeader) und der aeltere pageHeader zeigen
  "v0.11.0 · <commit>", mit Tooltip warum die Nummer allein nicht reicht.
- /health liefert buildRef, damit sich zwei Maschinen ueber die Leitung
  vergleichen lassen, ohne das Board zu oeffnen.

318 Tests gruen, tsc --noEmit sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-08-09 16:59:34 +02:00
parent 6fa94116a4
commit b8ad801c1d
6 changed files with 102 additions and 3 deletions

View File

@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "0.10.2",
"version": "0.11.0",
"description": "Local coordination layer for AI coding agents",
"type": "module",
"main": "./dist/index.js",

View File

@ -2,7 +2,7 @@ import { listTasks } from './taskService.js';
import { listMessages } from './messageService.js';
import { listAsks } from './askService.js';
import { getRoster } from './rosterService.js';
import { VERSION } from '../../version.js';
import { VERSION, buildRef } from '../../version.js';
/**
* Per-agent presence (lastSeen) + the /health report (TSK-0226).
@ -73,6 +73,8 @@ export interface AgentHealth {
export interface HealthReport {
status: 'ok';
version: string;
/** Short commit this hub runs from — '' for non-git installs. Compares machines. */
buildRef: string;
startedAt: string;
uptimeSec: number;
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number; pendingAsks: number; oldestAskAgeSec?: number };
@ -199,6 +201,7 @@ export function computeHealth(cwd: string, startedAtMs: number, now: number = Da
return {
status: 'ok',
version: VERSION,
buildRef: buildRef(),
startedAt: new Date(startedAtMs).toISOString(),
uptimeSec: Math.max(0, Math.round((now - startedAtMs) / 1000)),
counts: {

View File

@ -8,6 +8,7 @@
*/
import { TaskStatus as TaskStatusSchema } from '../core/schema.js';
import { versionLabel } from '../version.js';
type TaskStatus = 'open' | 'in_progress' | 'review' | 'done' | 'cancelled';
/** CSS variables block matching the AgentHub dark design spec. */
@ -272,6 +273,7 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
${mark}
<h1 style="font-size:18px;margin:0;font-weight:600;">AgentHub</h1>
<span style="color:var(--muted);font-size:12px;">${escapeHtml(projectName)}</span>
<span style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:2px 6px;cursor:help;" title="Version und Commit dieses Hubs — zum Abgleich zwischen Maschinen.">${escapeHtml(versionLabel())}</span>
<span style="flex:1;"></span>
<nav style="display:flex;gap:8px;align-items:center;">
${navItem('Board', '/board', current === 'board')}
@ -313,6 +315,13 @@ export function appHeaderOnlyCss(): string {
border-bottom: 1px solid rgba(255,255,255,.06);
background: rgba(9, 12, 24, .82); backdrop-filter: blur(12px);
}
.app-header .b2-build {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; color: var(--b2-muted, #7d8aa5);
padding: 3px 7px; border-radius: 5px;
border: 1px solid var(--b2-border, rgba(255,255,255,.08));
white-space: nowrap; cursor: help;
}
.app-header .b2-brand { display: flex; align-items: center; gap: 10px; font-size: 16px; }
.app-header .b2-brand b { font-weight: 700; letter-spacing: .01em; }
.b2-logo-img { display: block; }
@ -425,6 +434,7 @@ export function appHeader(projectName: string, current: HeaderPage): string {
${link('Decisions', '/decisions', 'decisions')}
</nav>
<span class="b2-hdr-right">
<span class="b2-build" title="Version und Commit, aus dem dieser Hub laeuft — zum Abgleich zwischen Maschinen. Die Versionsnummer allein reicht nicht: ein Fix kann ohne Bump ausgeliefert werden.">${escapeHtml(versionLabel())}</span>
<button class="b2-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">+ New task</button>
<span class="sse-status stale" id="sseStatus">
<span class="b2-live-dot" aria-hidden="true"></span>

View File

@ -1,2 +1,64 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, parse } from 'node:path';
/** Single source of truth for the agenthub version (package.json, CLI, MCP, /health). */
export const VERSION = '0.10.2';
export const VERSION = '0.11.0';
/** Locate the agenthub install dir by walking up from this file. */
function findPackageRoot(): string | undefined {
let dir = dirname(fileURLToPath(import.meta.url));
const { root } = parse(dir);
for (;;) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { name?: string };
if (pkg.name === 'agenthub') return dir;
} catch {
/* malformed package.json — keep walking */
}
}
if (dir === root) return undefined;
dir = dirname(dir);
}
}
/** Memoised so repeated header renders never re-shell out. `''` once resolved empty. */
let cachedBuildRef: string | undefined;
/**
* Short commit the running code was built from `''` for non-git installs.
*
* The version alone cannot tell two machines apart: a fix can ship without a
* version bump, and then both hubs report the same number while running
* different code. That happened on 2026-08-09 and sent a diagnosis down the
* wrong path, so every surface that shows the version shows this next to it.
*
* Lazy on purpose: `VERSION` is imported by every CLI invocation, and shelling
* out to git at import time would tax commands that never display it.
*/
export function buildRef(): string {
if (cachedBuildRef !== undefined) return cachedBuildRef;
cachedBuildRef = '';
try {
const root = findPackageRoot();
if (root) {
cachedBuildRef = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: root,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
}
} catch {
/* not a git checkout, or git unavailable — the version has to stand alone */
}
return cachedBuildRef;
}
/** `v0.11.0 · 6fa9411`, or just `v0.11.0` when the commit is unknown. */
export function versionLabel(): string {
const ref = buildRef();
return ref ? `v${VERSION} · ${ref}` : `v${VERSION}`;
}

View File

@ -44,6 +44,10 @@ describe('GET /health + lastSeen stamping (TSK-0226)', () => {
const h = await getHealth();
expect(h.status).toBe('ok');
expect(h.version).toBe(VERSION);
// The version alone cannot tell two hubs apart — a fix can ship without a
// bump. /health therefore carries the commit, so machines are comparable
// over the wire without opening the board.
expect(typeof h.buildRef).toBe('string');
expect(typeof h.uptimeSec).toBe('number');
expect(h.uptimeSec).toBeGreaterThanOrEqual(0);
expect(typeof h.startedAt).toBe('string');

View File

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { agentAvatar, designTokensCss, escapeHtml, liveTimerJs, statusPill, appHeader, appHeaderCss, appHeaderOnlyCss } from '../src/server/ui-shared.js';
import { VERSION, buildRef, versionLabel } from '../src/version.js';
describe('ui-shared helpers', () => {
it('designTokensCss returns the dark palette variables', () => {
@ -76,6 +77,25 @@ describe('appHeader (v2 shared header)', () => {
expect(appHeaderOnlyCss()).toContain('.app-header');
expect(appHeaderCss()).toContain('.modal-backdrop');
});
it('shows version + build commit in the header so two machines can be compared', () => {
// On 2026-08-09 a fix shipped without a version bump: both hubs reported
// 0.10.2 while running different code, and the mismatch was invisible.
// The header therefore carries the commit next to the version.
const html = appHeader('proj', 'board');
expect(html).toContain(versionLabel());
expect(html).toContain(`v${VERSION}`);
expect(appHeaderOnlyCss()).toContain('.app-header .b2-build');
});
it('versionLabel falls back to the bare version when the commit is unknown', () => {
// Non-git installs must still render a header — never an empty badge.
const label = versionLabel();
expect(label.startsWith(`v${VERSION}`)).toBe(true);
const ref = buildRef();
if (ref) expect(label).toBe(`v${VERSION} · ${ref}`);
else expect(label).toBe(`v${VERSION}`);
});
it('makes the mobile nav horizontally scrollable with 44px tap targets', () => {
const css = appHeaderOnlyCss();
expect(css).toMatch(/@media \(max-width: 680px\)[\s\S]*\.app-header \.b2-nav \{[^}]*overflow-x: auto/);