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
+8 -1
View File
@@ -64,18 +64,25 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
| `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. | | `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. |
| `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. | | `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
| `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. | | `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. |
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. |
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
| `JFLOW_WORKER_CONCURRENCY` | `5` | Max parallel workflow jobs per worker process. |
| `JFLOW_ROLE` | `all` | `all` (API + cron producer + worker), `api` (HTTP/admin/cron enqueue only), or `worker` (consume queue only). |
| `JFLOW_LOG_LEVEL` | `debug` | Pino level | | `JFLOW_LOG_LEVEL` | `debug` | Pino level |
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune | | `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev | | `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
| `PORT` | `9000` | HTTP port | | `PORT` | `9000` | HTTP port |
| `NODE_ENV` | — | Set `production` for secure cookies | | `NODE_ENV` | — | Set `production` for secure cookies |
Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { runId, status: "queued" }` immediately; poll `GET /api/runs/:id` for progress (`queued` → `running` → `success` \| `failed`). Cron remains an in-process producer that enqueues jobs on each tick.
## Production ## Production
```bash ```bash
pnpm install pnpm install
pnpm build pnpm build
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... NODE_ENV=production pnpm start # Redis must be reachable at REDIS_URL
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 NODE_ENV=production pnpm start
``` ```
The server serves `packages/web/dist` when that folder exists. The server serves `packages/web/dist` when that folder exists.
@@ -0,0 +1,24 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.alterTable("workflow_runs", (t) => {
t.text("job_id");
t.text("queued_at");
});
await knex.schema.raw(
"CREATE INDEX workflow_runs_job_id_idx ON workflow_runs (job_id)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.raw("DROP INDEX IF EXISTS workflow_runs_job_id_idx");
await knex.schema.alterTable("workflow_runs", (t) => {
t.dropColumn("job_id");
t.dropColumn("queued_at");
});
}
+2
View File
@@ -17,7 +17,9 @@
"axios": "^1.19.0", "axios": "^1.19.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"bullmq": "^6.1.2",
"fastify": "^5.12.0", "fastify": "^5.12.0",
"ioredis": "^6.0.0",
"jsonata": "^2.2.2", "jsonata": "^2.2.2",
"knex": "^3.3.0", "knex": "^3.3.0",
"mustache": "^4.2.0", "mustache": "^4.2.0",
+134 -77
View File
@@ -35,6 +35,7 @@ import {
buildFailureAlertData, buildFailureAlertData,
resolveFailureTriggerConfig, resolveFailureTriggerConfig,
} from "./trigger-failure.js"; } from "./trigger-failure.js";
import { enqueueWorkflowJob } from "./workflow-queue.js";
/** /**
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry * @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
@@ -56,8 +57,10 @@ function hasWorkflowTrigger(workflow) {
/** /**
* @param {import("fastify").FastifyInstance} server * @param {import("fastify").FastifyInstance} server
* @param {{ queue?: import("bullmq").Queue | null }} [opts]
*/ */
export function createRegistry(server) { export function createRegistry(server, opts = {}) {
const queue = opts.queue ?? null;
/** @type {Map<string, WorkflowEntry>} */ /** @type {Map<string, WorkflowEntry>} */
const workflows = new Map(); const workflows = new Map();
/** @type {Map<string, string>} */ /** @type {Map<string, string>} */
@@ -261,26 +264,27 @@ export function createRegistry(server) {
} }
} }
const result = await runWorkflow( const result = await enqueueWorkflow(
mapped.key, mapped.key,
{ data: req.body }, { data: req.body },
{ type: "http", detail: `${method} ${url}` }, { type: "http", detail: `${method} ${url}` },
); );
if (result.status === "failed") { if (result.status === "failed") {
return reply.code(500).send({ return reply.code(result.runId ? 500 : 404).send({
runId: result.runId, runId: result.runId,
status: result.status,
error: result.error, error: result.error,
}); });
} }
const defaultBody = { const defaultBody = {
runId: result.runId, runId: result.runId,
result: result.result, status: result.status,
}; };
if (typeof liveTrigger.response === "string" && liveTrigger.response) { if (typeof liveTrigger.response === "string" && liveTrigger.response) {
return sendSuccessPage(reply, liveTrigger.response, defaultBody); return sendSuccessPage(reply, liveTrigger.response, defaultBody);
} }
return reply.send(defaultBody); return reply.code(202).send(defaultBody);
} }
function registerCronTriggers() { function registerCronTriggers() {
@@ -308,7 +312,7 @@ export function createRegistry(server) {
schedule, schedule,
() => { () => {
log.debug(`cron firing ${key} (${schedule})`); log.debug(`cron firing ${key} (${schedule})`);
return runWorkflow( return enqueueWorkflow(
key, key,
{ data: workflow.data ?? null }, { data: workflow.data ?? null },
{ type: "cron", detail: schedule }, { type: "cron", detail: schedule },
@@ -544,14 +548,13 @@ export function createRegistry(server) {
); );
} }
const destKey = resolveWorkflowTriggerKey(owner, name); const destKey = resolveWorkflowTriggerKey(owner, name);
return runWorkflow( return enqueueWorkflow(
destKey, destKey,
{ data }, { data },
{ type: "workflow", detail: parentKey }, { type: "workflow", detail: parentKey },
{ {
parentRunId, parentRunId,
depth: depth + 1, depth: depth + 1,
detach: true,
}, },
); );
}, },
@@ -696,32 +699,36 @@ export function createRegistry(server) {
"triggering failure alert workflow", "triggering failure alert workflow",
); );
await runWorkflow( await enqueueWorkflow(
destKey, destKey,
{ data: alertData }, { data: alertData },
{ type: "workflow", detail: `failure:${opts.key}` }, { type: "workflow", detail: `failure:${opts.key}` },
{ {
parentRunId: opts.runId, parentRunId: opts.runId,
depth: opts.depth + 1, depth: opts.depth + 1,
detach: true,
}, },
); );
} }
/** /**
* Create a queued run and push a BullMQ job. Returns immediately.
*
* @param {string} key * @param {string} key
* @param {{ data?: unknown, context?: unknown }} context * @param {{ data?: unknown, context?: unknown }} context
* @param {{ type: string, detail?: string | null }} trigger * @param {{ type: string, detail?: string | null }} trigger
* @param {{ * @param {{
* parentRunId?: string | null, * parentRunId?: string | null,
* depth?: number, * depth?: number,
* detach?: boolean,
* }} [opts] * }} [opts]
*/ */
async function runWorkflow(key, context, trigger, opts = {}) { async function enqueueWorkflow(key, context, trigger, opts = {}) {
const parentRunId = opts.parentRunId ?? null; const parentRunId = opts.parentRunId ?? null;
const depth = opts.depth ?? 0; const depth = opts.depth ?? 0;
const detach = opts.detach === true;
if (!queue) {
log.error({ workflow: key }, "workflow queue is not configured");
return { runId: null, status: "failed", error: "workflow queue is not configured" };
}
const entry = workflows.get(key); const entry = workflows.get(key);
if (!entry) { if (!entry) {
@@ -734,82 +741,130 @@ export function createRegistry(server) {
log.debug({ workflow: key, trigger }, "skipping disabled workflow"); log.debug({ workflow: key, trigger }, "skipping disabled workflow");
return { runId: null, status: "failed", error: "workflow disabled" }; return { runId: null, status: "failed", error: "workflow disabled" };
} }
const input = context.data ?? workflow.data ?? null;
const run = await store.startRun({ const run = await store.startRun({
owner, owner,
workflow: key, workflow: key,
workflowName: workflow?.name, workflowName: workflow?.name,
trigger, trigger,
input: context.data, input,
parentRunId, parentRunId,
status: "queued",
}); });
const runLog = log.child({ runId: run.id, owner, workflow: key }); const runLog = log.child({ runId: run.id, owner, workflow: key });
runLog.debug(detach ? "running workflow (detached)" : "running workflow");
const initialCtx = { try {
data: context.data ?? workflow.data ?? null, const job = await enqueueWorkflowJob(queue, {
context: normalizeContext(context.context), runId: run.id,
}; key,
depth,
const execute = async () => {
let ctx = initialCtx;
try {
const compiled = compileWorkflowScripts(workflow.scripts);
if (compiled.dagMode) {
ctx = await runDagSteps(
compiled,
ctx,
run.id,
runLog,
key,
owner,
depth,
);
} else {
ctx = await runLinearSteps(
compiled,
ctx,
run.id,
runLog,
key,
owner,
depth,
);
}
await store.finishRun(run.id, "success", storedEnvelope(ctx));
return { runId: run.id, status: "success", result: storedEnvelope(ctx) };
} catch (err) {
runLog.error({ err }, "workflow failed");
const error = err instanceof Error ? err.message : String(err);
await store.finishRun(run.id, "failed", null, err);
try {
await maybeTriggerFailureWorkflow({
key,
owner,
workflow,
runId: run.id,
trigger,
error,
depth,
});
} catch (alertErr) {
runLog.error({ err: alertErr }, "failed to trigger failure alert workflow");
}
return {
runId: run.id,
status: "failed",
error,
};
}
};
if (detach) {
execute().catch((err) => {
runLog.error({ err }, "detached workflow failed unexpectedly");
}); });
return { runId: run.id, status: "started" }; await store.setRunJobId(run.id, String(job.id));
runLog.debug({ jobId: job.id }, "workflow queued");
return { runId: run.id, status: "queued", jobId: String(job.id) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
runLog.error({ err }, "failed to enqueue workflow");
await store.finishRun(run.id, "failed", null, err);
return { runId: run.id, status: "failed", error: message };
}
}
/**
* Execute a previously queued run (BullMQ worker entrypoint).
*
* @param {{ runId: string, key: string, depth?: number }} jobData
*/
async function executeQueuedRun(jobData) {
const runId = jobData.runId;
const key = jobData.key;
const depth = jobData.depth ?? 0;
const entry = workflows.get(key);
if (!entry) {
await store.finishRun(runId, "failed", null, new Error("workflow not found"));
return { runId, status: "failed", error: "workflow not found" };
} }
return execute(); const existing = await store.getRun(runId);
if (!existing) {
return { runId, status: "failed", error: "run not found" };
}
if (existing.status === "success" || existing.status === "failed") {
return { runId, status: existing.status };
}
const marked = await store.markRunRunning(runId);
if (!marked.updated && existing.status !== "running") {
return { runId, status: existing.status };
}
const { owner, workflow } = entry;
const runLog = log.child({ runId, owner, workflow: key });
runLog.debug("running queued workflow");
const trigger = {
type: existing.trigger_type,
detail: existing.trigger_detail,
};
const initialCtx = {
data: existing.input ?? workflow.data ?? null,
context: normalizeContext(null),
};
try {
const compiled = compileWorkflowScripts(workflow.scripts);
let ctx;
if (compiled.dagMode) {
ctx = await runDagSteps(
compiled,
initialCtx,
runId,
runLog,
key,
owner,
depth,
);
} else {
ctx = await runLinearSteps(
compiled,
initialCtx,
runId,
runLog,
key,
owner,
depth,
);
}
await store.finishRun(runId, "success", storedEnvelope(ctx));
return { runId, status: "success", result: storedEnvelope(ctx) };
} catch (err) {
runLog.error({ err }, "workflow failed");
const error = err instanceof Error ? err.message : String(err);
await store.finishRun(runId, "failed", null, err);
try {
await maybeTriggerFailureWorkflow({
key,
owner,
workflow,
runId,
trigger,
error,
depth,
});
} catch (alertErr) {
runLog.error({ err: alertErr }, "failed to trigger failure alert workflow");
}
return { runId, status: "failed", error };
}
}
/**
* @deprecated Prefer enqueueWorkflow; kept as alias for callers.
*/
async function runWorkflow(key, context, trigger, opts = {}) {
return enqueueWorkflow(key, context, trigger, opts);
} }
function reregister() { function reregister() {
@@ -842,6 +897,8 @@ export function createRegistry(server) {
registerPruneJob, registerPruneJob,
reregister, reregister,
runWorkflow, runWorkflow,
enqueueWorkflow,
executeQueuedRun,
referencedScripts, referencedScripts,
}; };
} }
+150 -95
View File
@@ -22,6 +22,12 @@ import httpPagesPlugin from "./src/api/http-pages.js";
import httpAuthsPlugin from "./src/api/http-auths.js"; import httpAuthsPlugin from "./src/api/http-auths.js";
import { WEB_DIST } from "./paths.js"; import { WEB_DIST } from "./paths.js";
import { resolveSecretsKeyMaterial } from "./secrets.js"; import { resolveSecretsKeyMaterial } from "./secrets.js";
import {
closeRedis,
createWorkflowQueue,
createWorkflowWorker,
getRedisUrl,
} from "./workflow-queue.js";
await migrate(); await migrate();
enableLogPersistence(); enableLogPersistence();
@@ -42,6 +48,20 @@ try {
process.exit(1); process.exit(1);
} }
const role = (process.env.JFLOW_ROLE || "all").toLowerCase();
const runApi = role === "all" || role === "api";
const runWorker = role === "all" || role === "worker";
log.info({ redis: getRedisUrl(), role }, "starting jerapah-flow");
const workflowQueue = createWorkflowQueue();
try {
await workflowQueue.waitUntilReady();
} catch (err) {
log.error({ err, redis: getRedisUrl() }, "failed to connect to Redis");
process.exit(1);
}
const server = fastify({ loggerInstance: log }); const server = fastify({ loggerInstance: log });
await server.register(cookie); await server.register(cookie);
@@ -71,98 +91,129 @@ server.decorate("requireAdmin", async function requireAdmin(req, reply) {
} }
}); });
const registry = createRegistry(server); const registry = createRegistry(server, { queue: workflowQueue });
registry.registerWorkflows(); registry.registerWorkflows();
registry.registerHttpTriggers(); if (runApi) {
registry.registerCronTriggers(); registry.registerHttpTriggers();
registry.registerPruneJob(); registry.registerCronTriggers();
registry.registerPruneJob();
}
await server.register( /** @type {import("bullmq").Worker | null} */
async (api) => { let workflowWorker = null;
api.addHook("onRequest", async (req, reply) => { if (runWorker) {
const raw = (req.url || "").split("?")[0]; workflowWorker = createWorkflowWorker(async (job) => {
const stripped = raw.replace(/^\/api/, "") || "/"; const data = /** @type {{ runId?: string, key?: string, depth?: number }} */ (
const routeUrl = req.routeOptions?.url || stripped; job.data ?? {}
const open = );
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) || if (typeof data.runId !== "string" || typeof data.key !== "string") {
OPEN_API_ROUTES.has(`${req.method} ${stripped}`); throw new Error("invalid workflow job payload");
if (open) return;
await server.authenticate(req, reply);
});
await api.register(authPlugin);
await api.register(usersPlugin);
await api.register(secretsPlugin);
await api.register(variablesPlugin);
await api.register(kvPlugin);
await api.register(httpPagesPlugin);
await api.register(httpAuthsPlugin);
await api.register(scriptsPluginFactory(registry));
await api.register(workflowsPluginFactory(registry));
await api.register(runsPlugin);
await api.register(dashboardPluginFactory(registry));
},
{ prefix: "/api" },
);
server.post(
"/admin/workflows/reregister",
{ onRequest: [server.authenticate] },
async (_req, reply) => {
registry.reregister();
return reply.send({ message: "Workflows refreshed" });
},
);
server.get(
"/admin/runs",
{ onRequest: [server.authenticate] },
async (req, reply) => {
const q = /** @type {Record<string, string | undefined>} */ (req.query);
const limit = q.limit ? Number(q.limit) : undefined;
const runs = await store.listRuns({
owner: q.owner,
workflow: q.workflow,
status: q.status,
limit: Number.isFinite(limit) ? limit : undefined,
before: q.before,
});
return reply.send({ runs });
},
);
server.get(
"/admin/runs/:id",
{ onRequest: [server.authenticate] },
async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const run = await store.getRun(id);
if (!run) {
return reply.code(404).send({ error: "run not found" });
} }
return reply.send(run); const result = await registry.executeQueuedRun({
}, runId: data.runId,
); key: data.key,
depth: data.depth ?? 0,
if (fs.existsSync(WEB_DIST)) { });
await server.register(fastifyStatic, { if (result.status === "failed") {
root: WEB_DIST, throw new Error(result.error || "workflow failed");
wildcard: false,
});
server.setNotFoundHandler((req, reply) => {
const url = req.raw.url ?? "";
if (
url.startsWith("/api") ||
url.startsWith("/u/") ||
url.startsWith("/admin")
) {
return reply.code(404).send({ error: "not found" });
} }
return reply.sendFile("index.html"); return result;
}); });
} }
if (runApi) {
await server.register(
async (api) => {
api.addHook("onRequest", async (req, reply) => {
const raw = (req.url || "").split("?")[0];
const stripped = raw.replace(/^\/api/, "") || "/";
const routeUrl = req.routeOptions?.url || stripped;
const open =
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) ||
OPEN_API_ROUTES.has(`${req.method} ${stripped}`);
if (open) return;
await server.authenticate(req, reply);
});
await api.register(authPlugin);
await api.register(usersPlugin);
await api.register(secretsPlugin);
await api.register(variablesPlugin);
await api.register(kvPlugin);
await api.register(httpPagesPlugin);
await api.register(httpAuthsPlugin);
await api.register(scriptsPluginFactory(registry));
await api.register(workflowsPluginFactory(registry));
await api.register(runsPlugin);
await api.register(dashboardPluginFactory(registry));
},
{ prefix: "/api" },
);
server.post(
"/admin/workflows/reregister",
{ onRequest: [server.authenticate] },
async (_req, reply) => {
registry.reregister();
return reply.send({ message: "Workflows refreshed" });
},
);
server.get(
"/admin/runs",
{ onRequest: [server.authenticate] },
async (req, reply) => {
const q = /** @type {Record<string, string | undefined>} */ (req.query);
const limit = q.limit ? Number(q.limit) : undefined;
const runs = await store.listRuns({
owner: q.owner,
workflow: q.workflow,
status: q.status,
limit: Number.isFinite(limit) ? limit : undefined,
before: q.before,
});
return reply.send({ runs });
},
);
server.get(
"/admin/runs/:id",
{ onRequest: [server.authenticate] },
async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const run = await store.getRun(id);
if (!run) {
return reply.code(404).send({ error: "run not found" });
}
return reply.send(run);
},
);
if (fs.existsSync(WEB_DIST)) {
await server.register(fastifyStatic, {
root: WEB_DIST,
wildcard: false,
});
server.setNotFoundHandler((req, reply) => {
const url = req.raw.url ?? "";
if (
url.startsWith("/api") ||
url.startsWith("/u/") ||
url.startsWith("/admin")
) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
}
}
async function shutdown() { async function shutdown() {
try { try {
if (workflowWorker) {
await workflowWorker.close();
}
await workflowQueue.close();
await closeRedis();
await flushLogs(); await flushLogs();
await db.destroy(); await db.destroy();
} catch (err) { } catch (err) {
@@ -176,15 +227,19 @@ process.on("SIGTERM", shutdown);
const port = Number(process.env.PORT ?? 9000); const port = Number(process.env.PORT ?? 9000);
server if (runApi) {
.listen({ server
host: "0.0.0.0", .listen({
port, host: "0.0.0.0",
}) port,
.then(() => { })
log.info(`Server is running on port ${port}`); .then(() => {
}) log.info(`Server is running on port ${port}`);
.catch((err) => { })
log.error({ err }, "failed to start server"); .catch((err) => {
process.exit(1); log.error({ err }, "failed to start server");
}); process.exit(1);
});
} else {
log.info("worker-only mode; HTTP server not started");
}
+3 -3
View File
@@ -63,8 +63,8 @@ export default function dashboardPluginFactory(registry) {
} }
} }
const [running, failed, recent] = await Promise.all([ const [active, failed, recent] = await Promise.all([
store.listRuns({ status: "running", limit: 10 }), store.listRuns({ status: ["queued", "running"], limit: 10 }),
store.listRuns({ status: "failed", limit: 20 }), store.listRuns({ status: "failed", limit: 20 }),
store.listRuns({ limit: 10 }), store.listRuns({ limit: 10 }),
]); ]);
@@ -74,7 +74,7 @@ export default function dashboardPluginFactory(registry) {
scriptCount: fsStore.listScriptFiles().length, scriptCount: fsStore.listScriptFiles().length,
enabledCount, enabledCount,
brokenCount, brokenCount,
running, running: active,
needsAttention: { needsAttention: {
failed, failed,
brokenWorkflows, brokenWorkflows,
+5 -4
View File
@@ -404,7 +404,7 @@ export default function workflowsPluginFactory(registry) {
}); });
} }
const body = /** @type {{ data?: unknown }} */ (req.body ?? {}); const body = /** @type {{ data?: unknown }} */ (req.body ?? {});
const result = await registry.runWorkflow( const result = await registry.enqueueWorkflow(
key, key,
{ data: body.data ?? null }, { data: body.data ?? null },
{ type: "manual", detail: "ui" }, { type: "manual", detail: "ui" },
@@ -412,14 +412,15 @@ export default function workflowsPluginFactory(registry) {
if (result.status === "failed") { if (result.status === "failed") {
return reply.code(result.runId ? 500 : 404).send({ return reply.code(result.runId ? 500 : 404).send({
runId: result.runId, runId: result.runId,
status: result.status,
error: result.error, error: result.error,
}); });
} }
return { return reply.code(202).send({
runId: result.runId, runId: result.runId,
status: result.status, status: result.status,
result: result.result, jobId: result.jobId ?? null,
}; });
}); });
fastify.post("/workflows/reregister", async () => { fastify.post("/workflows/reregister", async () => {
+39 -6
View File
@@ -49,6 +49,7 @@ function nowIso() {
* trigger: { type: string, detail?: string | null }, * trigger: { type: string, detail?: string | null },
* input?: unknown, * input?: unknown,
* parentRunId?: string | null, * parentRunId?: string | null,
* status?: "queued" | "running",
* }} opts * }} opts
*/ */
export async function startRun({ export async function startRun({
@@ -58,9 +59,11 @@ export async function startRun({
trigger, trigger,
input = null, input = null,
parentRunId = null, parentRunId = null,
status = "queued",
}) { }) {
const id = randomUUID(); const id = randomUUID();
const started_at = nowIso(); const now = nowIso();
const isQueued = status === "queued";
await db("workflow_runs").insert({ await db("workflow_runs").insert({
id, id,
owner, owner,
@@ -68,12 +71,36 @@ export async function startRun({
workflow_name: workflowName ?? null, workflow_name: workflowName ?? null,
trigger_type: trigger.type, trigger_type: trigger.type,
trigger_detail: trigger.detail ?? null, trigger_detail: trigger.detail ?? null,
status: "running", status,
started_at, started_at: now,
queued_at: isQueued ? now : null,
input: serialize(input), input: serialize(input),
parent_run_id: parentRunId ?? null, parent_run_id: parentRunId ?? null,
}); });
return { id, started_at }; return { id, started_at: now, queued_at: isQueued ? now : null };
}
/**
* @param {string} id
* @param {string} jobId
*/
export async function setRunJobId(id, jobId) {
await db("workflow_runs").where({ id }).update({ job_id: jobId });
}
/**
* @param {string} id
*/
export async function markRunRunning(id) {
const started_at = nowIso();
const updated = await db("workflow_runs")
.where({ id })
.whereIn("status", ["queued", "running"])
.update({
status: "running",
started_at,
});
return { updated: Number(updated) > 0, started_at };
} }
/** /**
@@ -181,7 +208,7 @@ export async function insertLogs(rows) {
* @param {{ * @param {{
* owner?: string, * owner?: string,
* workflow?: string, * workflow?: string,
* status?: string, * status?: string | string[],
* limit?: number, * limit?: number,
* before?: string, * before?: string,
* }} [filters] * }} [filters]
@@ -203,7 +230,13 @@ export async function listRuns(filters = {}) {
q = q.where("workflow", key); q = q.where("workflow", key);
} }
} }
if (filters.status) q = q.where("status", filters.status); if (filters.status) {
if (Array.isArray(filters.status)) {
q = q.whereIn("status", filters.status);
} else {
q = q.where("status", filters.status);
}
}
if (filters.before) q = q.where("started_at", "<", filters.before); if (filters.before) q = q.where("started_at", "<", filters.before);
const rows = await q.limit(limit); const rows = await q.limit(limit);
return rows.map((row) => ({ return rows.map((row) => ({
+103
View File
@@ -0,0 +1,103 @@
import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";
import { log } from "./logger.js";
const DEFAULT_REDIS_URL = "redis://127.0.0.1:6379";
const DEFAULT_QUEUE_NAME = "jerapah-workflows";
const DEFAULT_CONCURRENCY = 5;
/** @type {IORedis | null} */
let sharedConnection = null;
export function getRedisUrl() {
return process.env.REDIS_URL || DEFAULT_REDIS_URL;
}
export function getQueueName() {
return process.env.JFLOW_QUEUE_NAME || DEFAULT_QUEUE_NAME;
}
export function getWorkerConcurrency() {
const raw = Number(process.env.JFLOW_WORKER_CONCURRENCY ?? DEFAULT_CONCURRENCY);
if (!Number.isFinite(raw) || raw < 1) return DEFAULT_CONCURRENCY;
return Math.floor(raw);
}
/**
* BullMQ requires maxRetriesPerRequest: null for blocking commands.
* @returns {IORedis}
*/
export function getSharedConnection() {
if (sharedConnection) return sharedConnection;
sharedConnection = new IORedis(getRedisUrl(), {
maxRetriesPerRequest: null,
enableReadyCheck: true,
});
sharedConnection.on("error", (err) => {
log.error({ err }, "redis connection error");
});
return sharedConnection;
}
/**
* @returns {Queue}
*/
export function createWorkflowQueue() {
return new Queue(getQueueName(), {
connection: getSharedConnection(),
defaultJobOptions: {
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
attempts: 1,
},
});
}
/**
* @param {(job: import("bullmq").Job) => Promise<unknown>} processor
* @returns {Worker}
*/
export function createWorkflowWorker(processor) {
const concurrency = getWorkerConcurrency();
const worker = new Worker(getQueueName(), processor, {
connection: getSharedConnection(),
concurrency,
});
worker.on("error", (err) => {
log.error({ err }, "workflow worker error");
});
log.info({ concurrency, queue: getQueueName() }, "workflow worker started");
return worker;
}
/**
* @param {Queue} queue
* @param {{
* runId: string,
* key: string,
* depth?: number,
* }} data
*/
export async function enqueueWorkflowJob(queue, data) {
const job = await queue.add(
"run",
{
runId: data.runId,
key: data.key,
depth: data.depth ?? 0,
},
{
jobId: data.runId,
},
);
return job;
}
/**
* @param {IORedis | null} [connection]
*/
export async function closeRedis(connection = sharedConnection) {
if (!connection) return;
if (connection === sharedConnection) sharedConnection = null;
await connection.quit().catch(() => connection.disconnect());
}
+4 -2
View File
@@ -271,8 +271,10 @@ export function useRun(id) {
queryKey: ["runs", id], queryKey: ["runs", id],
queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data, queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data,
enabled: Boolean(id), enabled: Boolean(id),
refetchInterval: (query) => refetchInterval: (query) => {
query.state.data?.status === "running" ? 2000 : false, const status = query.state.data?.status;
return status === "running" || status === "queued" ? 1500 : false;
},
}); });
} }
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu"; 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 { useRunWorkflow } from "../../api/hooks.js";
import { CodeEditor } from "../CodeEditor.jsx"; import { CodeEditor } from "../CodeEditor.jsx";
import { StatusBadge } from "../../lib/format.jsx"; import { StatusBadge } from "../../lib/format.jsx";
@@ -14,11 +14,25 @@ import {
import { SchemaTooltip } from "./FieldHelp.jsx"; import { SchemaTooltip } from "./FieldHelp.jsx";
const SAVE_DEBOUNCE_MS = 300; const SAVE_DEBOUNCE_MS = 300;
const POLL_MS = 1500;
function initialDataJson(owner, file, defaultData) { function initialDataJson(owner, file, defaultData) {
return prettyJson(overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file))); 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({ export function WorkflowTestPanel({
owner, owner,
file, file,
@@ -34,8 +48,10 @@ export function WorkflowTestPanel({
const [inputTouched, setInputTouched] = useState(() => readWorkflowTestData(owner, file) != null); const [inputTouched, setInputTouched] = useState(() => readWorkflowTestData(owner, file) != null);
const [parseError, setParseError] = useState(null); const [parseError, setParseError] = useState(null);
const [last, setLast] = useState(null); const [last, setLast] = useState(null);
const [polling, setPolling] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const saveTimer = useRef(null); const saveTimer = useRef(null);
const pollAbort = useRef(null);
const seedJson = prettyJson( const seedJson = prettyJson(
overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)), overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)),
@@ -44,6 +60,7 @@ export function WorkflowTestPanel({
useEffect(() => { useEffect(() => {
return () => { return () => {
if (saveTimer.current) clearTimeout(saveTimer.current); if (saveTimer.current) clearTimeout(saveTimer.current);
pollAbort.current?.abort();
}; };
}, []); }, []);
@@ -79,16 +96,42 @@ export function WorkflowTestPanel({
return; return;
} }
persist(data); persist(data);
pollAbort.current?.abort();
const controller = new AbortController();
pollAbort.current = controller;
run.mutate( run.mutate(
{ owner, file, data }, { owner, file, data },
{ {
onSuccess: (res) => { onSuccess: async (res) => {
const runId = res.runId ?? null;
setLast({ setLast({
status: res.status ?? "success", status: res.status ?? "queued",
runId: res.runId ?? null, runId,
result: res.result, result: null,
error: 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) => { onError: (err) => {
const runId = err?.response?.data?.runId; const runId = err?.response?.data?.runId;
@@ -107,11 +150,15 @@ export function WorkflowTestPanel({
? prettyJson(last.error ? { error: last.error } : last.result) ? prettyJson(last.error ? { error: last.error } : last.result)
: ""; : "";
const busy = run.isPending || polling;
function close() { function close() {
if (saveTimer.current) { if (saveTimer.current) {
clearTimeout(saveTimer.current); clearTimeout(saveTimer.current);
saveTimer.current = null; saveTimer.current = null;
} }
pollAbort.current?.abort();
setPolling(false);
try { try {
persist(JSON.parse(dataJson || "null")); persist(JSON.parse(dataJson || "null"));
} catch { } catch {
@@ -134,8 +181,8 @@ export function WorkflowTestPanel({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-bold text-lg">Test workflow</h3> <h3 className="font-bold text-lg">Test workflow</h3>
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">
Runs the <strong>saved</strong> YAML and writes an Event. Manual run works even when Enqueues the <strong>saved</strong> YAML and polls the Event until it finishes.
the workflow is disabled. Save before testing unsaved edits. Manual run works even when the workflow is disabled. Save before testing unsaved edits.
</p> </p>
</div> </div>
<button <button
@@ -192,10 +239,10 @@ export function WorkflowTestPanel({
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
disabled={disabled || run.isPending} disabled={disabled || busy}
onClick={onRun} onClick={onRun}
> >
{run.isPending ? ( {busy ? (
<span className="loading loading-spinner loading-xs" /> <span className="loading loading-spinner loading-xs" />
) : ( ) : (
<LuPlay className="size-4" /> <LuPlay className="size-4" />
+10 -2
View File
@@ -13,9 +13,11 @@ export function StatusBadge({ status }) {
? "badge-error" ? "badge-error"
: status === "running" : status === "running"
? "badge-warning" ? "badge-warning"
: status === "skipped" : status === "queued"
? "badge-info" ? "badge-info"
: "badge-ghost"; : status === "skipped"
? "badge-ghost"
: "badge-ghost";
return <span className={`badge badge-sm ${cls}`}>{status ?? "—"}</span>; return <span className={`badge badge-sm ${cls}`}>{status ?? "—"}</span>;
} }
@@ -53,6 +55,12 @@ export function WorkflowStatusBadge({ workflow }) {
failed failed
</span>, </span>,
); );
} else if (workflow.lastStatus === "queued") {
badges.push(
<span key="queued" className="badge badge-info badge-sm">
queued
</span>,
);
} else if (workflow.lastStatus === "running") { } else if (workflow.lastStatus === "running") {
badges.push( badges.push(
<span key="running" className="badge badge-warning badge-sm"> <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"> <div className="stat-figure text-warning">
<LuActivity className="size-7" /> <LuActivity className="size-7" />
</div> </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 className="stat-value text-2xl">{data.running?.length ?? 0}</div>
</div> </div>
<div className="stat"> <div className="stat">
@@ -58,7 +58,7 @@ export function HomePage() {
</div> </div>
<section> <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" /> <RunList runs={data.running} empty="None" />
</section> </section>
+176
View File
@@ -35,9 +35,15 @@ importers:
better-sqlite3: better-sqlite3:
specifier: ^13.0.3 specifier: ^13.0.3
version: 13.0.3 version: 13.0.3
bullmq:
specifier: ^6.1.2
version: 6.1.2(ioredis@6.0.0)
fastify: fastify:
specifier: ^5.12.0 specifier: ^5.12.0
version: 5.12.0 version: 5.12.0
ioredis:
specifier: ^6.0.0
version: 6.0.0
jsonata: jsonata:
specifier: ^2.2.2 specifier: ^2.2.2
version: 2.2.2 version: 2.2.2
@@ -450,6 +456,9 @@ packages:
'@iconify/utils@3.1.4': '@iconify/utils@3.1.4':
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
'@ioredis/commands@2.0.0':
resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==}
'@isaacs/cliui@9.0.0': '@isaacs/cliui@9.0.0':
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -487,6 +496,36 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==}
cpu: [x64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==}
cpu: [arm64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==}
cpu: [arm]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==}
cpu: [x64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==}
cpu: [x64]
os: [win32]
'@napi-rs/lzma-linux-x64-gnu@1.5.1': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25} engines: {node: ^22.20 || ^24.12 || >=25}
@@ -933,6 +972,24 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
bullmq@6.1.2:
resolution: {integrity: sha512-GSX8JfWN8CElAGDyt7Zmq59n1FfWZ0L7IjsThNUQq7X8mvVVDb9I5i2F+nFg4A00UbysJ5yejN0oWzmbUJPDFg==}
engines: {node: '>=14.17.0'}
peerDependencies:
bullmq-otel: '>=2.0.0'
ioredis: '>=5.0.0'
pg: '>=8.0.0'
redis: '>=5.0.0'
peerDependenciesMeta:
bullmq-otel:
optional: true
ioredis:
optional: true
pg:
optional: true
redis:
optional: true
byte-length@1.0.2: byte-length@1.0.2:
resolution: {integrity: sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==} resolution: {integrity: sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==}
@@ -946,6 +1003,10 @@ packages:
charenc@0.0.2: charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
cluster-key-slot@1.1.1:
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
engines: {node: '>=0.10.0'}
colorette@2.0.19: colorette@2.0.19:
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
@@ -986,6 +1047,10 @@ packages:
cose-base@2.2.0: cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
cron-parser@5.10.0:
resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==}
engines: {node: '>=18'}
cronstrue@3.24.0: cronstrue@3.24.0:
resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==} resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==}
hasBin: true hasBin: true
@@ -1198,6 +1263,10 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@2.0.0: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1469,6 +1538,10 @@ packages:
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
ioredis@6.0.0:
resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==}
engines: {node: '>=20.0.0'}
ipaddr.js@2.5.0: ipaddr.js@2.5.0:
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -1653,6 +1726,10 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1716,6 +1793,13 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msgpackr-extract@3.0.4:
resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==}
hasBin: true
msgpackr@2.0.5:
resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==}
mustache@4.2.0: mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true hasBin: true
@@ -1728,6 +1812,9 @@ packages:
nested-property@4.0.0: nested-property@4.0.0:
resolution: {integrity: sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==} resolution: {integrity: sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==}
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@8.9.2: node-addon-api@8.9.2:
resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==}
engines: {node: ^18 || ^20 || >= 21} engines: {node: ^18 || ^20 || >= 21}
@@ -1745,6 +1832,10 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-gyp-build-optional-packages@5.2.2:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
node-html-parser@9.0.1: node-html-parser@9.0.1:
resolution: {integrity: sha512-QrdiYYm1NnLRXsMXThUgVcF/syWfWIgHFmy8hylWMGFbHFtnRuXLBxxGQvIm3xaIJMUDJ0ayOmT/FGJYT3pZIw==} resolution: {integrity: sha512-QrdiYYm1NnLRXsMXThUgVcF/syWfWIgHFmy8hylWMGFbHFtnRuXLBxxGQvIm3xaIJMUDJ0ayOmT/FGJYT3pZIw==}
@@ -1888,6 +1979,10 @@ packages:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'} engines: {node: '>= 10.13.0'}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
require-from-string@2.0.2: require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1994,6 +2089,9 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
state-local@1.0.7: state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
@@ -2450,6 +2548,8 @@ snapshots:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
import-meta-resolve: 4.2.0 import-meta-resolve: 4.2.0
'@ioredis/commands@2.0.0': {}
'@isaacs/cliui@9.0.0': {} '@isaacs/cliui@9.0.0': {}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
@@ -2488,6 +2588,24 @@ snapshots:
react: 19.2.8 react: 19.2.8
react-dom: 19.2.8(react@19.2.8) react-dom: 19.2.8(react@19.2.8)
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
optional: true
'@napi-rs/lzma-linux-x64-gnu@1.5.1': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true optional: true
@@ -2896,6 +3014,16 @@ snapshots:
node-releases: 2.0.53 node-releases: 2.0.53
update-browserslist-db: 1.3.1(browserslist@4.28.8) update-browserslist-db: 1.3.1(browserslist@4.28.8)
bullmq@6.1.2(ioredis@6.0.0):
dependencies:
cron-parser: 5.10.0
msgpackr: 2.0.5
node-abort-controller: 3.1.1
semver: 7.8.5
tslib: 2.8.1
optionalDependencies:
ioredis: 6.0.0
byte-length@1.0.2: {} byte-length@1.0.2: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
@@ -2907,6 +3035,8 @@ snapshots:
charenc@0.0.2: {} charenc@0.0.2: {}
cluster-key-slot@1.1.1: {}
colorette@2.0.19: {} colorette@2.0.19: {}
combined-stream@1.0.8: combined-stream@1.0.8:
@@ -2937,6 +3067,10 @@ snapshots:
dependencies: dependencies:
layout-base: 2.0.1 layout-base: 2.0.1
cron-parser@5.10.0:
dependencies:
luxon: 3.7.2
cronstrue@3.24.0: {} cronstrue@3.24.0: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
@@ -3163,6 +3297,8 @@ snapshots:
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
denque@2.1.0: {}
depd@2.0.0: {} depd@2.0.0: {}
dequal@2.0.3: {} dequal@2.0.3: {}
@@ -3473,6 +3609,17 @@ snapshots:
interpret@2.2.0: {} interpret@2.2.0: {}
ioredis@6.0.0:
dependencies:
'@ioredis/commands': 2.0.0
cluster-key-slot: 1.1.1
debug: 4.4.3
denque: 2.1.0
redis-errors: 1.2.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@2.5.0: {} ipaddr.js@2.5.0: {}
is-buffer@1.1.6: {} is-buffer@1.1.6: {}
@@ -3603,6 +3750,8 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
luxon@3.7.2: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -3676,12 +3825,30 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
msgpackr-extract@3.0.4:
dependencies:
node-gyp-build-optional-packages: 5.2.2
optionalDependencies:
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
optional: true
msgpackr@2.0.5:
optionalDependencies:
msgpackr-extract: 3.0.4
mustache@4.2.0: {} mustache@4.2.0: {}
nanoid@3.3.18: {} nanoid@3.3.18: {}
nested-property@4.0.0: {} nested-property@4.0.0: {}
node-abort-controller@3.1.1: {}
node-addon-api@8.9.2: {} node-addon-api@8.9.2: {}
node-cron@4.6.0: {} node-cron@4.6.0: {}
@@ -3694,6 +3861,11 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
node-gyp-build-optional-packages@5.2.2:
dependencies:
detect-libc: 2.1.2
optional: true
node-html-parser@9.0.1: node-html-parser@9.0.1:
dependencies: dependencies:
css-select: 5.2.2 css-select: 5.2.2
@@ -3819,6 +3991,8 @@ snapshots:
dependencies: dependencies:
resolve: 1.22.12 resolve: 1.22.12
redis-errors@1.2.0: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
requires-port@1.0.0: {} requires-port@1.0.0: {}
@@ -3926,6 +4100,8 @@ snapshots:
split2@4.2.0: {} split2@4.2.0: {}
standard-as-callback@2.1.0: {}
state-local@1.0.7: {} state-local@1.0.7: {}
statuses@2.0.2: {} statuses@2.0.2: {}