Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 69 additions & 8 deletions engine/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
137 changes: 137 additions & 0 deletions src/screens/RecordReview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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<string, (payload: unknown) => void>(),
}));

vi.mock("../lib/engine", async (importOriginal) => {
const original = await importOriginal<typeof import("../lib/engine")>();
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(<RecordReview onCompiled={onCompiled} />);

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" });
});
});

it("opens one workflow when the event and command return report the same compile", async () => {
vi.mocked(engineTry).mockResolvedValue({
recording: false,
paused: false,
duration_secs: 0,
capture_id: null,
controls: { pause: false, resume: false, stop: false },
});
vi.mocked(engineInvoke).mockImplementation(async (command) => {
if (command === "start_recording") {
return { capture_id: "cap-1", recording: true };
}
if (command === "stop_recording") {
act(() => {
eventMocks.handlers.get(EVT.COMPILE_PROGRESS)?.({
capture_id: "cap-1",
state: "compiled",
bundle_id: "workflow-1",
recording_retained: true,
});
});
return {
capture_id: "cap-1",
compile: {
ok: true,
workflow_id: "workflow-1",
recording_retained: true,
},
};
}
return {};
});
const onCompiled = vi.fn();
render(<RecordReview onCompiled={onCompiled} />);

await waitFor(() => {
expect(eventMocks.handlers.has(EVT.COMPILE_PROGRESS)).toBe(true);
});
fireEvent.click(screen.getByRole("button", { name: "Start recording" }));
act(() => {
eventMocks.handlers.get(EVT.STATUS_UPDATE)?.({
recording: true,
paused: false,
duration_secs: 1,
capture_id: "cap-1",
controls: { pause: false, resume: false, stop: true },
});
});
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));

await waitFor(() => {
expect(onCompiled).toHaveBeenCalledTimes(1);
expect(onCompiled).toHaveBeenCalledWith("workflow-1", { backend: "web" });
});
});
Loading