-
Notifications
You must be signed in to change notification settings - Fork 347
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 7 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,125 @@ | ||
| 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", | ||
| }); | ||
| applyStatus((await res.json()) as RecordingStatus); | ||
| if ((status.status as string) === "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 |
|---|---|---|
|
|
@@ -25,7 +25,8 @@ import Sidebar from "./sidebar"; | |
| import { NodeConfigMenu } from "./node-config-menu"; | ||
| import { PlayButton } from "./play-button"; | ||
| import NoWorkflowsMessage from "./no-workflow-message"; | ||
| import { $api } from "../lib/api"; | ||
| import { $api, fetchClient } from "../lib/api"; | ||
| import { useQueryClient } from "@tanstack/react-query"; | ||
|
|
||
| const WorkflowLayout: React.FC = () => { | ||
| const [selected, setSelected] = useState<string | null>(null); | ||
|
|
@@ -37,7 +38,20 @@ const WorkflowLayout: React.FC = () => { | |
| const [savedNodePositions, setSavedNodePositions] = useState< | ||
| Record<string, Record<string, { x: number; y: number }>> | ||
| >({}); | ||
| const [allWorkflowsMetadata, setAllWorkflowsMetadata] = useState< | ||
| Record<string, WorkflowMetadata> | ||
| >({}); | ||
| const { fitView } = useReactFlow(); | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| // After a GUI recording is saved, reload the list and select the new file | ||
| const handleRecordingSaved = useCallback( | ||
| (workflowFile: string) => { | ||
| queryClient.invalidateQueries(); | ||
| setSelected(workflowFile); | ||
| }, | ||
| [queryClient] | ||
| ); | ||
|
|
||
| // ----- Queries using $api ----- | ||
| // Fetch all workflows | ||
|
|
@@ -159,6 +173,37 @@ const WorkflowLayout: React.FC = () => { | |
| } | ||
| }, [workflows, selected]); | ||
|
|
||
| // Fetch metadata for every workflow so sidebar items show their real | ||
| // names instead of a permanent "Loading workflow…" placeholder | ||
| useEffect(() => { | ||
| if (!workflows.length) return; | ||
| let cancelled = false; | ||
| (async () => { | ||
| const entries = await Promise.all( | ||
| workflows.map(async (name) => { | ||
| try { | ||
| const { data } = await fetchClient.GET("/api/workflows/{name}", { | ||
|
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: This effect fetches the entire workflow document (including every step) for all workflows — N parallel GETs on mount and again whenever Prompt for AI agents |
||
| params: { path: { name } }, | ||
| }); | ||
| if (!data) return null; | ||
| const parsed = typeof data === "string" ? JSON.parse(data) : data; | ||
| return [name, parsed as WorkflowMetadata] as const; | ||
| } catch { | ||
| return null; | ||
| } | ||
| }) | ||
| ); | ||
| if (!cancelled) { | ||
| setAllWorkflowsMetadata( | ||
| Object.fromEntries(entries.filter((e): e is NonNullable<typeof e> => e !== null)) | ||
| ); | ||
| } | ||
| })(); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [workflows]); | ||
|
|
||
| const isLoading = isLoadingWorkflows || isLoadingSelectedWorkflow; | ||
|
|
||
| if (isLoading) { | ||
|
|
@@ -183,6 +228,8 @@ const WorkflowLayout: React.FC = () => { | |
| onSelect={setSelected} | ||
| selected={selected} | ||
| workflowMetadata={workflowMetadata} | ||
| allWorkflowsMetadata={allWorkflowsMetadata} | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| onRecordingSaved={handleRecordingSaved} | ||
| onUpdateMetadata={async (metadata: WorkflowMetadata) => { | ||
| if (selected) { | ||
| await updateWorkflowMetadata(selected, metadata); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
|
|
||
| from .service import WorkflowService | ||
| from .views import ( | ||
| RecordingStatusResponse, | ||
| WorkflowCancelResponse, | ||
| WorkflowExecuteRequest, | ||
| WorkflowExecuteResponse, | ||
|
|
@@ -18,9 +19,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) | ||
|
|
@@ -117,3 +125,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.