diff --git a/.gitignore b/.gitignore index 8ce6fee..7232703 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ temp/ # Backup files *.bak *~ + +# Harness runtime state +.harness/*.json diff --git a/.harness/keepalive.json b/.harness/keepalive.json deleted file mode 100644 index aa424dd..0000000 --- a/.harness/keepalive.json +++ /dev/null @@ -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 -} diff --git a/frontend/src/components/MissionControlPanel.tsx b/frontend/src/components/MissionControlPanel.tsx new file mode 100644 index 0000000..dc0b1d7 --- /dev/null +++ b/frontend/src/components/MissionControlPanel.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

Mission Control: loading status…

+
+ ); + } + + if (error || !status) { + return ( +
+

Mission Control unavailable: {error ?? 'unknown error'}

+
+ ); + } + + return ( +
+
+

Mission Control — Harness Progress

+ + {status.keepalive.status ?? 'UNKNOWN'} + +
+ +
+

+ Last commit:{' '} + {status.commit ? `${status.commit.hash} (${status.commit.relative})` : 'N/A'} +

+

+ Iteration:{' '} + {status.keepalive.activeSessionLabel ?? 'none'} +

+

+ v1 tasks:{' '} + {status.todo.checked} done / {status.todo.unchecked} remaining +

+

+ Heartbeat age:{' '} + {status.keepalive.heartbeatAgeSeconds != null ? `${status.keepalive.heartbeatAgeSeconds}s` : 'n/a'} +

+
+ +

+ Next task:{' '} + {status.todo.nextTask ?? 'No unchecked v1 tasks'} +

+ + {status.workerHeartbeatHistory.length > 0 && ( +
+

Last 5 heartbeats

+
    + {status.workerHeartbeatHistory.map((entry, index) => ( +
  • + {entry.timestamp ?? 'unknown time'} — {entry.step ?? 'step n/a'} ({entry.status ?? 'status n/a'}) +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/RecipeListPage.tsx b/frontend/src/pages/RecipeListPage.tsx index 3876fef..0058fc2 100644 --- a/frontend/src/pages/RecipeListPage.tsx +++ b/frontend/src/pages/RecipeListPage.tsx @@ -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 (
+ + {/* Header */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0709cde..23cdb9e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 return result.data; } + +/** + * Fetch harness mission-control status for progress visibility + */ +export async function fetchHarnessStatus(): Promise { + 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 = await response.json(); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to fetch harness status'); + } + + return result.data; +} diff --git a/frontend/src/types/recipe.ts b/frontend/src/types/recipe.ts index 3912949..5069055 100644 --- a/frontend/src/types/recipe.ts +++ b/frontend/src/types/recipe.ts @@ -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; + }>; +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 8dd9f3a..d1f629c 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -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); diff --git a/src/backend/routes/harness.ts b/src/backend/routes/harness.ts new file mode 100644 index 0000000..f29a7e2 --- /dev/null +++ b/src/backend/routes/harness.ts @@ -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(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(keepalivePath) ?? { status: 'MISSING' }; + const workerHeartbeat = safeReadJson(workerHeartbeatPath); + const history = safeReadJson(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; +}