Merge pull request #12 from nasyarobby/cursor/bullmq-queue-phase-a-3038

feat(runner): queue workflow runs with BullMQ (phase A)
This commit is contained in:
2026-08-19 22:19:10 +07:00
committed by GitHub
14 changed files with 739 additions and 223 deletions
+9 -1
View File
@@ -64,18 +64,26 @@ 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. |
| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. |
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
| `JFLOW_WORKER_CONCURRENCY` | `5` | Max parallel workflow jobs per worker process. |
| `JFLOW_ROLE` | `all` | Which duties this process performs: `all` (HTTP/admin + cron producer + worker), `api` (HTTP/admin + cron enqueue only), or `worker` (consume queue only). Use separate processes in production when you want to scale workers independently. |
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
| `PORT` | `8700` | 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 (set REDIS_PASS if Redis requires AUTH)
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... 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");
});
}
+2
View File
@@ -20,7 +20,9 @@
"basic-ftp": "^6.2.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
View File
@@ -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
View File
@@ -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,
getRedisUrlForLog,
} 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: getRedisUrlForLog(), role }, "starting jerapah-flow");
const workflowQueue = createWorkflowQueue();
try {
await workflowQueue.waitUntilReady();
} catch (err) {
log.error({ err, redis: getRedisUrlForLog() }, "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 ?? 8700);
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");
}
+3 -3
View File
@@ -63,8 +63,8 @@ export default function dashboardPluginFactory(registry) {
}
}
const [running, streaks, failedEvents, recent] = await Promise.all([
store.listRuns({ status: "running", limit: 10 }),
const [active, streaks, failedEvents, recent] = await Promise.all([
store.listRuns({ status: ["queued", "running"], limit: 10 }),
store.listConsecutiveFailureStreaks({ minCount: 4, limit: 10 }),
store.listRuns({ status: "failed", limit: 5 }),
store.listRuns({ limit: 10 }),
@@ -75,7 +75,7 @@ export default function dashboardPluginFactory(registry) {
scriptCount: fsStore.listScriptFiles().length,
enabledCount,
brokenCount,
running,
running: active,
needsAttention: {
consecutiveFailures: streaks.items,
consecutiveFailureCount: streaks.total,
+5 -4
View File
@@ -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: store.toDisplayValue(result.result),
};
jobId: result.jobId ?? null,
});
});
fastify.post("/workflows/reregister", async () => {
+39 -6
View File
@@ -64,6 +64,7 @@ function nowIso() {
* trigger: { type: string, detail?: string | null },
* input?: unknown,
* parentRunId?: string | null,
* status?: "queued" | "running",
* }} opts
*/
export async function startRun({
@@ -73,9 +74,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,
@@ -83,12 +86,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 };
}
/**
@@ -196,7 +223,7 @@ export async function insertLogs(rows) {
* @param {{
* owner?: string,
* workflow?: string,
* status?: string,
* status?: string | string[],
* limit?: number,
* before?: string,
* }} [filters]
@@ -218,7 +245,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) => ({
+130
View File
@@ -0,0 +1,130 @@
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;
}
/**
* Optional Redis AUTH password. Applied even when REDIS_URL has no embedded credentials.
* @returns {string | undefined}
*/
export function getRedisPassword() {
const pass = process.env.REDIS_PASS;
if (typeof pass !== "string" || pass.length === 0) return undefined;
return pass;
}
/** Redact credentials for logs. */
export function getRedisUrlForLog() {
try {
const url = new URL(getRedisUrl());
if (url.password || getRedisPassword()) url.password = "***";
if (url.username) url.username = url.username ? "***" : "";
return url.toString();
} catch {
return getRedisUrl();
}
}
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;
/** @type {import("ioredis").RedisOptions} */
const options = {
maxRetriesPerRequest: null,
enableReadyCheck: true,
};
const password = getRedisPassword();
if (password) options.password = password;
sharedConnection = new IORedis(getRedisUrl(), options);
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
@@ -282,8 +282,10 @@ export function useRun(id) {
queryKey: ["runs", id],
queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data,
enabled: Boolean(id),
refetchInterval: (query) =>
query.state.data?.status === "running" ? 2000 : false,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "running" || status === "queued" ? 1500 : false;
},
});
}
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
import { errorMessage } from "../../api/client.js";
import { api, errorMessage } from "../../api/client.js";
import { useRunWorkflow } from "../../api/hooks.js";
import { CodeEditor } from "../CodeEditor.jsx";
import { StatusBadge } from "../../lib/format.jsx";
@@ -14,11 +14,25 @@ import {
import { SchemaTooltip } from "./FieldHelp.jsx";
const SAVE_DEBOUNCE_MS = 300;
const POLL_MS = 1500;
function initialDataJson(owner, file, defaultData) {
return prettyJson(overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)));
}
function isTerminalStatus(status) {
return status === "success" || status === "failed";
}
async function waitForRun(runId, { signal } = {}) {
while (!signal?.aborted) {
const run = (await api.get(`/runs/${encodeURIComponent(runId)}`)).data;
if (isTerminalStatus(run.status)) return run;
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}
throw new Error("polling aborted");
}
export function WorkflowTestPanel({
owner,
file,
@@ -34,8 +48,10 @@ export function WorkflowTestPanel({
const [inputTouched, setInputTouched] = useState(() => readWorkflowTestData(owner, file) != null);
const [parseError, setParseError] = useState(null);
const [last, setLast] = useState(null);
const [polling, setPolling] = useState(false);
const [expanded, setExpanded] = useState(false);
const saveTimer = useRef(null);
const pollAbort = useRef(null);
const seedJson = prettyJson(
overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)),
@@ -44,6 +60,7 @@ export function WorkflowTestPanel({
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
pollAbort.current?.abort();
};
}, []);
@@ -79,16 +96,42 @@ export function WorkflowTestPanel({
return;
}
persist(data);
pollAbort.current?.abort();
const controller = new AbortController();
pollAbort.current = controller;
run.mutate(
{ owner, file, data },
{
onSuccess: (res) => {
onSuccess: async (res) => {
const runId = res.runId ?? null;
setLast({
status: res.status ?? "success",
runId: res.runId ?? null,
result: res.result,
status: res.status ?? "queued",
runId,
result: null,
error: null,
});
if (!runId) return;
setPolling(true);
try {
const finished = await waitForRun(runId, { signal: controller.signal });
setLast({
status: finished.status,
runId,
result: finished.output,
error: finished.error ?? null,
});
} catch (err) {
if (controller.signal.aborted) return;
setLast({
status: "failed",
runId,
result: null,
error: errorMessage(err),
});
} finally {
if (!controller.signal.aborted) setPolling(false);
}
},
onError: (err) => {
const runId = err?.response?.data?.runId;
@@ -107,11 +150,15 @@ export function WorkflowTestPanel({
? prettyJson(last.error ? { error: last.error } : last.result)
: "";
const busy = run.isPending || polling;
function close() {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
pollAbort.current?.abort();
setPolling(false);
try {
persist(JSON.parse(dataJson || "null"));
} catch {
@@ -134,8 +181,8 @@ export function WorkflowTestPanel({
<div className="min-w-0 flex-1">
<h3 className="font-bold text-lg">Test workflow</h3>
<p className="text-sm opacity-70">
Runs the <strong>saved</strong> YAML and writes an Event. Manual run works even when
the workflow is disabled. Save before testing unsaved edits.
Enqueues the <strong>saved</strong> YAML and polls the Event until it finishes.
Manual run works even when the workflow is disabled. Save before testing unsaved edits.
</p>
</div>
<button
@@ -192,10 +239,10 @@ export function WorkflowTestPanel({
<button
type="button"
className="btn btn-primary"
disabled={disabled || run.isPending}
disabled={disabled || busy}
onClick={onRun}
>
{run.isPending ? (
{busy ? (
<span className="loading loading-spinner loading-xs" />
) : (
<LuPlay className="size-4" />
+10 -2
View File
@@ -13,9 +13,11 @@ export function StatusBadge({ status }) {
? "badge-error"
: status === "running"
? "badge-warning"
: status === "skipped"
: status === "queued"
? "badge-info"
: "badge-ghost";
: status === "skipped"
? "badge-ghost"
: "badge-ghost";
return <span className={`badge badge-sm ${cls}`}>{status ?? "—"}</span>;
}
@@ -53,6 +55,12 @@ export function WorkflowStatusBadge({ workflow }) {
failed
</span>,
);
} else if (workflow.lastStatus === "queued") {
badges.push(
<span key="queued" className="badge badge-info badge-sm">
queued
</span>,
);
} else if (workflow.lastStatus === "running") {
badges.push(
<span key="running" className="badge badge-warning badge-sm">
+18 -1
View File
@@ -1,5 +1,10 @@
import { Link } from "react-router-dom";
import { LuCode, LuGitBranch, LuTriangleAlert } from "react-icons/lu";
import {
LuActivity,
LuCode,
LuGitBranch,
LuTriangleAlert,
} from "react-icons/lu";
import { useDashboard } from "../api/hooks.js";
import { formatTime, StatusBadge } from "../lib/format.jsx";
@@ -36,6 +41,13 @@ export function HomePage() {
<div className="stat-title">Scripts</div>
<div className="stat-value text-2xl">{data.scriptCount}</div>
</div>
<div className="stat">
<div className="stat-figure text-warning">
<LuActivity className="size-7" />
</div>
<div className="stat-title">Active</div>
<div className="stat-value text-2xl">{data.running?.length ?? 0}</div>
</div>
<div className="stat">
<div className="stat-figure text-error">
<LuTriangleAlert className="size-7" />
@@ -45,6 +57,11 @@ export function HomePage() {
</div>
</div>
<section>
<h2 className="text-lg font-semibold mb-2">Queued / running</h2>
<RunList runs={data.running} empty="None" />
</section>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<section className="min-w-0">
<div className="mb-2 flex items-baseline justify-between gap-2">
+155 -23
View File
@@ -44,9 +44,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
@@ -538,6 +544,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'}
@@ -575,6 +584,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}
@@ -1065,6 +1104,24 @@ packages:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
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==}
@@ -1078,6 +1135,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==}
@@ -1122,10 +1183,6 @@ packages:
cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
cpu-features@0.0.10:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
cronstrue@3.24.0:
resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==}
hasBin: true
@@ -1338,6 +1395,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'}
@@ -1609,6 +1670,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'}
@@ -1793,6 +1858,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==}
@@ -1856,6 +1925,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
@@ -1871,6 +1947,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}
@@ -1888,6 +1967,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==}
@@ -2035,6 +2118,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'}
@@ -2141,14 +2228,6 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
ssh2-sftp-client@12.1.1:
resolution: {integrity: sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==}
engines: {node: '>=18.20.4'}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
@@ -2791,6 +2870,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':
@@ -2829,6 +2910,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
@@ -3282,11 +3381,6 @@ snapshots:
node-releases: 2.0.53
update-browserslist-db: 1.3.1(browserslist@4.28.8)
buffer-from@1.1.2: {}
buildcheck@0.0.7:
optional: true
byte-length@1.0.2: {}
call-bind-apply-helpers@1.0.2:
@@ -3298,6 +3392,8 @@ snapshots:
charenc@0.0.2: {}
cluster-key-slot@1.1.1: {}
colorette@2.0.19: {}
combined-stream@1.0.8:
@@ -3335,12 +3431,6 @@ snapshots:
dependencies:
layout-base: 2.0.1
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
nan: 2.28.0
optional: true
cronstrue@3.24.0: {}
cross-spawn@7.0.6:
@@ -3567,6 +3657,8 @@ snapshots:
delayed-stream@1.0.0: {}
denque@2.1.0: {}
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -3877,6 +3969,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: {}
@@ -4007,6 +4110,8 @@ snapshots:
dependencies:
yallist: 3.1.1
luxon@3.7.2: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -4080,6 +4185,22 @@ 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: {}
nan@2.28.0:
@@ -4089,6 +4210,8 @@ snapshots:
nested-property@4.0.0: {}
node-abort-controller@3.1.1: {}
node-addon-api@8.9.2: {}
node-cron@4.6.0: {}
@@ -4101,6 +4224,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
@@ -4232,6 +4360,8 @@ snapshots:
dependencies:
resolve: 1.22.12
redis-errors@1.2.0: {}
require-from-string@2.0.2: {}
requires-port@1.0.0: {}
@@ -4339,6 +4469,8 @@ snapshots:
split2@4.2.0: {}
standard-as-callback@2.1.0: {}
ssh2-sftp-client@12.1.1:
dependencies:
concat-stream: 2.0.0