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:
@@ -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_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. |
|
||||
| `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_RETENTION_DAYS` | `30` | Run history prune |
|
||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
|
||||
| `PORT` | `9000` | HTTP port |
|
||||
| `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
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,9 @@
|
||||
"axios": "^1.19.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"bullmq": "^6.1.2",
|
||||
"fastify": "^5.12.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"jsonata": "^2.2.2",
|
||||
"knex": "^3.3.0",
|
||||
"mustache": "^4.2.0",
|
||||
|
||||
+134
-77
@@ -35,6 +35,7 @@ import {
|
||||
buildFailureAlertData,
|
||||
resolveFailureTriggerConfig,
|
||||
} from "./trigger-failure.js";
|
||||
import { enqueueWorkflowJob } from "./workflow-queue.js";
|
||||
|
||||
/**
|
||||
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
|
||||
@@ -56,8 +57,10 @@ function hasWorkflowTrigger(workflow) {
|
||||
|
||||
/**
|
||||
* @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>} */
|
||||
const workflows = new Map();
|
||||
/** @type {Map<string, string>} */
|
||||
@@ -261,26 +264,27 @@ export function createRegistry(server) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runWorkflow(
|
||||
const result = await enqueueWorkflow(
|
||||
mapped.key,
|
||||
{ data: req.body },
|
||||
{ type: "http", detail: `${method} ${url}` },
|
||||
);
|
||||
if (result.status === "failed") {
|
||||
return reply.code(500).send({
|
||||
return reply.code(result.runId ? 500 : 404).send({
|
||||
runId: result.runId,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultBody = {
|
||||
runId: result.runId,
|
||||
result: result.result,
|
||||
status: result.status,
|
||||
};
|
||||
if (typeof liveTrigger.response === "string" && liveTrigger.response) {
|
||||
return sendSuccessPage(reply, liveTrigger.response, defaultBody);
|
||||
}
|
||||
return reply.send(defaultBody);
|
||||
return reply.code(202).send(defaultBody);
|
||||
}
|
||||
|
||||
function registerCronTriggers() {
|
||||
@@ -308,7 +312,7 @@ export function createRegistry(server) {
|
||||
schedule,
|
||||
() => {
|
||||
log.debug(`cron firing ${key} (${schedule})`);
|
||||
return runWorkflow(
|
||||
return enqueueWorkflow(
|
||||
key,
|
||||
{ data: workflow.data ?? null },
|
||||
{ type: "cron", detail: schedule },
|
||||
@@ -544,14 +548,13 @@ export function createRegistry(server) {
|
||||
);
|
||||
}
|
||||
const destKey = resolveWorkflowTriggerKey(owner, name);
|
||||
return runWorkflow(
|
||||
return enqueueWorkflow(
|
||||
destKey,
|
||||
{ data },
|
||||
{ type: "workflow", detail: parentKey },
|
||||
{
|
||||
parentRunId,
|
||||
depth: depth + 1,
|
||||
detach: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -696,32 +699,36 @@ export function createRegistry(server) {
|
||||
"triggering failure alert workflow",
|
||||
);
|
||||
|
||||
await runWorkflow(
|
||||
await enqueueWorkflow(
|
||||
destKey,
|
||||
{ data: alertData },
|
||||
{ type: "workflow", detail: `failure:${opts.key}` },
|
||||
{
|
||||
parentRunId: opts.runId,
|
||||
depth: opts.depth + 1,
|
||||
detach: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a queued run and push a BullMQ job. Returns immediately.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {{ data?: unknown, context?: unknown }} context
|
||||
* @param {{ type: string, detail?: string | null }} trigger
|
||||
* @param {{
|
||||
* parentRunId?: string | null,
|
||||
* depth?: number,
|
||||
* detach?: boolean,
|
||||
* }} [opts]
|
||||
*/
|
||||
async function runWorkflow(key, context, trigger, opts = {}) {
|
||||
async function enqueueWorkflow(key, context, trigger, opts = {}) {
|
||||
const parentRunId = opts.parentRunId ?? null;
|
||||
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);
|
||||
if (!entry) {
|
||||
@@ -734,82 +741,130 @@ export function createRegistry(server) {
|
||||
log.debug({ workflow: key, trigger }, "skipping disabled workflow");
|
||||
return { runId: null, status: "failed", error: "workflow disabled" };
|
||||
}
|
||||
|
||||
const input = context.data ?? workflow.data ?? null;
|
||||
const run = await store.startRun({
|
||||
owner,
|
||||
workflow: key,
|
||||
workflowName: workflow?.name,
|
||||
trigger,
|
||||
input: context.data,
|
||||
input,
|
||||
parentRunId,
|
||||
status: "queued",
|
||||
});
|
||||
const runLog = log.child({ runId: run.id, owner, workflow: key });
|
||||
runLog.debug(detach ? "running workflow (detached)" : "running workflow");
|
||||
|
||||
const initialCtx = {
|
||||
data: context.data ?? workflow.data ?? null,
|
||||
context: normalizeContext(context.context),
|
||||
};
|
||||
|
||||
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");
|
||||
try {
|
||||
const job = await enqueueWorkflowJob(queue, {
|
||||
runId: run.id,
|
||||
key,
|
||||
depth,
|
||||
});
|
||||
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() {
|
||||
@@ -842,6 +897,8 @@ export function createRegistry(server) {
|
||||
registerPruneJob,
|
||||
reregister,
|
||||
runWorkflow,
|
||||
enqueueWorkflow,
|
||||
executeQueuedRun,
|
||||
referencedScripts,
|
||||
};
|
||||
}
|
||||
|
||||
+150
-95
@@ -22,6 +22,12 @@ import httpPagesPlugin from "./src/api/http-pages.js";
|
||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||
import { WEB_DIST } from "./paths.js";
|
||||
import { resolveSecretsKeyMaterial } from "./secrets.js";
|
||||
import {
|
||||
closeRedis,
|
||||
createWorkflowQueue,
|
||||
createWorkflowWorker,
|
||||
getRedisUrl,
|
||||
} from "./workflow-queue.js";
|
||||
|
||||
await migrate();
|
||||
enableLogPersistence();
|
||||
@@ -42,6 +48,20 @@ try {
|
||||
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 });
|
||||
|
||||
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.registerHttpTriggers();
|
||||
registry.registerCronTriggers();
|
||||
registry.registerPruneJob();
|
||||
if (runApi) {
|
||||
registry.registerHttpTriggers();
|
||||
registry.registerCronTriggers();
|
||||
registry.registerPruneJob();
|
||||
}
|
||||
|
||||
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" });
|
||||
/** @type {import("bullmq").Worker | null} */
|
||||
let workflowWorker = null;
|
||||
if (runWorker) {
|
||||
workflowWorker = createWorkflowWorker(async (job) => {
|
||||
const data = /** @type {{ runId?: string, key?: string, depth?: number }} */ (
|
||||
job.data ?? {}
|
||||
);
|
||||
if (typeof data.runId !== "string" || typeof data.key !== "string") {
|
||||
throw new Error("invalid workflow job payload");
|
||||
}
|
||||
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" });
|
||||
const result = await registry.executeQueuedRun({
|
||||
runId: data.runId,
|
||||
key: data.key,
|
||||
depth: data.depth ?? 0,
|
||||
});
|
||||
if (result.status === "failed") {
|
||||
throw new Error(result.error || "workflow failed");
|
||||
}
|
||||
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() {
|
||||
try {
|
||||
if (workflowWorker) {
|
||||
await workflowWorker.close();
|
||||
}
|
||||
await workflowQueue.close();
|
||||
await closeRedis();
|
||||
await flushLogs();
|
||||
await db.destroy();
|
||||
} catch (err) {
|
||||
@@ -176,15 +227,19 @@ process.on("SIGTERM", shutdown);
|
||||
|
||||
const port = Number(process.env.PORT ?? 9000);
|
||||
|
||||
server
|
||||
.listen({
|
||||
host: "0.0.0.0",
|
||||
port,
|
||||
})
|
||||
.then(() => {
|
||||
log.info(`Server is running on port ${port}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error({ err }, "failed to start server");
|
||||
process.exit(1);
|
||||
});
|
||||
if (runApi) {
|
||||
server
|
||||
.listen({
|
||||
host: "0.0.0.0",
|
||||
port,
|
||||
})
|
||||
.then(() => {
|
||||
log.info(`Server is running on port ${port}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error({ err }, "failed to start server");
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
log.info("worker-only mode; HTTP server not started");
|
||||
}
|
||||
@@ -63,8 +63,8 @@ export default function dashboardPluginFactory(registry) {
|
||||
}
|
||||
}
|
||||
|
||||
const [running, failed, recent] = await Promise.all([
|
||||
store.listRuns({ status: "running", limit: 10 }),
|
||||
const [active, failed, recent] = await Promise.all([
|
||||
store.listRuns({ status: ["queued", "running"], limit: 10 }),
|
||||
store.listRuns({ status: "failed", limit: 20 }),
|
||||
store.listRuns({ limit: 10 }),
|
||||
]);
|
||||
@@ -74,7 +74,7 @@ export default function dashboardPluginFactory(registry) {
|
||||
scriptCount: fsStore.listScriptFiles().length,
|
||||
enabledCount,
|
||||
brokenCount,
|
||||
running,
|
||||
running: active,
|
||||
needsAttention: {
|
||||
failed,
|
||||
brokenWorkflows,
|
||||
|
||||
@@ -404,7 +404,7 @@ export default function workflowsPluginFactory(registry) {
|
||||
});
|
||||
}
|
||||
const body = /** @type {{ data?: unknown }} */ (req.body ?? {});
|
||||
const result = await registry.runWorkflow(
|
||||
const result = await registry.enqueueWorkflow(
|
||||
key,
|
||||
{ data: body.data ?? null },
|
||||
{ type: "manual", detail: "ui" },
|
||||
@@ -412,14 +412,15 @@ export default function workflowsPluginFactory(registry) {
|
||||
if (result.status === "failed") {
|
||||
return reply.code(result.runId ? 500 : 404).send({
|
||||
runId: result.runId,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
return {
|
||||
return reply.code(202).send({
|
||||
runId: result.runId,
|
||||
status: result.status,
|
||||
result: result.result,
|
||||
};
|
||||
jobId: result.jobId ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/workflows/reregister", async () => {
|
||||
|
||||
@@ -49,6 +49,7 @@ function nowIso() {
|
||||
* trigger: { type: string, detail?: string | null },
|
||||
* input?: unknown,
|
||||
* parentRunId?: string | null,
|
||||
* status?: "queued" | "running",
|
||||
* }} opts
|
||||
*/
|
||||
export async function startRun({
|
||||
@@ -58,9 +59,11 @@ export async function startRun({
|
||||
trigger,
|
||||
input = null,
|
||||
parentRunId = null,
|
||||
status = "queued",
|
||||
}) {
|
||||
const id = randomUUID();
|
||||
const started_at = nowIso();
|
||||
const now = nowIso();
|
||||
const isQueued = status === "queued";
|
||||
await db("workflow_runs").insert({
|
||||
id,
|
||||
owner,
|
||||
@@ -68,12 +71,36 @@ export async function startRun({
|
||||
workflow_name: workflowName ?? null,
|
||||
trigger_type: trigger.type,
|
||||
trigger_detail: trigger.detail ?? null,
|
||||
status: "running",
|
||||
started_at,
|
||||
status,
|
||||
started_at: now,
|
||||
queued_at: isQueued ? now : null,
|
||||
input: serialize(input),
|
||||
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 {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string,
|
||||
* status?: string | string[],
|
||||
* limit?: number,
|
||||
* before?: string,
|
||||
* }} [filters]
|
||||
@@ -203,7 +230,13 @@ export async function listRuns(filters = {}) {
|
||||
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);
|
||||
const rows = await q.limit(limit);
|
||||
return rows.map((row) => ({
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Generated
+176
@@ -35,9 +35,15 @@ importers:
|
||||
better-sqlite3:
|
||||
specifier: ^13.0.3
|
||||
version: 13.0.3
|
||||
bullmq:
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2(ioredis@6.0.0)
|
||||
fastify:
|
||||
specifier: ^5.12.0
|
||||
version: 5.12.0
|
||||
ioredis:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
jsonata:
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
@@ -450,6 +456,9 @@ packages:
|
||||
'@iconify/utils@3.1.4':
|
||||
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':
|
||||
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -487,6 +496,36 @@ packages:
|
||||
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
|
||||
|
||||
'@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':
|
||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||
@@ -933,6 +972,24 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
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:
|
||||
resolution: {integrity: sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==}
|
||||
|
||||
@@ -946,6 +1003,10 @@ packages:
|
||||
charenc@0.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
|
||||
|
||||
@@ -986,6 +1047,10 @@ packages:
|
||||
cose-base@2.2.0:
|
||||
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
||||
|
||||
cron-parser@5.10.0:
|
||||
resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cronstrue@3.24.0:
|
||||
resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==}
|
||||
hasBin: true
|
||||
@@ -1198,6 +1263,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
denque@2.1.0:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1469,6 +1538,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1653,6 +1726,10 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -1716,6 +1793,13 @@ packages:
|
||||
ms@2.1.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
|
||||
hasBin: true
|
||||
@@ -1728,6 +1812,9 @@ packages:
|
||||
nested-property@4.0.0:
|
||||
resolution: {integrity: sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==}
|
||||
|
||||
node-abort-controller@3.1.1:
|
||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||
|
||||
node-addon-api@8.9.2:
|
||||
resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==}
|
||||
engines: {node: ^18 || ^20 || >= 21}
|
||||
@@ -1745,6 +1832,10 @@ packages:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-QrdiYYm1NnLRXsMXThUgVcF/syWfWIgHFmy8hylWMGFbHFtnRuXLBxxGQvIm3xaIJMUDJ0ayOmT/FGJYT3pZIw==}
|
||||
|
||||
@@ -1888,6 +1979,10 @@ packages:
|
||||
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1994,6 +2089,9 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
state-local@1.0.7:
|
||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||
|
||||
@@ -2450,6 +2548,8 @@ snapshots:
|
||||
'@iconify/types': 2.0.0
|
||||
import-meta-resolve: 4.2.0
|
||||
|
||||
'@ioredis/commands@2.0.0': {}
|
||||
|
||||
'@isaacs/cliui@9.0.0': {}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
@@ -2488,6 +2588,24 @@ snapshots:
|
||||
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':
|
||||
optional: true
|
||||
|
||||
@@ -2896,6 +3014,16 @@ snapshots:
|
||||
node-releases: 2.0.53
|
||||
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: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
@@ -2907,6 +3035,8 @@ snapshots:
|
||||
|
||||
charenc@0.0.2: {}
|
||||
|
||||
cluster-key-slot@1.1.1: {}
|
||||
|
||||
colorette@2.0.19: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
@@ -2937,6 +3067,10 @@ snapshots:
|
||||
dependencies:
|
||||
layout-base: 2.0.1
|
||||
|
||||
cron-parser@5.10.0:
|
||||
dependencies:
|
||||
luxon: 3.7.2
|
||||
|
||||
cronstrue@3.24.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
@@ -3163,6 +3297,8 @@ snapshots:
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
@@ -3473,6 +3609,17 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
is-buffer@1.1.6: {}
|
||||
@@ -3603,6 +3750,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -3676,12 +3825,30 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
nested-property@4.0.0: {}
|
||||
|
||||
node-abort-controller@3.1.1: {}
|
||||
|
||||
node-addon-api@8.9.2: {}
|
||||
|
||||
node-cron@4.6.0: {}
|
||||
@@ -3694,6 +3861,11 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
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:
|
||||
dependencies:
|
||||
css-select: 5.2.2
|
||||
@@ -3819,6 +3991,8 @@ snapshots:
|
||||
dependencies:
|
||||
resolve: 1.22.12
|
||||
|
||||
redis-errors@1.2.0: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
requires-port@1.0.0: {}
|
||||
@@ -3926,6 +4100,8 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
state-local@1.0.7: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user