feat(mission-control): add live harness progress status panel

This commit is contained in:
Paul Huliganga 2026-03-25 00:46:21 -04:00
parent feb10fdb8b
commit 272ce1d2f0
8 changed files with 292 additions and 8 deletions

3
.gitignore vendored
View File

@ -54,3 +54,6 @@ temp/
# Backup files # Backup files
*.bak *.bak
*~ *~
# Harness runtime state
.harness/*.json

View File

@ -1,7 +0,0 @@
{
"checkedAt": "2026-03-25T03:35:36.708248Z",
"status": "STALE",
"sessionLabel": "recipe-v1-iter30",
"sessionUpdatedAt": "2026-03-25T03:24:43.509000Z",
"ageSeconds": 653
}

View File

@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { fetchHarnessStatus } from '../services/api';
import type { HarnessStatus } from '../types/recipe';
function getStatusPillClass(status: string | undefined): string {
switch (status) {
case 'HEALTHY':
return 'bg-green-100 text-green-800 border-green-200';
case 'IDLE':
return 'bg-gray-100 text-gray-700 border-gray-200';
case 'STALE':
case 'MISSING':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
}
}
export function MissionControlPanel() {
const [status, setStatus] = useState<HarnessStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const data = await fetchHarnessStatus();
if (!cancelled) {
setStatus(data);
setError(null);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load mission control status');
setLoading(false);
}
}
};
load();
const interval = setInterval(load, 15000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
if (loading) {
return (
<div className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500">Mission Control: loading status</p>
</div>
);
}
if (error || !status) {
return (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-800">Mission Control unavailable: {error ?? 'unknown error'}</p>
</div>
);
}
return (
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-blue-900">Mission Control Harness Progress</h3>
<span className={`rounded-full border px-2 py-0.5 text-xs font-medium ${getStatusPillClass(status.keepalive.status)}`}>
{status.keepalive.status ?? 'UNKNOWN'}
</span>
</div>
<div className="grid gap-2 text-sm text-gray-800 md:grid-cols-2">
<p>
<span className="font-medium">Last commit:</span>{' '}
{status.commit ? `${status.commit.hash} (${status.commit.relative})` : 'N/A'}
</p>
<p>
<span className="font-medium">Iteration:</span>{' '}
{status.keepalive.activeSessionLabel ?? 'none'}
</p>
<p>
<span className="font-medium">v1 tasks:</span>{' '}
{status.todo.checked} done / {status.todo.unchecked} remaining
</p>
<p>
<span className="font-medium">Heartbeat age:</span>{' '}
{status.keepalive.heartbeatAgeSeconds != null ? `${status.keepalive.heartbeatAgeSeconds}s` : 'n/a'}
</p>
</div>
<p className="mt-2 text-sm text-gray-700">
<span className="font-medium">Next task:</span>{' '}
{status.todo.nextTask ?? 'No unchecked v1 tasks'}
</p>
{status.workerHeartbeatHistory.length > 0 && (
<div className="mt-3">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-gray-600">Last 5 heartbeats</p>
<ul className="space-y-1 text-xs text-gray-700">
{status.workerHeartbeatHistory.map((entry, index) => (
<li key={`${entry.timestamp ?? 'heartbeat'}-${index}`}>
{entry.timestamp ?? 'unknown time'} {entry.step ?? 'step n/a'} ({entry.status ?? 'status n/a'})
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
import { useRecipes } from '../hooks/useRecipes'; import { useRecipes } from '../hooks/useRecipes';
import { useTags } from '../hooks/useTags'; import { useTags } from '../hooks/useTags';
import { RecipeCard } from '../components/RecipeCard'; import { RecipeCard } from '../components/RecipeCard';
import { MissionControlPanel } from '../components/MissionControlPanel';
export function RecipeListPage() { export function RecipeListPage() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@ -46,6 +47,8 @@ export function RecipeListPage() {
return ( return (
<div> <div>
<MissionControlPanel />
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">

View File

@ -2,7 +2,7 @@
* API client for Recipe Manager backend * API client for Recipe Manager backend
*/ */
import type { Recipe, RecipeDraft, Tag, ApiResponse, UrlImportResult } from '../types/recipe'; import type { Recipe, RecipeDraft, Tag, ApiResponse, UrlImportResult, HarnessStatus } from '../types/recipe';
// Use relative URL - nginx will proxy to backend in production // Use relative URL - nginx will proxy to backend in production
// For local development (npm run dev), configure vite.config.ts proxy // For local development (npm run dev), configure vite.config.ts proxy
@ -268,3 +268,20 @@ export async function importRecipeFromUrl(url: string): Promise<UrlImportResult>
return result.data; return result.data;
} }
/**
* Fetch harness mission-control status for progress visibility
*/
export async function fetchHarnessStatus(): Promise<HarnessStatus> {
const response = await fetch(`${API_BASE_URL}/harness/status`);
if (!response.ok) {
throw new Error(`Failed to fetch harness status: ${response.statusText}`);
}
const result: ApiResponse<HarnessStatus> = await response.json();
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch harness status');
}
return result.data;
}

View File

@ -60,3 +60,40 @@ export interface UrlImportResult {
json_ld_blocks: string[]; json_ld_blocks: string[];
draft_recipe: RecipeDraft | null; draft_recipe: RecipeDraft | null;
} }
export interface HarnessStatus {
projectRoot: string;
commit: {
hash: string;
message: string;
timestamp: string;
relative: string;
} | null;
todo: {
checked: number;
unchecked: number;
nextTask: string | null;
};
keepalive: {
checkedAt?: string;
status?: string;
heartbeatAgeSeconds?: number | null;
lastStep?: string | null;
historyCount?: number;
shouldRecover?: boolean;
activeSessionLabel?: string | null;
reason?: string;
};
workerHeartbeat: {
timestamp?: string;
step?: string;
status?: string;
note?: string;
} | null;
workerHeartbeatHistory: Array<{
timestamp?: string;
step?: string;
status?: string;
note?: string;
}>;
}

