From 21a05368d3dfa70e3c7d62055c7eede86ffbf3db Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 29 Apr 2026 16:50:18 +0100 Subject: [PATCH] Add GUI visual smoke and bounded actions --- apps/epiphany-gui/README.md | 21 +++ apps/epiphany-gui/package-lock.json | 48 +++++ apps/epiphany-gui/package.json | 2 + .../public/operator-snapshot.sample.json | 164 ++++++++++++++++++ apps/epiphany-gui/scripts/visual-smoke.mjs | 112 ++++++++++++ apps/epiphany-gui/src-tauri/src/lib.rs | 142 +++++++++++++-- apps/epiphany-gui/src/App.tsx | 55 +++++- apps/epiphany-gui/src/operatorApi.ts | 31 ++++ apps/epiphany-gui/src/styles.css | 38 +++- apps/epiphany-gui/src/types.ts | 8 + notes/epiphany-fork-implementation-plan.md | 8 +- notes/fresh-workspace-handoff.md | 14 +- state/evidence.jsonl | 1 + state/map.yaml | 8 +- 14 files changed, 620 insertions(+), 32 deletions(-) create mode 100644 apps/epiphany-gui/public/operator-snapshot.sample.json create mode 100644 apps/epiphany-gui/scripts/visual-smoke.mjs create mode 100644 apps/epiphany-gui/src/operatorApi.ts diff --git a/apps/epiphany-gui/README.md b/apps/epiphany-gui/README.md index 9f8da354..e93a9856 100644 --- a/apps/epiphany-gui/README.md +++ b/apps/epiphany-gui/README.md @@ -12,6 +12,8 @@ Current slice: - calls `tools/epiphany_mvp_status.py --json` through a Tauri command - renders coordinator recommendation, pressure, CRRC/reorientation status, role lanes, role findings, jobs, and artifact bundles +- writes explicit status-snapshot and coordinator-plan artifact bundles through + bounded operator buttons Run from this directory: @@ -21,6 +23,18 @@ npm run dev npm run tauri dev ``` +Visual smoke: + +```powershell +npm run smoke:visual +``` + +The smoke runs the React shell in a browser with the bundled sample operator +snapshot, clicks the bounded operator buttons, and writes: + +- `.epiphany-gui/operator-console-smoke-desktop.png` +- `.epiphany-gui/operator-console-smoke-mobile.png` + The status bridge expects the local app-server binary used by the existing smoke tools. Build it from `vendor/codex/codex-rs` if needed: @@ -28,3 +42,10 @@ smoke tools. Build it from `vendor/codex/codex-rs` if needed: $env:CARGO_TARGET_DIR='C:\Users\Meta\.cargo-target-codex' cargo build -p codex-app-server ``` + +The first bounded buttons are intentionally narrow: + +- **Status Snapshot** writes a status JSON/transcript/stderr bundle under + `.epiphany-gui/status-snapshots`. +- **Coordinator Plan** runs `tools/epiphany_mvp_coordinator.py --mode plan` and + writes an auditable bundle under `.epiphany-dogfood`. diff --git a/apps/epiphany-gui/package-lock.json b/apps/epiphany-gui/package-lock.json index e3b04d12..75e8043d 100644 --- a/apps/epiphany-gui/package-lock.json +++ b/apps/epiphany-gui/package-lock.json @@ -18,6 +18,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "playwright": "^1.59.1", "typescript": "^5.6.3", "vite": "^6.0.1" } @@ -1853,6 +1854,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", diff --git a/apps/epiphany-gui/package.json b/apps/epiphany-gui/package.json index 18c13118..b3b90770 100644 --- a/apps/epiphany-gui/package.json +++ b/apps/epiphany-gui/package.json @@ -7,6 +7,7 @@ "dev": "vite --host 127.0.0.1 --port 1420", "build": "tsc && vite build", "preview": "vite preview --host 127.0.0.1 --port 1421", + "smoke:visual": "node scripts/visual-smoke.mjs", "tauri": "tauri" }, "dependencies": { @@ -20,6 +21,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "playwright": "^1.59.1", "typescript": "^5.6.3", "vite": "^6.0.1" } diff --git a/apps/epiphany-gui/public/operator-snapshot.sample.json b/apps/epiphany-gui/public/operator-snapshot.sample.json new file mode 100644 index 00000000..f8365b45 --- /dev/null +++ b/apps/epiphany-gui/public/operator-snapshot.sample.json @@ -0,0 +1,164 @@ +{ + "generatedAt": "1777475847000", + "repoRoot": "E:\\Projects\\EpiphanyAgent", + "status": { + "threadId": "019dd9d1-045b-7f13-b0e1-38ed89b31495", + "scene": { + "scene": { + "availableActions": [ + "index", + "retrieve", + "distill", + "context", + "jobs", + "roles", + "coordinator", + "roleLaunch", + "roleResult", + "jobLaunch", + "freshness", + "pressure", + "reorient", + "crrc", + "update" + ], + "source": "live", + "stateStatus": "missing" + } + }, + "pressure": { + "pressure": { + "basis": "unknown", + "level": "unknown", + "note": "No token usage telemetry has been recorded for this thread yet.", + "shouldPrepareCompaction": false, + "status": "unknown" + } + }, + "reorient": { + "decision": { + "action": "regather", + "checkpointStatus": "missing", + "graphStatus": "missing", + "nextAction": "Re-gather source context before editing.", + "note": "No Epiphany state survived, so there is no authoritative checkpoint to resume.", + "pressureLevel": "unknown", + "reasons": ["missingState", "missingCheckpoint"], + "retrievalStatus": "ready", + "watcherStatus": "clean" + } + }, + "jobs": { + "jobs": [ + { + "id": "retrieval-index", + "kind": "indexing", + "ownerRole": "epiphany-core", + "progressNote": "Retrieval catalog is ready.", + "scope": "E:\\Projects\\EpiphanyAgent", + "status": "idle" + }, + { + "blockingReason": "Epiphany state is missing, so there is no graph to remap.", + "id": "graph-remap", + "kind": "remap", + "ownerRole": "epiphany-harness", + "scope": "architecture/dataflow graphs", + "status": "blocked" + }, + { + "blockingReason": "Epiphany state is missing, so there are no invariants to verify.", + "id": "verification", + "kind": "verification", + "ownerRole": "epiphany-harness", + "scope": "invariants/evidence", + "status": "blocked" + } + ] + }, + "roles": { + "roles": [ + { + "id": "implementation", + "note": "Wait for CRRC action: RegatherManually.", + "ownerRole": "coding-agent", + "status": "blocked", + "title": "Implementation" + }, + { + "id": "modeling", + "note": "Checkpoint missing: Re-gather source context before editing.", + "ownerRole": "epiphany-modeler", + "recommendedAction": "update", + "status": "needed", + "title": "Modeling / Checkpoint" + }, + { + "id": "verification", + "note": "Epiphany state is missing, so there are no invariants to verify.", + "ownerRole": "epiphany-verifier", + "status": "blocked", + "title": "Verification / Review" + }, + { + "id": "reorientation", + "note": "Regather verdict, result MissingState, pressure Unknown. No authoritative Epiphany state exists; re-gather source context before editing.", + "ownerRole": "epiphany-reorient", + "status": "needed", + "title": "Reorientation" + } + ] + }, + "roleResults": { + "modeling": { + "bindingId": "modeling-checkpoint-worker", + "note": "No authoritative Epiphany state exists for this thread.", + "roleId": "modeling", + "status": "missingState" + }, + "verification": { + "bindingId": "verification-review-worker", + "note": "No authoritative Epiphany state exists for this thread.", + "roleId": "verification", + "status": "missingState" + } + }, + "reorientResult": { + "bindingId": "reorient-worker", + "note": "No authoritative Epiphany state exists for this thread.", + "status": "missingState" + }, + "crrc": { + "recommendation": { + "action": "regatherManually", + "reason": "No authoritative Epiphany state exists; re-gather source context before editing." + } + }, + "coordinator": { + "action": "prepareCheckpoint", + "canAutoRun": false, + "reason": "Authoritative state or investigation checkpoint is missing; prepare a checkpoint before coordination can continue.", + "recommendedSceneAction": "update", + "requiresReview": false, + "targetRole": "modeling" + } + }, + "artifacts": [ + { + "name": "mvp-loop-selfdogfood-vanilla2-20260429", + "path": "E:\\Projects\\EpiphanyAgent\\.epiphany-dogfood\\mvp-loop-selfdogfood-vanilla2-20260429", + "files": [ + "artifact-manifest.json", + "comparison.md", + "epiphany-dogfood-summary.json", + "epiphany-final-status.json", + "epiphany-transcript.jsonl", + "vanilla-reference.md" + ], + "summaryPath": "E:\\Projects\\EpiphanyAgent\\.epiphany-dogfood\\mvp-loop-selfdogfood-vanilla2-20260429\\epiphany-dogfood-summary.json", + "finalStatusPath": "E:\\Projects\\EpiphanyAgent\\.epiphany-dogfood\\mvp-loop-selfdogfood-vanilla2-20260429\\epiphany-final-status.json", + "comparisonPath": "E:\\Projects\\EpiphanyAgent\\.epiphany-dogfood\\mvp-loop-selfdogfood-vanilla2-20260429\\comparison.md", + "modifiedMillis": 1777475847000 + } + ] +} diff --git a/apps/epiphany-gui/scripts/visual-smoke.mjs b/apps/epiphany-gui/scripts/visual-smoke.mjs new file mode 100644 index 00000000..673b43c0 --- /dev/null +++ b/apps/epiphany-gui/scripts/visual-smoke.mjs @@ -0,0 +1,112 @@ +import { chromium } from "playwright"; +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { createServer } from "vite"; + +const root = resolve(import.meta.dirname, "..", "..", ".."); +const artifactDir = resolve(root, ".epiphany-gui"); +const desktopScreenshotPath = resolve(artifactDir, "operator-console-smoke-desktop.png"); +const mobileScreenshotPath = resolve(artifactDir, "operator-console-smoke-mobile.png"); +const url = "http://127.0.0.1:1420"; + +await mkdir(artifactDir, { recursive: true }); + +const server = await createServer({ + root: resolve(root, "apps", "epiphany-gui"), + logLevel: "silent", + server: { + host: "127.0.0.1", + port: 1420, + strictPort: true, + }, +}); + +try { + await server.listen(); + await waitForServer(url); + const browser = await chromium.launch({ + channel: "msedge", + headless: true, + }); + await smokeViewport(browser, { width: 1366, height: 900 }, desktopScreenshotPath); + await smokeViewport(browser, { width: 390, height: 844 }, mobileScreenshotPath); + await browser.close(); + console.log( + JSON.stringify( + { + ok: true, + screenshots: [desktopScreenshotPath, mobileScreenshotPath], + }, + null, + 2, + ), + ); +} finally { + await server.close(); +} + +async function smokeViewport(browser, viewport, screenshotPath) { + const page = await browser.newPage({ viewport }); + await page.goto(url, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Operator Console" }).waitFor(); + await page.getByText("prepareCheckpoint").waitFor(); + await page.getByText("Role Lanes").waitFor(); + await page.getByText("Artifact Bundles").waitFor(); + if (viewport.width >= 900) { + await page.getByRole("button", { name: "Status Snapshot" }).click(); + await page.getByText("statusSnapshot sample completed.").waitFor(); + await page.getByRole("button", { name: "Coordinator Plan" }).click(); + await page.getByText("coordinatorPlan sample completed.").waitFor(); + } + await page.screenshot({ path: screenshotPath, fullPage: true }); + + const result = await page.evaluate(() => { + const elements = Array.from(document.querySelectorAll("h1, h2, h3, p, dd, code, button, input, .pill")); + const overlaps = []; + for (let index = 0; index < elements.length; index += 1) { + const left = elements[index]; + const a = elements[index].getBoundingClientRect(); + if (a.width === 0 || a.height === 0) continue; + for (let other = index + 1; other < elements.length; other += 1) { + const right = elements[other]; + if (left.contains(right) || right.contains(left)) continue; + const b = elements[other].getBoundingClientRect(); + if (b.width === 0 || b.height === 0) continue; + const x = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const y = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + if (x > 2 && y > 2) { + overlaps.push({ + left: left.textContent?.trim() || left.getAttribute("placeholder") || left.tagName, + right: right.textContent?.trim() || right.getAttribute("placeholder") || right.tagName, + }); + } + } + } + return { + overlaps, + horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + }; + }); + + await page.close(); + if (result.horizontalOverflow) { + throw new Error(`visual smoke found horizontal overflow at ${viewport.width}x${viewport.height}`); + } + if (result.overlaps.length > 0) { + throw new Error(`visual smoke found overlapping text/control boxes: ${JSON.stringify(result.overlaps)}`); + } +} + +async function waitForServer(target) { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + const response = await fetch(target); + if (response.ok) return; + } catch { + // Server is still waking up. + } + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + throw new Error("Vite server did not start."); +} diff --git a/apps/epiphany-gui/src-tauri/src/lib.rs b/apps/epiphany-gui/src-tauri/src/lib.rs index 97ea1f5b..608f1c0b 100644 --- a/apps/epiphany-gui/src-tauri/src/lib.rs +++ b/apps/epiphany-gui/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct StatusRequest { thread_id: Option, @@ -14,6 +14,14 @@ struct StatusRequest { app_server: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct OperatorActionResult { + action: String, + artifact_path: String, + summary: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ArtifactBundle { @@ -48,14 +56,17 @@ fn load_operator_snapshot(request: Option) -> Result Self { - Self { - thread_id: None, - cwd: None, - codex_home: None, - app_server: None, - } +#[tauri::command] +fn run_operator_action( + action: String, + request: Option, +) -> Result { + let repo_root = repo_root()?; + let request = request.unwrap_or_default(); + match action.as_str() { + "statusSnapshot" => run_status_snapshot(&repo_root, request), + "coordinatorPlan" => run_coordinator_plan(&repo_root, request), + _ => Err(format!("unknown operator action: {action}")), } } @@ -113,6 +124,114 @@ fn load_status(repo_root: &Path, request: StatusRequest) -> Result Result { + let python = find_python()?; + let artifact_root = repo_root + .join(".epiphany-gui") + .join("status-snapshots") + .join(unix_millis().to_string()); + fs::create_dir_all(&artifact_root) + .map_err(|err| format!("failed to create status artifact dir: {err}"))?; + let result_path = artifact_root.join("status.json"); + let transcript_path = artifact_root.join("transcript.jsonl"); + let stderr_path = artifact_root.join("server.stderr.log"); + let workspace = request + .cwd + .map(PathBuf::from) + .unwrap_or_else(|| repo_root.to_path_buf()); + let codex_home = request + .codex_home + .map(PathBuf::from) + .unwrap_or_else(|| repo_root.join(".epiphany-gui").join("codex-home")); + + let mut command = Command::new(python); + command + .current_dir(repo_root) + .arg(repo_root.join("tools").join("epiphany_mvp_status.py")) + .arg("--json") + .arg("--cwd") + .arg(workspace) + .arg("--codex-home") + .arg(codex_home) + .arg("--result") + .arg(&result_path) + .arg("--transcript") + .arg(transcript_path) + .arg("--stderr") + .arg(stderr_path); + if let Some(thread_id) = request.thread_id { + command.arg("--thread-id").arg(thread_id); + } + if let Some(app_server) = request.app_server { + command.arg("--app-server").arg(app_server); + } + run_command(command, "status snapshot")?; + Ok(OperatorActionResult { + action: "statusSnapshot".to_string(), + artifact_path: artifact_root.display().to_string(), + summary: "Status snapshot written.".to_string(), + }) +} + +fn run_coordinator_plan( + repo_root: &Path, + request: StatusRequest, +) -> Result { + let python = find_python()?; + let artifact_dir = repo_root + .join(".epiphany-dogfood") + .join(format!("gui-coordinator-plan-{}", unix_millis())); + let workspace = request + .cwd + .map(PathBuf::from) + .unwrap_or_else(|| repo_root.to_path_buf()); + let codex_home = request + .codex_home + .map(PathBuf::from) + .unwrap_or_else(|| repo_root.join(".epiphany-gui").join("codex-home")); + + let mut command = Command::new(python); + command + .current_dir(repo_root) + .arg(repo_root.join("tools").join("epiphany_mvp_coordinator.py")) + .arg("--mode") + .arg("plan") + .arg("--max-steps") + .arg("2") + .arg("--cwd") + .arg(workspace) + .arg("--codex-home") + .arg(codex_home) + .arg("--artifact-dir") + .arg(&artifact_dir); + if let Some(thread_id) = request.thread_id { + command.arg("--thread-id").arg(thread_id); + } + if let Some(app_server) = request.app_server { + command.arg("--app-server").arg(app_server); + } + run_command(command, "coordinator plan")?; + Ok(OperatorActionResult { + action: "coordinatorPlan".to_string(), + artifact_path: artifact_dir.display().to_string(), + summary: "Coordinator plan artifact written.".to_string(), + }) +} + +fn run_command(mut command: Command, label: &str) -> Result<(), String> { + let output = command + .output() + .map_err(|err| format!("failed to run {label}: {err}"))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("{label} exited with {}: {}", output.status, stderr)) +} + fn list_artifacts(repo_root: &Path) -> Result, String> { let root = repo_root.join(".epiphany-dogfood"); if !root.exists() { @@ -201,7 +320,10 @@ fn system_time_millis(value: SystemTime) -> Option { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![load_operator_snapshot]) + .invoke_handler(tauri::generate_handler![ + load_operator_snapshot, + run_operator_action + ]) .run(tauri::generate_context!()) .expect("error while running Epiphany operator"); } diff --git a/apps/epiphany-gui/src/App.tsx b/apps/epiphany-gui/src/App.tsx index 1f86a296..6669c59b 100644 --- a/apps/epiphany-gui/src/App.tsx +++ b/apps/epiphany-gui/src/App.tsx @@ -1,7 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; import { AlertTriangle, BriefcaseBusiness, ClipboardCheck, Database, FileText, GitBranch, RefreshCw } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import type { ArtifactBundle, OperatorSnapshot, StatusRequest } from "./types"; +import { loadOperatorSnapshot, runOperatorAction } from "./operatorApi"; +import type { ArtifactBundle, OperatorAction, OperatorActionResult, OperatorSnapshot, StatusRequest } from "./types"; const roleOrder = ["implementation", "modeling", "verification", "reorientation"]; @@ -34,13 +34,15 @@ function useSnapshot() { const [snapshot, setSnapshot] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [actionResult, setActionResult] = useState(null); + const [runningAction, setRunningAction] = useState(null); const [request, setRequest] = useState({}); async function refresh(nextRequest = request) { setLoading(true); setError(null); try { - const result = await invoke("load_operator_snapshot", { request: nextRequest }); + const result = await loadOperatorSnapshot(nextRequest); setSnapshot(result); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -49,16 +51,31 @@ function useSnapshot() { } } + async function runAction(action: OperatorAction) { + setRunningAction(action); + setError(null); + setActionResult(null); + try { + const result = await runOperatorAction(action, request); + setActionResult(result); + await refresh(request); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRunningAction(null); + } + } + useEffect(() => { void refresh({}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return { snapshot, loading, error, request, setRequest, refresh }; + return { snapshot, loading, error, request, setRequest, refresh, actionResult, runningAction, runAction }; } export function App() { - const { snapshot, loading, error, request, setRequest, refresh } = useSnapshot(); + const { snapshot, loading, error, request, setRequest, refresh, actionResult, runningAction, runAction } = useSnapshot(); const status = snapshot?.status; const scene = status?.scene?.scene ?? {}; const pressure = status?.pressure?.pressure ?? {}; @@ -106,6 +123,32 @@ export function App() { +
+ + + {actionResult && ( +

+ {actionResult.summary} {actionResult.artifactPath} +

+ )} +
+ {error && (