From c33fa3895d97d9c3d6a4caf6613a4a4b2ae66720 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 01:25:22 -0400 Subject: [PATCH 1/2] feat: compile recordings automatically after stop --- engine/dispatch.py | 77 ++++++++++++++-- src/screens/RecordReview.test.tsx | 81 +++++++++++++++++ src/screens/RecordReview.tsx | 137 ++++++++++++++++++++++++++--- tests/test_engine/test_dispatch.py | 62 ++++++++++++- 4 files changed, 333 insertions(+), 24 deletions(-) create mode 100644 src/screens/RecordReview.test.tsx diff --git a/engine/dispatch.py b/engine/dispatch.py index 339730d..25e5926 100644 --- a/engine/dispatch.py +++ b/engine/dispatch.py @@ -454,7 +454,7 @@ def start_recording(self, **params: Any) -> dict: return {"capture_id": capture_id, "recording": True} def stop_recording(self, **params: Any) -> dict: - """Stop the active recording and emit ``recording_stopped``.""" + """Stop the active recording, retain it, and compile it automatically.""" controller = self.services.controller active = self._flow_recording if active is not None: @@ -469,7 +469,12 @@ def stop_recording(self, **params: Any) -> dict: metadata = controller.stop() self.emit("recording_stopped", metadata) self.emit("status_update", self._status_dict(controller)) - return {"capture_id": metadata.get("id"), **metadata} + stopped = {"capture_id": metadata.get("id"), **metadata} + stopped["compile"] = self._compile_registered_capture( + str(stopped["capture_id"]), + automatic=True, + ) + return stopped def _finalize_flow_capture(self, active: _ActiveFlowRecording, result: Any) -> dict: """Register one compile-ready Flow capture and emit its local evidence.""" @@ -522,6 +527,10 @@ def _finalize_flow_capture(self, active: _ActiveFlowRecording, result: Any) -> d } self.emit("recording_stopped", metadata) self.emit("status_update", self._status_dict(self.services.controller)) + metadata["compile"] = self._compile_registered_capture( + active.capture_id, + automatic=True, + ) return metadata def pause_recording(self, **params: Any) -> dict: @@ -672,23 +681,75 @@ def compile_recording(self, **params: Any) -> dict: capture_id = params.get("capture_id") if not capture_id: return {"ok": False, "error": "capture_id is required", "workflow_id": ""} + return self._compile_registered_capture(str(capture_id), automatic=False) + + def _compile_registered_capture(self, capture_id: str, *, automatic: bool) -> dict: + """Compile one retained capture and report a retryable local state. + + Flow writes the bundle to a separate directory. The source recording + remains in the capture directory on success and on failure. + """ + progress = {"capture_id": capture_id, "automatic": automatic} capture = self.services.db.get_capture(capture_id) capture_dir = capture and (capture.get("capture_path") or capture.get("capture_dir")) if not capture_dir: - return {"ok": False, "error": f"Unknown capture {capture_id}", "workflow_id": ""} - self.emit("compile_progress", {"capture_id": capture_id, "state": "compiling"}) - compiled = self.services.controller.compile_capture(capture_id, Path(capture_dir)) + error = f"OpenAdapt could not find the retained recording {capture_id}." + self.emit( + "compile_progress", + { + **progress, + "state": "failed", + "error": error, + "recording_retained": False, + }, + ) + return { + "ok": False, + "error": error, + "workflow_id": "", + "recording_retained": False, + } + self.emit("compile_progress", {**progress, "state": "compiling"}) + try: + compiled = self.services.controller.compile_capture(capture_id, Path(capture_dir)) + except Exception: + logger.exception("Compile failed for retained capture {cid}", cid=capture_id) + compiled = None + recording_retained = Path(capture_dir).is_dir() if not compiled: - self.emit("compile_progress", {"capture_id": capture_id, "state": "failed"}) - return {"ok": False, "error": "Compile failed (see logs)", "workflow_id": ""} + error = ( + "OpenAdapt could not compile this recording. " + "The raw recording was retained and is ready for another attempt." + if recording_retained + else "OpenAdapt could not compile because the recording is no longer available." + ) + failed = { + **progress, + "state": "failed", + "error": error, + "recording_retained": recording_retained, + } + self.emit("compile_progress", failed) + return { + "ok": False, + "error": error, + "workflow_id": "", + "recording_retained": recording_retained, + } self.emit( "compile_progress", - {"capture_id": capture_id, "state": "compiled", "bundle_id": compiled["bundle_id"]}, + { + **progress, + "state": "compiled", + "bundle_id": compiled["bundle_id"], + "recording_retained": recording_retained, + }, ) return { "ok": True, "workflow_id": compiled["bundle_id"], "bundle_path": compiled["bundle_path"], + "recording_retained": recording_retained, } def replay_workflow(self, **params: Any) -> dict: diff --git a/src/screens/RecordReview.test.tsx b/src/screens/RecordReview.test.tsx new file mode 100644 index 0000000..43d0d15 --- /dev/null +++ b/src/screens/RecordReview.test.tsx @@ -0,0 +1,81 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { engineInvoke, engineTry, EVT } from "../lib/engine"; +import { RecordReview } from "./RecordReview"; + +const eventMocks = vi.hoisted(() => ({ + handlers: new Map void>(), +})); + +vi.mock("../lib/engine", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + engineInvoke: vi.fn(), + engineTry: vi.fn(), + onEngineEvent: vi.fn( + (event: string, handler: (payload: unknown) => void) => { + eventMocks.handlers.set(event, handler); + return Promise.resolve(() => eventMocks.handlers.delete(event)); + }, + ), + }; +}); + +vi.mock("../overlay/preferences", () => ({ + overlayPresentationEnabled: () => false, +})); + +afterEach(() => { + cleanup(); + eventMocks.handlers.clear(); + vi.clearAllMocks(); +}); + +it("shows automatic compile progress and retries a retained recording", async () => { + vi.mocked(engineTry).mockResolvedValue({ + recording: false, + paused: false, + duration_secs: 0, + capture_id: null, + controls: { pause: false, resume: false, stop: false }, + }); + const onCompiled = vi.fn(); + render(); + + await waitFor(() => { + expect(eventMocks.handlers.has(EVT.RECORDING_STOPPED)).toBe(true); + expect(eventMocks.handlers.has(EVT.COMPILE_PROGRESS)).toBe(true); + }); + + act(() => { + eventMocks.handlers.get(EVT.RECORDING_STOPPED)?.({ capture_id: "cap-1" }); + }); + expect(await screen.findByText("Building your workflow")).toBeTruthy(); + expect(screen.getByText(/raw recording stays unchanged/i)).toBeTruthy(); + + act(() => { + eventMocks.handlers.get(EVT.COMPILE_PROGRESS)?.({ + capture_id: "cap-1", + state: "failed", + error: "The raw recording was retained and is ready for another attempt.", + recording_retained: true, + }); + }); + expect(await screen.findByText("Compilation needs attention")).toBeTruthy(); + expect(screen.getByText(/raw recording was retained/i)).toBeTruthy(); + + vi.mocked(engineInvoke).mockResolvedValue({ + ok: true, + workflow_id: "workflow-1", + recording_retained: true, + }); + fireEvent.click(screen.getByRole("button", { name: "Retry compilation" })); + + await waitFor(() => { + expect(engineInvoke).toHaveBeenCalledWith("compile_recording", { + capture_id: "cap-1", + }); + expect(onCompiled).toHaveBeenCalledWith("workflow-1", { backend: "web" }); + }); +}); diff --git a/src/screens/RecordReview.tsx b/src/screens/RecordReview.tsx index 96af08a..6362383 100644 --- a/src/screens/RecordReview.tsx +++ b/src/screens/RecordReview.tsx @@ -1,7 +1,7 @@ // Record & review — drive a recording, then review + compile it. // Recording state comes from the engine via events + get_status; after stop the // capture can be scrubbed (PHI gate) and compiled into a workflow. -import { useEffect, useId, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { CMD, engineInvoke, engineTry, onEngineEvent, EVT } from "../lib/engine"; import type { BrowserRuntimeStatus, @@ -21,6 +21,28 @@ function fmt(secs?: number | null) { return `${m}:${String(s).padStart(2, "0")}`; } +type CompilePhase = "idle" | "compiling" | "failed"; + +interface CompileResult { + ok: boolean; + workflow_id?: string; + error?: string; + recording_retained?: boolean; +} + +interface RecordingResult { + capture_id?: string; + compile?: CompileResult; +} + +interface CompileProgress { + capture_id?: string; + state: "compiling" | "compiled" | "failed"; + bundle_id?: string; + error?: string; + recording_retained?: boolean; +} + export function RecordReview({ onCompiled, }: { @@ -34,7 +56,8 @@ export function RecordReview({ controls: { pause: false, resume: false, stop: false }, }); const [lastCapture, setLastCapture] = useState(null); - const [phase, setPhase] = useState<"idle" | "compiling">("idle"); + const [phase, setPhase] = useState("idle"); + const [compileError, setCompileError] = useState(null); const [busy, setBusy] = useState(false); const [target, setTarget] = useState({ backend: "web" }); const [task, setTask] = useState(""); @@ -46,6 +69,17 @@ export function RecordReview({ const [presentationError, setPresentationError] = useState(null); const [exportingPresentation, setExportingPresentation] = useState(false); const fieldPrefix = useId(); + const targetRef = useRef(target); + const onCompiledRef = useRef(onCompiled); + const openedWorkflowRef = useRef(null); + targetRef.current = target; + onCompiledRef.current = onCompiled; + + function openCompiledWorkflow(workflowId: string) { + if (openedWorkflowRef.current === workflowId) return; + openedWorkflowRef.current = workflowId; + onCompiledRef.current(workflowId, targetRef.current); + } async function refresh() { const s = await engineTry(CMD.GET_STATUS, {}, status); @@ -57,7 +91,26 @@ export function RecordReview({ const unsubs = [ onEngineEvent(EVT.STATUS_UPDATE, (s: EngineStatus) => setStatus(s)), onEngineEvent(EVT.RECORDING_STOPPED, (d: { capture_id?: string }) => { - if (d?.capture_id) setLastCapture(d.capture_id); + if (d?.capture_id) { + setLastCapture(d.capture_id); + setCompileError(null); + setPhase("compiling"); + } + }), + onEngineEvent(EVT.COMPILE_PROGRESS, (next: CompileProgress) => { + if (next.capture_id) setLastCapture(next.capture_id); + if (next.state === "compiling") { + setCompileError(null); + setPhase("compiling"); + } else if (next.state === "failed") { + setCompileError( + next.error || + "OpenAdapt could not compile this recording. The raw recording was retained.", + ); + setPhase("failed"); + } else if (next.bundle_id) { + openCompiledWorkflow(next.bundle_id); + } }), onEngineEvent(EVT.BROWSER_RUNTIME, (next: BrowserRuntimeStatus) => { if (next.workflow_id === "recording") setRuntime(next); @@ -88,11 +141,16 @@ export function RecordReview({ async function start() { setBusy(true); setRuntime(null); + setLastCapture(null); + setCompileError(null); + setPhase("idle"); + openedWorkflowRef.current = null; try { - await engineInvoke(CMD.START_RECORDING, { + const result = await engineInvoke(CMD.START_RECORDING, { target, ...(task.trim() ? { purpose: task.trim() } : {}), }); + applyCompileResult(result); } finally { setBusy(false); } @@ -100,26 +158,54 @@ export function RecordReview({ async function stop() { setBusy(true); try { - const r = await engineInvoke<{ capture_id?: string }>( + const r = await engineInvoke( CMD.STOP_RECORDING, {}, ); if (r?.capture_id) setLastCapture(r.capture_id); + applyCompileResult(r); } finally { setBusy(false); } } - async function compile() { + + function applyCompileResult(result: RecordingResult) { + const compiled = result.compile; + if (!compiled) return; + if (compiled.ok && compiled.workflow_id) { + openCompiledWorkflow(compiled.workflow_id); + return; + } + setCompileError( + compiled.error || + "OpenAdapt could not compile this recording. The raw recording was retained.", + ); + setPhase("failed"); + } + + async function retryCompile() { if (!lastCapture) return; setPhase("compiling"); + setCompileError(null); try { - const r = await engineInvoke<{ workflow_id?: string }>( + const r = await engineInvoke( CMD.COMPILE_RECORDING, { capture_id: lastCapture }, ); - if (r?.workflow_id) onCompiled(r.workflow_id, target); - } finally { - setPhase("idle"); + if (r.ok && r.workflow_id) { + openCompiledWorkflow(r.workflow_id); + } else { + setCompileError( + r.error || + "OpenAdapt could not compile this recording. The raw recording was retained.", + ); + setPhase("failed"); + } + } catch { + setCompileError( + "OpenAdapt could not start compilation. The raw recording is still available.", + ); + setPhase("failed"); } } @@ -265,10 +351,29 @@ export function RecordReview({ {lastCapture && !recording && ( +
+ {phase === "compiling" && ( + + OpenAdapt is converting the completed demonstration into a + deterministic workflow. The raw recording stays unchanged. + + )} + {phase === "failed" && ( + + {compileError} + + )} +
OpenAdapt scrubs the recording (fail-closed) before any upload. On the BYOC lane it is never uploaded — compile, replay, and teach all @@ -278,9 +383,13 @@ export function RecordReview({