-
Notifications
You must be signed in to change notification settings - Fork 345
Fix recording pipeline and deterministic replay on browser-use 0.13 / Chrome 137+ #166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a158fef
cab60bb
c3999df
19ec182
e823384
e384d18
329da18
8defbb4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import React, { useCallback, useEffect, useRef, useState } from "react"; | ||
|
|
||
| const API_BASE = "http://localhost:8000"; | ||
|
|
||
| type RecordingStatus = { | ||
| status: "idle" | "recording" | "saving" | "done" | "error" | "no_data"; | ||
| message?: string | null; | ||
| workflow_file?: string | null; | ||
| }; | ||
|
|
||
| interface RecordButtonProps { | ||
| onRecordingSaved: (workflowFile: string) => void; | ||
| } | ||
|
|
||
| const RecordButton: React.FC<RecordButtonProps> = ({ onRecordingSaved }) => { | ||
| const [status, setStatus] = useState<RecordingStatus>({ status: "idle" }); | ||
| const [busy, setBusy] = useState(false); | ||
| const pollRef = useRef<number | null>(null); | ||
|
|
||
| const stopPolling = useCallback(() => { | ||
| if (pollRef.current !== null) { | ||
| window.clearInterval(pollRef.current); | ||
| pollRef.current = null; | ||
| } | ||
| }, []); | ||
|
|
||
| const applyStatus = useCallback( | ||
| (next: RecordingStatus) => { | ||
| setStatus(next); | ||
| if (next.status !== "recording" && next.status !== "saving") { | ||
| stopPolling(); | ||
| if (next.status === "done" && next.workflow_file) { | ||
| onRecordingSaved(next.workflow_file); | ||
| } | ||
| } | ||
| }, | ||
| [onRecordingSaved, stopPolling] | ||
| ); | ||
|
|
||
| const poll = useCallback(async () => { | ||
| try { | ||
| const res = await fetch(`${API_BASE}/api/workflows/recordings/status`); | ||
| applyStatus((await res.json()) as RecordingStatus); | ||
| } catch { | ||
| /* backend briefly unreachable — keep polling */ | ||
| } | ||
| }, [applyStatus]); | ||
|
|
||
| const startPolling = useCallback(() => { | ||
| stopPolling(); | ||
| pollRef.current = window.setInterval(poll, 2000); | ||
| }, [poll, stopPolling]); | ||
|
|
||
| useEffect(() => stopPolling, [stopPolling]); | ||
|
|
||
| const start = async () => { | ||
| setBusy(true); | ||
| try { | ||
| const res = await fetch(`${API_BASE}/api/workflows/recordings/start`, { | ||
| method: "POST", | ||
| }); | ||
| applyStatus((await res.json()) as RecordingStatus); | ||
| startPolling(); | ||
| } catch (e) { | ||
| setStatus({ status: "error", message: String(e) }); | ||
| } finally { | ||
| setBusy(false); | ||
| } | ||
| }; | ||
|
|
||
| const stop = async () => { | ||
| setBusy(true); | ||
| try { | ||
| const res = await fetch(`${API_BASE}/api/workflows/recordings/stop`, { | ||
| method: "POST", | ||
| }); | ||
| const next = (await res.json()) as RecordingStatus; | ||
| applyStatus(next); | ||
| // Decide from the response, not the pre-request closure state. | ||
| if (next.status === "saving") startPolling(); | ||
| } catch (e) { | ||
| setStatus({ status: "error", message: String(e) }); | ||
| } finally { | ||
| setBusy(false); | ||
| } | ||
| }; | ||
|
|
||
| const isRecording = status.status === "recording" || status.status === "saving"; | ||
|
|
||
| return ( | ||
| <div className="mb-3"> | ||
| <button | ||
| onClick={isRecording ? stop : start} | ||
| disabled={busy} | ||
| className={`w-full rounded py-2 text-sm font-semibold text-white transition-colors ${ | ||
| isRecording | ||
| ? "bg-red-600 hover:bg-red-700" | ||
| : "bg-blue-500 hover:bg-blue-600" | ||
| } ${busy ? "opacity-60" : ""}`} | ||
| > | ||
| {isRecording ? "■ Stop Recording" : "● Record New Workflow"} | ||
| </button> | ||
|
|
||
| {status.status === "recording" && ( | ||
| <p className="mt-2 text-xs text-[#aaa]"> | ||
| Recording… interact in the opened browser window, then click Stop (or | ||
| close the browser). | ||
| </p> | ||
| )} | ||
| {status.status === "saving" && ( | ||
| <p className="mt-2 text-xs text-[#aaa]">Saving recording…</p> | ||
| )} | ||
| {status.status === "done" && status.workflow_file && ( | ||
| <p className="mt-2 text-xs text-green-400"> | ||
| Saved: {status.workflow_file} | ||
| </p> | ||
| )} | ||
| {(status.status === "error" || status.status === "no_data") && ( | ||
| <p className="mt-2 text-xs text-red-400"> | ||
| {status.message ?? "Recording failed"} | ||
| </p> | ||
| )} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default RecordButton; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,9 @@ | |
|
|
||
| from .service import WorkflowService | ||
| from .views import ( | ||
| RecordingStatusResponse, | ||
| WorkflowCancelResponse, | ||
| WorkflowMetadataListResponse, | ||
| WorkflowExecuteRequest, | ||
| WorkflowExecuteResponse, | ||
| WorkflowListResponse, | ||
|
|
@@ -18,9 +20,16 @@ | |
|
|
||
| router = APIRouter(prefix='/api/workflows') | ||
|
|
||
| # Single shared service: per-request instances would lose in-memory task | ||
| # and recording state, breaking /tasks/{id}/status and recording control. | ||
| _service: WorkflowService | None = None | ||
|
|
||
|
|
||
| def get_service() -> WorkflowService: | ||
| return WorkflowService() | ||
| global _service | ||
| if _service is None: | ||
| _service = WorkflowService() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Concurrent Prompt for AI agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Completed task results now accumulate for the backend process lifetime, so repeated executions cause unbounded memory growth. Expire or cap completed Prompt for AI agents |
||
| return _service | ||
|
|
||
|
|
||
| @router.get('', response_model=WorkflowListResponse) | ||
|
|
@@ -30,6 +39,13 @@ async def list_workflows(): | |
| return WorkflowListResponse(workflows=workflows) | ||
|
|
||
|
|
||
| # NOTE: must be registered before GET /{name}, which would otherwise capture 'metadata' | ||
| @router.get('/metadata', response_model=WorkflowMetadataListResponse) | ||
| async def list_workflow_metadata(): | ||
| service = get_service() | ||
| return WorkflowMetadataListResponse(workflows=service.list_workflow_metadata()) | ||
|
|
||
|
|
||
| @router.get('/{name}', response_model=str) | ||
| async def get_workflow(name: str): | ||
| service = get_service() | ||
|
|
@@ -117,3 +133,21 @@ async def cancel_workflow(task_id: str): | |
| if not result.success and result.message == 'Task not found': | ||
| raise HTTPException(status_code=404, detail=f'Task {task_id} not found') | ||
| return result | ||
|
|
||
|
|
||
| @router.post('/recordings/start', response_model=RecordingStatusResponse) | ||
| async def start_recording(): | ||
| service = get_service() | ||
| return await service.start_recording() | ||
|
|
||
|
|
||
| @router.post('/recordings/stop', response_model=RecordingStatusResponse) | ||
| async def stop_recording(): | ||
| service = get_service() | ||
| return await service.stop_recording() | ||
|
|
||
|
|
||
| @router.get('/recordings/status', response_model=RecordingStatusResponse) | ||
| async def recording_status(): | ||
| service = get_service() | ||
| return service.recording_status() | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: This replaced the typed OpenAPI client call (previously
fetchClient.GET(...)) with a rawfetchto a hardcodedhttp://localhost:8000, duplicating the base URL that already lives inui/src/lib/api/index.tsand bypassing the$api/typed client used everywhere else in this component. Because the response is cast blindly (as Array<{ file: string } & WorkflowMetadata>),data.workflows.map(...)will throw if the response shape changes or an error body is returned. Since the new backend endpoints aren't in the checked-inopenapi.json/apigen, they were forced to bypass the typed client. Consider regenerating the OpenAPI client (npm run type-gen-update) so/api/workflows/metadatais represented, then call it throughfetchClient/$api(or a shared base-URL constant) instead of a hardcoded URL, and validateres.okbefore parsing.Prompt for AI agents