Skip to content
125 changes: 125 additions & 0 deletions ui/src/components/record-button.tsx
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();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
} 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;
5 changes: 5 additions & 0 deletions ui/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import WorkflowItem from "./workflow-item";
import RecordButton from "./record-button";
import { WorkflowMetadata } from "../types/workflow-layout.types";

interface SidebarProps {
Expand All @@ -9,6 +10,7 @@ interface SidebarProps {
workflowMetadata: WorkflowMetadata | null;
onUpdateMetadata: (metadata: WorkflowMetadata) => Promise<void>;
allWorkflowsMetadata?: Record<string, WorkflowMetadata>;
onRecordingSaved: (workflowFile: string) => void;
}

export const Sidebar: React.FC<SidebarProps> = ({
Expand All @@ -18,6 +20,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
workflowMetadata,
onUpdateMetadata,
allWorkflowsMetadata = {},
onRecordingSaved,
}) => (
<aside className="w-[250px] border-r border-[#542e2e] p-3 bg-[#2a2a2a] text-white flex flex-col overflow-auto">
{/* logo */}
Expand All @@ -31,6 +34,8 @@ export const Sidebar: React.FC<SidebarProps> = ({

<h3 className="text-lg text-[#ddd]">Workflows</h3>

<RecordButton onRecordingSaved={onRecordingSaved} />

<ul className="m-0 p-0">
{workflows.map((id) => (
<WorkflowItem
Expand Down
49 changes: 48 additions & 1 deletion ui/src/components/workflow-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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}", {

@cubic-dev-ai cubic-dev-ai Bot Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 workflows changes — but the Sidebar/WorkflowItem only use the result to render each non-selected row's name and version (the details/edit panel always reads the selected workflow's separate workflowMetadata). Fetching a full document per workflow just to display a name is wasteful and can fan out a large number of requests for repos with many workflows. Consider using a lighter metadata/list endpoint (or deriving names from the existing /api/workflows response) instead of downloading each full workflow, and it's worth logging failures in the catch so a failed fetch doesn't silently leave the row stuck on the 'Loading workflow…' placeholder.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ui/src/components/workflow-layout.tsx, line 174:

<comment>This effect fetches the *entire* workflow document (including every step) for all workflows — N parallel GETs on mount and again whenever `workflows` changes — but the Sidebar/WorkflowItem only use the result to render each non-selected row's name and version (the details/edit panel always reads the selected workflow's separate `workflowMetadata`). Fetching a full document per workflow just to display a name is wasteful and can fan out a large number of requests for repos with many workflows. Consider using a lighter metadata/list endpoint (or deriving names from the existing /api/workflows response) instead of downloading each full workflow, and it's worth logging failures in the `catch` so a failed fetch doesn't silently leave the row stuck on the 'Loading workflow…' placeholder.</comment>

<file context>
@@ -159,6 +162,37 @@ const WorkflowLayout: React.FC = () => {
+      const entries = await Promise.all(
+        workflows.map(async (name) => {
+          try {
+            const { data } = await fetchClient.GET("/api/workflows/{name}", {
+              params: { path: { name } },
+            });
</file context>
Fix with cubic

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) {
Expand All @@ -183,6 +228,8 @@ const WorkflowLayout: React.FC = () => {
onSelect={setSelected}
selected={selected}
workflowMetadata={workflowMetadata}
allWorkflowsMetadata={allWorkflowsMetadata}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
onRecordingSaved={handleRecordingSaved}
onUpdateMetadata={async (metadata: WorkflowMetadata) => {
if (selected) {
await updateWorkflowMetadata(selected, metadata);
Expand Down
28 changes: 27 additions & 1 deletion workflows/backend/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .service import WorkflowService
from .views import (
RecordingStatusResponse,
WorkflowCancelResponse,
WorkflowExecuteRequest,
WorkflowExecuteResponse,
Expand All @@ -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()

@cubic-dev-ai cubic-dev-ai Bot Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Concurrent /execute requests can run another task’s workflow and close each other’s browser. Keep task/recording state shared, but create workflow/browser execution state per task (or serialize execution).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At workflows/backend/routers.py, line 30:

<comment>Concurrent `/execute` requests can run another task’s workflow and close each other’s browser. Keep task/recording state shared, but create workflow/browser execution state per task (or serialize execution).</comment>

<file context>
@@ -18,9 +19,16 @@
-	return WorkflowService()
+	global _service
+	if _service is None:
+		_service = WorkflowService()
+	return _service
 
</file context>
Fix with cubic

@cubic-dev-ai cubic-dev-ai Bot Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 active_tasks entries after a status-retention window.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At workflows/backend/routers.py, line 30:

<comment>Completed task results now accumulate for the backend process lifetime, so repeated executions cause unbounded memory growth. Expire or cap completed `active_tasks` entries after a status-retention window.</comment>

<file context>
@@ -18,9 +19,16 @@
-	return WorkflowService()
+	global _service
+	if _service is None:
+		_service = WorkflowService()
+	return _service
 
</file context>
Fix with cubic

return _service


@router.get('', response_model=WorkflowListResponse)
Expand Down Expand Up @@ -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()
Loading