View File

@ -3,6 +3,7 @@ import { getDatabase, saveDatabase } from './db/database.js';
import { createRecipeRoutes } from './routes/recipes.js'; import { createRecipeRoutes } from './routes/recipes.js';
import { createTagRoutes } from './routes/tags.js'; import { createTagRoutes } from './routes/tags.js';
import { createImportRoutes } from './routes/import.js'; import { createImportRoutes } from './routes/import.js';
import { createHarnessRoutes } from './routes/harness.js';
const app = express(); const app = express();
const port = 3000; const port = 3000;
@ -43,6 +44,7 @@ async function startServer() {
app.use('/api/recipes', createRecipeRoutes(db)); app.use('/api/recipes', createRecipeRoutes(db));
app.use('/api/tags', createTagRoutes(db)); app.use('/api/tags', createTagRoutes(db));
app.use('/api/import', createImportRoutes()); app.use('/api/import', createImportRoutes());
app.use('/api/harness', createHarnessRoutes(process.cwd()));
// Save database periodically (every 5 seconds) // Save database periodically (every 5 seconds)
setInterval(() => { setInterval(() => {
@ -86,6 +88,8 @@ async function startServer() {
console.log(` DELETE /api/tags/recipes/:id/tags/:id - Remove tag`); console.log(` DELETE /api/tags/recipes/:id/tags/:id - Remove tag`);
console.log(` Import:`); console.log(` Import:`);
console.log(` POST /api/import/url - Import recipe foundation data from URL`); console.log(` POST /api/import/url - Import recipe foundation data from URL`);
console.log(` Harness:`);
console.log(` GET /api/harness/status - Mission Control progress/status feed`);
}); });
} catch (error) { } catch (error) {
console.error('Failed to start server:', error); console.error('Failed to start server:', error);

View File

@ -0,0 +1,112 @@
import { Router } from 'express';
import { execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
type KeepaliveStatus = {
checkedAt?: string;
status?: 'IDLE' | 'HEALTHY' | 'STALE' | 'MISSING' | string;
heartbeatAgeSeconds?: number | null;
lastStep?: string | null;
historyCount?: number;
shouldRecover?: boolean;
activeSessionLabel?: string | null;
reason?: string;
};
type WorkerHeartbeat = {
timestamp?: string;
step?: string;
status?: string;
note?: string;
};
type GitCommitSummary = {
hash: string;
message: string;
timestamp: string;
relative: string;
};
function safeReadJson<T>(path: string): T | null {
try {
if (!existsSync(path)) return null;
return JSON.parse(readFileSync(path, 'utf8')) as T;
} catch {
return null;
}
}
function parseActiveV1Todo(todoText: string): { checked: number; unchecked: number; nextTask: string | null } {
const activeStart = todoText.indexOf('## 🎯 Active Tasks — v1.0 Recipe Import');
const backlogStart = todoText.indexOf('## 📋 Backlog (Post-v1)');
if (activeStart < 0 || backlogStart < 0 || backlogStart <= activeStart) {
return { checked: 0, unchecked: 0, nextTask: null };
}
const activeSection = todoText.slice(activeStart, backlogStart);
const lines = activeSection.split('\n').map((line) => line.trim());
const checked = lines.filter((line) => line.startsWith('- [x]')).length;
const uncheckedLines = lines.filter((line) => line.startsWith('- [ ]'));
return {
checked,
unchecked: uncheckedLines.length,
nextTask: uncheckedLines[0]?.replace(/^- \[ \] /, '') ?? null,
};
}
function getLastCommit(projectRoot: string): GitCommitSummary | null {
try {
const format = '%H|%s|%cI|%cr';
const raw = execSync(`git -C "${projectRoot}" log -1 --pretty=format:${format}`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
const [hash, message, timestamp, relative] = raw.split('|');
if (!hash || !message || !timestamp || !relative) return null;
return { hash: hash.slice(0, 8), message, timestamp, relative };
} catch {
return null;
}
}
export function createHarnessRoutes(projectRoot = process.cwd()): Router {
const router = Router();
router.get('/status', (_req, res) => {
try {
const todoPath = join(projectRoot, 'TODO.md');
const keepalivePath = join(projectRoot, '.harness', 'keepalive.json');
const workerHeartbeatPath = join(projectRoot, '.harness', 'worker-heartbeat.json');
const workerHistoryPath = join(projectRoot, '.harness', 'worker-heartbeat-history.json');
const todoText = existsSync(todoPath) ? readFileSync(todoPath, 'utf8') : '';
const todo = parseActiveV1Todo(todoText);
const keepalive = safeReadJson<KeepaliveStatus>(keepalivePath) ?? { status: 'MISSING' };
const workerHeartbeat = safeReadJson<WorkerHeartbeat>(workerHeartbeatPath);
const history = safeReadJson<WorkerHeartbeat[]>(workerHistoryPath) ?? [];
const data = {
projectRoot,
commit: getLastCommit(projectRoot),
todo,
keepalive,
workerHeartbeat,
workerHeartbeatHistory: Array.isArray(history) ? history.slice(-5) : [],
};
res.status(200).json({ success: true, data, error: null });
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
res.status(500).json({ success: false, data: null, error: message });
}
});
return router;
}