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
|
||||
*.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 { useTags } from '../hooks/useTags';
|
||||
import { RecipeCard } from '../components/RecipeCard';
|
||||
import { MissionControlPanel } from '../components/MissionControlPanel';
|
||||
|
||||
export function RecipeListPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
|
@ -46,6 +47,8 @@ export function RecipeListPage() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<MissionControlPanel />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* 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
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
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 { createTagRoutes } from './routes/tags.js';
|
||||
import { createImportRoutes } from './routes/import.js';
|
||||
import { createHarnessRoutes } from './routes/harness.js';
|
||||
|
||||
const app = express();
|
||||
const port = 3000;
|
||||
|
|
@ -43,6 +44,7 @@ async function startServer() {
|
|||
app.use('/api/recipes', createRecipeRoutes(db));
|
||||
app.use('/api/tags', createTagRoutes(db));
|
||||
app.use('/api/import', createImportRoutes());
|
||||
app.use('/api/harness', createHarnessRoutes(process.cwd()));
|
||||
|
||||
// Save database periodically (every 5 seconds)
|
||||
setInterval(() => {
|
||||
|
|
@ -86,6 +88,8 @@ async function startServer() {
|
|||
console.log(` DELETE /api/tags/recipes/:id/tags/:id - Remove tag`);
|
||||
console.log(` Import:`);
|
||||
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) {
|
||||
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