feat(runner): queue workflow runs with BullMQ (phase A)

Enqueue HTTP, cron, and manual runs via Redis/BullMQ; workers execute
asynchronously with configurable concurrency. Triggers return 202 and
clients poll GET /api/runs/:id for progress (queued → running → done).

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-19 04:25:21 +00:00
co-authored by nsrb
parent 93362208a2
commit a95e6d7bc8
14 changed files with 716 additions and 201 deletions
+4 -2
View File
@@ -271,8 +271,10 @@ export function useRun(id) {
queryKey: ["runs", id],
queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data,
enabled: Boolean(id),
refetchInterval: (query) =>
query.state.data?.status === "running" ? 2000 : false,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "running" || status === "queued" ? 1500 : false;
},
});
}
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
import { errorMessage } from "../../api/client.js";
import { api, errorMessage } from "../../api/client.js";
import { useRunWorkflow } from "../../api/hooks.js";
import { CodeEditor } from "../CodeEditor.jsx";
import { StatusBadge } from "../../lib/format.jsx";
@@ -14,11 +14,25 @@ import {
import { SchemaTooltip } from "./FieldHelp.jsx";
const SAVE_DEBOUNCE_MS = 300;
const POLL_MS = 1500;
function initialDataJson(owner, file, defaultData) {
return prettyJson(overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)));
}
function isTerminalStatus(status) {
return status === "success" || status === "failed";
}
async function waitForRun(runId, { signal } = {}) {
while (!signal?.aborted) {
const run = (await api.get(`/runs/${encodeURIComponent(runId)}`)).data;
if (isTerminalStatus(run.status)) return run;
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}
throw new Error("polling aborted");
}
export function WorkflowTestPanel({
owner,
file,
@@ -34,8 +48,10 @@ export function WorkflowTestPanel({
const [inputTouched, setInputTouched] = useState(() => readWorkflowTestData(owner, file) != null);
const [parseError, setParseError] = useState(null);
const [last, setLast] = useState(null);
const [polling, setPolling] = useState(false);
const [expanded, setExpanded] = useState(false);
const saveTimer = useRef(null);
const pollAbort = useRef(null);
const seedJson = prettyJson(
overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)),
@@ -44,6 +60,7 @@ export function WorkflowTestPanel({
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
pollAbort.current?.abort();
};
}, []);
@@ -79,16 +96,42 @@ export function WorkflowTestPanel({
return;
}
persist(data);
pollAbort.current?.abort();
const controller = new AbortController();
pollAbort.current = controller;
run.mutate(
{ owner, file, data },
{
onSuccess: (res) => {
onSuccess: async (res) => {
const runId = res.runId ?? null;
setLast({
status: res.status ?? "success",
runId: res.runId ?? null,
result: res.result,
status: res.status ?? "queued",
runId,
result: null,
error: null,
});
if (!runId) return;
setPolling(true);
try {
const finished = await waitForRun(runId, { signal: controller.signal });
setLast({
status: finished.status,
runId,
result: finished.output,
error: finished.error ?? null,
});
} catch (err) {
if (controller.signal.aborted) return;
setLast({
status: "failed",
runId,
result: null,
error: errorMessage(err),
});
} finally {
if (!controller.signal.aborted) setPolling(false);
}
},
onError: (err) => {
const runId = err?.response?.data?.runId;
@@ -107,11 +150,15 @@ export function WorkflowTestPanel({
? prettyJson(last.error ? { error: last.error } : last.result)
: "";
const busy = run.isPending || polling;
function close() {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
pollAbort.current?.abort();
setPolling(false);
try {
persist(JSON.parse(dataJson || "null"));
} catch {
@@ -134,8 +181,8 @@ export function WorkflowTestPanel({
<div className="min-w-0 flex-1">
<h3 className="font-bold text-lg">Test workflow</h3>
<p className="text-sm opacity-70">
Runs the <strong>saved</strong> YAML and writes an Event. Manual run works even when
the workflow is disabled. Save before testing unsaved edits.
Enqueues the <strong>saved</strong> YAML and polls the Event until it finishes.
Manual run works even when the workflow is disabled. Save before testing unsaved edits.
</p>
</div>
<button
@@ -192,10 +239,10 @@ export function WorkflowTestPanel({
<button
type="button"
className="btn btn-primary"
disabled={disabled || run.isPending}
disabled={disabled || busy}
onClick={onRun}
>
{run.isPending ? (
{busy ? (
<span className="loading loading-spinner loading-xs" />
) : (
<LuPlay className="size-4" />
+10 -2
View File
@@ -13,9 +13,11 @@ export function StatusBadge({ status }) {
? "badge-error"
: status === "running"
? "badge-warning"
: status === "skipped"
: status === "queued"
? "badge-info"
: "badge-ghost";
: status === "skipped"
? "badge-ghost"
: "badge-ghost";
return <span className={`badge badge-sm ${cls}`}>{status ?? "—"}</span>;
}
@@ -53,6 +55,12 @@ export function WorkflowStatusBadge({ workflow }) {
failed
</span>,
);
} else if (workflow.lastStatus === "queued") {
badges.push(
<span key="queued" className="badge badge-info badge-sm">
queued
</span>,
);
} else if (workflow.lastStatus === "running") {
badges.push(
<span key="running" className="badge badge-warning badge-sm">
+2 -2
View File
@@ -43,7 +43,7 @@ export function HomePage() {
<div className="stat-figure text-warning">
<LuActivity className="size-7" />
</div>
<div className="stat-title">Running</div>
<div className="stat-title">Active</div>
<div className="stat-value text-2xl">{data.running?.length ?? 0}</div>
</div>
<div className="stat">
@@ -58,7 +58,7 @@ export function HomePage() {
</div>
<section>
<h2 className="text-lg font-semibold mb-2">Running</h2>
<h2 className="text-lg font-semibold mb-2">Queued / running</h2>
<RunList runs={data.running} empty="None" />
</section>