Skip to content
127 changes: 127 additions & 0 deletions ui/src/components/record-button.tsx
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;
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
47 changes: 47 additions & 0 deletions ui/src/components/workflow-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { NodeConfigMenu } from "./node-config-menu";
import { PlayButton } from "./play-button";
import NoWorkflowsMessage from "./no-workflow-message";
import { $api } 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 @@ -75,6 +89,12 @@ const WorkflowLayout: React.FC = () => {
await updateMetadataMutation.mutateAsync({
body: { name, metadata: metadata as unknown as Record<string, never> },
});
// Keep the sidebar cache in sync, or the row's label reverts to the
// pre-save name as soon as another workflow is selected.
setAllWorkflowsMetadata((prev) => ({
...prev,
[name]: { ...prev[name], ...metadata },
}));
},
[updateMetadataMutation]
);
Expand Down Expand Up @@ -159,6 +179,31 @@ const WorkflowLayout: React.FC = () => {
}
}, [workflows, selected]);

// Fetch lightweight metadata for every workflow in ONE request so sidebar
// items show their real names instead of a "Loading workflow…" placeholder
useEffect(() => {
if (!workflows.length) return;
let cancelled = false;
(async () => {
try {
const res = await fetch("http://localhost:8000/api/workflows/metadata");

@cubic-dev-ai cubic-dev-ai Bot Aug 11, 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 replaced the typed OpenAPI client call (previously fetchClient.GET(...)) with a raw fetch to a hardcoded http://localhost:8000, duplicating the base URL that already lives in ui/src/lib/api/index.ts and 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-in openapi.json/apigen, they were forced to bypass the typed client. Consider regenerating the OpenAPI client (npm run type-gen-update) so /api/workflows/metadata is represented, then call it through fetchClient/$api (or a shared base-URL constant) instead of a hardcoded URL, and validate res.ok before parsing.

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 189:

<comment>This replaced the typed OpenAPI client call (previously `fetchClient.GET(...)`) with a raw `fetch` to a hardcoded `http://localhost:8000`, duplicating the base URL that already lives in `ui/src/lib/api/index.ts` and 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-in `openapi.json`/`apigen`, they were forced to bypass the typed client. Consider regenerating the OpenAPI client (`npm run type-gen-update`) so `/api/workflows/metadata` is represented, then call it through `fetchClient`/`$api` (or a shared base-URL constant) instead of a hardcoded URL, and validate `res.ok` before parsing.</comment>

<file context>
@@ -173,30 +179,24 @@ const WorkflowLayout: React.FC = () => {
-          Object.fromEntries(entries.filter((e): e is NonNullable<typeof e> => e !== null))
-        );
+      try {
+        const res = await fetch("http://localhost:8000/api/workflows/metadata");
+        const data = (await res.json()) as {
+          workflows: Array<{ file: string } & WorkflowMetadata>;
</file context>
Fix with cubic

const data = (await res.json()) as {
workflows: Array<{ file: string } & WorkflowMetadata>;
};
if (!cancelled) {
setAllWorkflowsMetadata(
Object.fromEntries(data.workflows.map((w) => [w.file, w]))
);
}
} catch {
/* list still renders with filename placeholders */
}
})();
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
36 changes: 35 additions & 1 deletion workflows/backend/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

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

@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 All @@ -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()
Expand Down Expand Up @@ -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()
Loading