feat(mission-control): add live harness progress status panel
This commit is contained in:
parent
feb10fdb8b
commit
272ce1d2f0
|
|
@ -54,3 +54,6 @@ temp/
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Harness runtime state
|
||||||
|
.harness/*.json
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue