Add initial project structure with database setup, logging, and workflow management

This commit is contained in:
2026-08-14 11:36:54 +07:00
commit 5dc992a517
21 changed files with 2080 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
data/
logs/
*.db
*.db-*
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
import knex from "knex";
import config from "./knexfile.js";
export const db = knex(config);
/**
* Apply pending migrations. Call once before registering workflows.
*/
export async function migrate() {
await db.migrate.latest();
}
+35
View File
@@ -0,0 +1,35 @@
### Manual trigger (default owner)
POST http://localhost:9000/u/default/mt
Content-Type: application/json
0
HTTP/1.1 200 - OK
content-type: application/json; charset=utf-8
content-length: 73
date: Fri, 14 Aug 2026 04:34:56 GMT
connection: close
###
POST http://localhost:9000/u/default/time-to-ntfy
Content-Type: application/json
{}
HTTP/1.1 500 - Internal Server Error
content-type: application/json; charset=utf-8
content-length: 97
date: Fri, 14 Aug 2026 04:11:52 GMT
connection: close
###
GET http://localhost:9000/admin/runs?owner=default&limit=20
###
POST http://localhost:9000/admin/workflows/reregister
Content-Type: application/json
{}
HTTP/1.1 200 - OK
content-type: application/json; charset=utf-8
content-length: 33
date: Fri, 14 Aug 2026 04:34:22 GMT
connection: close
+33
View File
@@ -0,0 +1,33 @@
import fs from "fs";
import path from "path";
const dbPath = process.env.SCRUNNER_DB_PATH ?? path.resolve("data/scrunner.db");
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
/** @type {import("knex").Knex.Config} */
const config = {
client: "better-sqlite3",
connection: {
filename: dbPath,
},
useNullAsDefault: true,
migrations: {
directory: "./migrations",
extension: "js",
loadExtensions: [".js"],
},
pool: {
afterCreate(conn, done) {
try {
conn.pragma("journal_mode = WAL");
conn.pragma("busy_timeout = 5000");
conn.pragma("foreign_keys = ON");
done(null, conn);
} catch (err) {
done(err, conn);
}
},
},
};
export default config;
+125
View File
@@ -0,0 +1,125 @@
import fs from "fs";
import path from "path";
import pino from "pino";
import * as store from "./store.js";
const LEVEL_TO_NUM = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
};
const FLUSH_MS = 200;
/** @type {Array<{ runId: string, stepId?: string | null, ts: string, level: number, msg?: string | null, payload?: unknown }>} */
let buffer = [];
let flushChain = Promise.resolve();
let ready = false;
let timer = null;
/**
* @param {string} line
*/
function enqueueLine(line) {
let record;
try {
record = JSON.parse(line);
} catch {
return;
}
if (!record.runId) return;
const {
runId,
stepId = null,
time,
level,
msg = null,
...rest
} = record;
const ts =
typeof time === "number"
? new Date(time).toISOString()
: typeof time === "string"
? time
: new Date().toISOString();
const levelNum =
typeof level === "number"
? level
: LEVEL_TO_NUM[level] ?? LEVEL_TO_NUM.info;
const payload = Object.keys(rest).length ? rest : null;
buffer.push({ runId, stepId, ts, level: levelNum, msg, payload });
}
function scheduleFlush() {
if (timer) return;
timer = setTimeout(() => {
timer = null;
void flush();
}, FLUSH_MS);
timer.unref?.();
}
async function flush() {
if (!ready || buffer.length === 0) return;
const batch = buffer;
buffer = [];
flushChain = flushChain
.then(() => store.insertLogs(batch))
.catch((err) => {
// Re-queue failed batch so we don't lose run-scoped logs on transient errors.
buffer = batch.concat(buffer);
console.error("failed to flush logs to sqlite", err);
});
await flushChain;
}
const sqliteStream = {
write(line) {
enqueueLine(typeof line === "string" ? line : String(line));
scheduleFlush();
},
};
fs.mkdirSync("logs", { recursive: true });
const rollingFile = pino.transport({
target: "pino-roll",
options: {
file: path.resolve("logs/scrunner.log"),
size: "10m",
mkdir: true,
limit: { count: 5 },
},
});
export const log = pino(
{ level: process.env.SCRUNNER_LOG_LEVEL ?? "debug" },
pino.multistream([
{ level: "debug", stream: process.stdout },
{ level: "debug", stream: rollingFile },
{ level: "debug", stream: sqliteStream },
]),
);
/** Allow the SQLite log writer to flush after migrations have completed. */
export function enableLogPersistence() {
ready = true;
void flush();
}
/** Flush remaining buffered log lines (call on shutdown). */
export async function flushLogs() {
if (timer) {
clearTimeout(timer);
timer = null;
}
await flush();
await flushChain;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("workflow_runs", (t) => {
t.text("id").primary();
t.text("owner").notNullable();
t.text("workflow").notNullable();
t.text("workflow_name");
t.text("trigger_type").notNullable();
t.text("trigger_detail");
t.text("status").notNullable();
t.text("started_at").notNullable();
t.text("finished_at");
t.integer("duration_ms");
t.text("input");
t.text("output");
t.text("error");
t.text("parent_run_id").references("id").inTable("workflow_runs");
});
await knex.schema.raw(
"CREATE INDEX workflow_runs_owner_started_at_idx ON workflow_runs (owner, started_at DESC)",
);
await knex.schema.raw(
"CREATE INDEX workflow_runs_workflow_started_at_idx ON workflow_runs (workflow, started_at DESC)",
);
await knex.schema.raw(
"CREATE INDEX workflow_runs_status_started_at_idx ON workflow_runs (status, started_at DESC)",
);
await knex.schema.createTable("step_runs", (t) => {
t.text("id").primary();
t.text("run_id")
.notNullable()
.references("id")
.inTable("workflow_runs")
.onDelete("CASCADE");
t.integer("step_index").notNullable();
t.text("script").notNullable();
t.text("config");
t.text("status").notNullable();
t.text("started_at").notNullable();
t.text("finished_at");
t.integer("duration_ms");
t.text("output");
t.text("error");
});
await knex.schema.raw(
"CREATE INDEX step_runs_run_id_step_index_idx ON step_runs (run_id, step_index)",
);
await knex.schema.createTable("logs", (t) => {
t.increments("id").primary();
t.text("run_id")
.notNullable()
.references("id")
.inTable("workflow_runs")
.onDelete("CASCADE");
t.text("step_id");
t.text("ts").notNullable();
t.integer("level").notNullable();
t.text("msg");
t.text("payload");
});
await knex.schema.raw(
"CREATE INDEX logs_run_id_ts_idx ON logs (run_id, ts)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("logs");
await knex.schema.dropTableIfExists("step_runs");
await knex.schema.dropTableIfExists("workflow_runs");
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "scrunner",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.25.0",
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
]
},
"dependencies": {
"axios": "^1.19.0",
"better-sqlite3": "^13.0.3",
"fastify": "^5.12.0",
"jsonata": "^2.2.2",
"knex": "^3.3.0",
"node-cron": "^4.6.0",
"pino": "^10.3.1",
"pino-roll": "^4.0.0",
"yaml": "^2.9.0"
}
}
+842
View File
@@ -0,0 +1,842 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
axios:
specifier: ^1.19.0
version: 1.19.0
better-sqlite3:
specifier: ^13.0.3
version: 13.0.3
fastify:
specifier: ^5.12.0
version: 5.12.0
jsonata:
specifier: ^2.2.2
version: 2.2.2
knex:
specifier: ^3.3.0
version: 3.3.0(better-sqlite3@13.0.3)
node-cron:
specifier: ^4.6.0
version: 4.6.0
pino:
specifier: ^10.3.1
version: 10.3.1
pino-roll:
specifier: ^4.0.0
version: 4.0.0
yaml:
specifier: ^2.9.0
version: 2.9.0
packages:
'@fastify/ajv-compiler@4.0.6':
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
'@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
'@fastify/fast-json-stringify-compiler@5.1.0':
resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==}
'@fastify/forwarded@3.0.2':
resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==}
'@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
'@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
avvio@9.3.0:
resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
axios@1.19.0:
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
better-sqlite3@13.0.3:
resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==}
engines: {node: '>=22'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
colorette@2.0.19:
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
esm@3.2.25:
resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==}
engines: {node: '>=6'}
fast-decode-uri-component@1.0.1:
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-json-stringify@7.0.1:
resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==}
fast-querystring@1.1.2:
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fast-uri@4.1.2:
resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==}
fastify@5.12.0:
resolution: {integrity: sha512-A3RNEaDIHWaxFW8n8rNJaW1wQ+XAXuoU71llfUQJjuh5WaYLmKfRhhanaJBOx8m2EBPQkR6sDBovYdHNe9F6rA==}
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
find-my-way@9.7.0:
resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==}
engines: {node: '>=20'}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-package-type@0.1.0:
resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
engines: {node: '>=8.0.0'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
getopts@2.3.0:
resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
interpret@2.2.0:
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
engines: {node: '>= 0.10'}
ipaddr.js@2.5.0:
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
engines: {node: '>= 10'}
is-core-module@2.16.2:
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
engines: {node: '>= 0.4'}
json-schema-ref-resolver@3.0.0:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
jsonata@2.2.2:
resolution: {integrity: sha512-XDFH2PuaAXv0AXJEWwElXbqADmKFGKMLMkFb+qOz0EhjQW2EIJ6pgtordLU+4u3IVERyBfqy6bi6kZKEncvRqA==}
engines: {node: '>= 8'}
knex@3.3.0:
resolution: {integrity: sha512-LgWl031hNuLv9Lhxdd9093zULa2aNcoP04Bk29e5NidgRcEr/T0rAr2WYi4BhjTiCDoQiRU56meUE5rppLKZzQ==}
engines: {node: '>=16'}
hasBin: true
peerDependencies:
better-sqlite3: '*'
mariadb: '*'
mysql: '*'
mysql2: '*'
pg: '*'
pg-native: '*'
pg-query-stream: ^4.14.0
sqlite3: '*'
tedious: '*'
peerDependenciesMeta:
better-sqlite3:
optional: true
mariadb:
optional: true
mysql:
optional: true
mysql2:
optional: true
pg:
optional: true
pg-native:
optional: true
pg-query-stream:
optional: true
sqlite3:
optional: true
tedious:
optional: true
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
node-addon-api@8.9.2:
resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==}
engines: {node: ^18 || ^20 || >= 21}
node-cron@4.6.0:
resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==}
engines: {node: '>=20'}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
pg-connection-string@2.6.2:
resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==}
pino-abstract-transport@3.0.0:
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
pino-roll@4.0.0:
resolution: {integrity: sha512-axI1aQaIxXdw1F4OFFli1EDxIrdYNGLowkw/ZoZogX8oCSLHUghzwVVXUS8U+xD/Savwa5IXpiXmsSGKFX/7Sg==}
pino-std-serializers@7.1.0:
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
pino@10.3.1:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
process-warning@4.0.1:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
real-require@1.0.0:
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
rechoir@0.8.0:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
resolve@1.22.12:
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
engines: {node: '>= 0.4'}
hasBin: true
ret@0.5.0:
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
engines: {node: '>=10'}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
safe-regex2@5.1.1:
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
hasBin: true
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tarn@3.1.2:
resolution: {integrity: sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==}
engines: {node: '>=8.0.0'}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
tildify@2.0.0:
resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==}
engines: {node: '>=8'}
toad-cache@3.7.4:
resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==}
engines: {node: '>=20'}
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots:
'@fastify/ajv-compiler@4.0.6':
dependencies:
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 4.1.2
'@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.1.0':
dependencies:
fast-json-stringify: 7.0.1
'@fastify/forwarded@3.0.2': {}
'@fastify/merge-json-schemas@0.2.1':
dependencies:
dequal: 2.0.3
'@fastify/proxy-addr@5.1.0':
dependencies:
'@fastify/forwarded': 3.0.2
ipaddr.js: 2.5.0
'@pinojs/redact@0.4.0': {}
abstract-logging@2.0.1: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.5
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {}
avvio@9.3.0:
dependencies:
'@fastify/error': 4.2.0
fastq: 1.20.1
axios@1.19.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
better-sqlite3@13.0.3:
dependencies:
node-addon-api: 8.9.2
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
colorette@2.0.19: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@10.0.1: {}
cookie@1.1.1: {}
date-fns@4.4.0: {}
debug@4.3.4:
dependencies:
ms: 2.1.2
debug@4.4.3:
dependencies:
ms: 2.1.3
delayed-stream@1.0.0: {}
dequal@2.0.3: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
escalade@3.2.0: {}
esm@3.2.25: {}
fast-decode-uri-component@1.0.1: {}
fast-deep-equal@3.1.3: {}
fast-json-stringify@7.0.1:
dependencies:
'@fastify/merge-json-schemas': 0.2.1
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 4.1.2
json-schema-ref-resolver: 3.0.0
rfdc: 1.4.1
fast-querystring@1.1.2:
dependencies:
fast-decode-uri-component: 1.0.1
fast-uri@3.1.5: {}
fast-uri@4.1.2: {}
fastify@5.12.0:
dependencies:
'@fastify/ajv-compiler': 4.0.6
'@fastify/error': 4.2.0
'@fastify/fast-json-stringify-compiler': 5.1.0
'@fastify/proxy-addr': 5.1.0
abstract-logging: 2.0.1
avvio: 9.3.0
fast-json-stringify: 7.0.1
find-my-way: 9.7.0
light-my-request: 6.6.0
pino: 10.3.1
process-warning: 5.1.0
rfdc: 1.4.1
secure-json-parse: 4.1.0
semver: 7.8.5
toad-cache: 3.7.4
fastq@1.20.1:
dependencies:
reusify: 1.1.0
find-my-way@9.7.0:
dependencies:
fast-deep-equal: 3.1.3
fast-querystring: 1.1.2
safe-regex2: 5.1.1
follow-redirects@1.16.0: {}
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
get-package-type@0.1.0: {}
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
getopts@2.3.0: {}
gopd@1.2.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
interpret@2.2.0: {}
ipaddr.js@2.5.0: {}
is-core-module@2.16.2:
dependencies:
hasown: 2.0.4
json-schema-ref-resolver@3.0.0:
dependencies:
dequal: 2.0.3
json-schema-traverse@1.0.0: {}
jsonata@2.2.2: {}
knex@3.3.0(better-sqlite3@13.0.3):
dependencies:
colorette: 2.0.19
commander: 10.0.1
debug: 4.3.4
escalade: 3.2.0
esm: 3.2.25
get-package-type: 0.1.0
getopts: 2.3.0
interpret: 2.2.0
lodash: 4.18.1
pg-connection-string: 2.6.2
rechoir: 0.8.0
resolve-from: 5.0.0
tarn: 3.1.2
tildify: 2.0.0
optionalDependencies:
better-sqlite3: 13.0.3
transitivePeerDependencies:
- supports-color
light-my-request@6.6.0:
dependencies:
cookie: 1.1.1
process-warning: 4.0.1
set-cookie-parser: 2.7.2
lodash@4.18.1: {}
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
ms@2.1.2: {}
ms@2.1.3: {}
node-addon-api@8.9.2: {}
node-cron@4.6.0: {}
on-exit-leak-free@2.1.2: {}
path-parse@1.0.7: {}
pg-connection-string@2.6.2: {}
pino-abstract-transport@3.0.0:
dependencies:
split2: 4.2.0
pino-roll@4.0.0:
dependencies:
date-fns: 4.4.0
sonic-boom: 4.2.1
pino-std-serializers@7.1.0: {}
pino@10.3.1:
dependencies:
'@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pino-std-serializers: 7.1.0
process-warning: 5.1.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.1
thread-stream: 4.2.0
process-warning@4.0.1: {}
process-warning@5.1.0: {}
proxy-from-env@2.1.0: {}
quick-format-unescaped@4.0.4: {}
real-require@0.2.0: {}
real-require@1.0.0: {}
rechoir@0.8.0:
dependencies:
resolve: 1.22.12
require-from-string@2.0.2: {}
resolve-from@5.0.0: {}
resolve@1.22.12:
dependencies:
es-errors: 1.3.0
is-core-module: 2.16.2
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
ret@0.5.0: {}
reusify@1.1.0: {}
rfdc@1.4.1: {}
safe-regex2@5.1.1:
dependencies:
ret: 0.5.0
safe-stable-stringify@2.5.0: {}
secure-json-parse@4.1.0: {}
semver@7.8.5: {}
set-cookie-parser@2.7.2: {}
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
split2@4.2.0: {}
supports-preserve-symlinks-flag@1.0.0: {}
tarn@3.1.2: {}
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
tildify@2.0.0: {}
toad-cache@3.7.4: {}
yaml@2.9.0: {}
+350
View File
@@ -0,0 +1,350 @@
import fs from "fs";
import path from "path";
import yaml from "yaml";
import fastify from "fastify";
import cron from "node-cron";
import { migrate, db } from "./db.js";
import { log, enableLogPersistence, flushLogs } from "./logger.js";
import * as store from "./store.js";
import { clearScriptCache, runScript } from "./script-sandbox.js";
await migrate();
enableLogPersistence();
/**
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
*/
/**
* @type {Map<string, WorkflowEntry>}
*/
const workflows = new Map();
/**
* @type {import("node-cron").ScheduledTask[]}
*/
const cronTasks = [];
/**
* @type {import("node-cron").ScheduledTask | null}
*/
let pruneTask = null;
/**
* @type {Set<string>}
*/
const registeredHttpRoutes = new Set();
function parseScriptStep(step) {
if (typeof step === "string") return { script: step, config: null };
if (step?.script) return { script: step.script, config: step.config ?? null };
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
}
/**
* Resolve an HTTP path under the owner namespace: /notify -> /u/alice/notify
* @param {string} owner
* @param {string} triggerPath
*/
function namespacedPath(owner, triggerPath) {
const cleaned = String(triggerPath).replace(/^\/+/, "");
return `/u/${owner}/${cleaned}`;
}
function registerWorkflows() {
workflows.clear();
clearScriptCache();
const workflowsRoot = "workflows";
if (!fs.existsSync(workflowsRoot)) {
log.warn("workflows directory missing");
return;
}
const owners = fs
.readdirSync(workflowsRoot, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const owner of owners) {
const registersPath = path.join(workflowsRoot, owner, "registers.yaml");
if (!fs.existsSync(registersPath)) {
log.warn(`Skipping owner "${owner}": no registers.yaml`);
continue;
}
let workflowFiles = [];
try {
const registerData = fs.readFileSync(registersPath, "utf8");
const parsed = yaml.parse(registerData) ?? {};
workflowFiles = parsed.scripts ?? [];
} catch (err) {
log.error({ err, owner }, "failed to parse registers.yaml");
continue;
}
const ownerDir = path.join(workflowsRoot, owner);
const onDisk = fs
.readdirSync(ownerDir)
.filter((f) => f.endsWith(".yaml") && f !== "registers.yaml");
for (const file of onDisk) {
if (!workflowFiles.includes(file)) {
log.warn(
`Workflow file not in registers.yaml: ${owner}/${file}`,
);
}
}
for (const file of workflowFiles) {
const key = `${owner}/${file}`;
const filePath = path.join(ownerDir, file);
try {
const workflowData = fs.readFileSync(filePath, "utf8");
const workflow = yaml.parse(workflowData);
workflows.set(key, { owner, file, workflow });
} catch (err) {
log.error({ err, workflow: key }, "failed to load workflow; skipping");
}
}
}
log.debug({ count: workflows.size }, "workflows loaded");
}
function registerHttpTriggers() {
const seen = new Set();
for (const [key, { owner, workflow }] of workflows) {
if (workflow.enabled === false) {
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
continue;
}
for (const trigger of workflow.triggers ?? []) {
if (trigger.type !== "HTTP") continue;
const method = String(trigger.method ?? "POST").toUpperCase();
const url = namespacedPath(owner, trigger.path);
const routeKey = `${method} ${url}`;
if (seen.has(routeKey)) {
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
continue;
}
seen.add(routeKey);
if (registeredHttpRoutes.has(routeKey)) {
continue;
}
registeredHttpRoutes.add(routeKey);
server.route({
method,
url,
handler: async (req, reply) => {
const result = await runWorkflow(
key,
{ data: req.body },
{ type: "http", detail: `${method} ${url}` },
);
if (result.status === "failed") {
return reply.code(500).send({
runId: result.runId,
error: result.error,
});
}
return reply.send({
runId: result.runId,
result: result.result,
});
},
});
log.debug(`Registered HTTP trigger ${routeKey} (${key})`);
}
}
}
function registerCronTriggers() {
for (const task of cronTasks) {
task.destroy();
}
cronTasks.length = 0;
for (const [key, { workflow }] of workflows) {
if (workflow.enabled === false) {
log.debug(`Skipping disabled workflow cron triggers (${key})`);
continue;
}
for (const trigger of workflow.triggers ?? []) {
if (trigger.type !== "cron") continue;
const schedule = trigger.schedule;
if (!schedule || !cron.validate(schedule)) {
log.warn(`Skipping invalid cron schedule "${schedule}" (${key})`);
continue;
}
const task = cron.schedule(
schedule,
() => {
log.debug(`cron firing ${key} (${schedule})`);
return runWorkflow(
key,
{ data: workflow.data ?? null },
{ type: "cron", detail: schedule },
);
},
{ name: `${key}:${schedule}`, noOverlap: true },
);
cronTasks.push(task);
log.debug(`Registered cron trigger ${schedule} (${key})`);
}
}
}
function registerPruneJob() {
if (pruneTask) {
pruneTask.destroy();
pruneTask = null;
}
const days = Number(process.env.SCRUNNER_RETENTION_DAYS ?? 30);
pruneTask = cron.schedule(
"0 0 * * *",
async () => {
try {
const deleted = await store.pruneOlderThan(days);
log.info({ deleted, days }, "pruned old workflow runs");
} catch (err) {
log.error({ err }, "failed to prune old workflow runs");
}
},
{ name: "prune-runs" },
);
}
/**
* @param {string} key
* @param {{ data?: unknown }} context
* @param {{ type: string, detail?: string | null }} trigger
*/
async function runWorkflow(key, context, trigger) {
const entry = workflows.get(key);
if (!entry) {
log.error({ workflow: key }, "workflow not found");
return { runId: null, status: "failed", error: "workflow not found" };
}
const { owner, workflow } = entry;
const run = await store.startRun({
owner,
workflow: key,
workflowName: workflow?.name,
trigger,
input: context.data,
});
const runLog = log.child({ runId: run.id, owner, workflow: key });
runLog.debug("running workflow");
let ctx = {
...context,
data: context.data ?? workflow.data ?? null,
};
try {
for (const [index, rawStep] of (workflow.scripts ?? []).entries()) {
const { script, config } = parseScriptStep(rawStep);
const step = await store.startStep({
runId: run.id,
index,
script,
config,
});
const stepLog = runLog.child({ stepId: step.id, script });
try {
ctx = await runScript(script, { ...ctx, config }, {
log: stepLog,
workflowName: key,
});
await store.finishStep(step.id, "success", ctx);
} catch (err) {
await store.finishStep(step.id, "failed", null, err);
throw err;
}
}
await store.finishRun(run.id, "success", ctx);
return { runId: run.id, status: "success", result: ctx };
} catch (err) {
runLog.error({ err }, "workflow failed");
await store.finishRun(run.id, "failed", null, err);
return {
runId: run.id,
status: "failed",
error: err instanceof Error ? err.message : String(err),
};
}
}
registerWorkflows();
const server = fastify({ loggerInstance: log });
registerHttpTriggers();
registerCronTriggers();
registerPruneJob();
server.post("/admin/workflows/reregister", async (_req, reply) => {
registerWorkflows();
// Fastify cannot remove routes; new routes are added, cron is rebuilt.
registerHttpTriggers();
registerCronTriggers();
return reply.send({ message: "Workflows refreshed" });
});
server.get("/admin/runs", 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", 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);
});
async function shutdown() {
try {
await flushLogs();
await db.destroy();
} catch (err) {
log.error({ err }, "shutdown error");
}
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
server
.listen({
host: "0.0.0.0",
port: 9000,
})
.then(() => {
log.info("Server is running on port 9000");
})
.catch((err) => {
log.error({ err }, "failed to start server");
process.exit(1);
});
+245
View File
@@ -0,0 +1,245 @@
import fs from "fs";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const hostRequire = createRequire(import.meta.url);
const ALLOWED_MODULES = new Set(["axios", "jsonata"]);
const BUILTIN_NAMES = [
"Infinity",
"NaN",
"undefined",
"Object",
"Array",
"String",
"Boolean",
"Number",
"Symbol",
"BigInt",
"Error",
"AggregateError",
"EvalError",
"RangeError",
"ReferenceError",
"SyntaxError",
"TypeError",
"URIError",
"Math",
"Date",
"JSON",
"RegExp",
"Map",
"Set",
"WeakMap",
"WeakSet",
"WeakRef",
"FinalizationRegistry",
"ArrayBuffer",
"DataView",
"Int8Array",
"Uint8Array",
"Uint8ClampedArray",
"Int16Array",
"Uint16Array",
"Int32Array",
"Uint32Array",
"Float16Array",
"Float32Array",
"Float64Array",
"BigInt64Array",
"BigUint64Array",
"Promise",
"Proxy",
"Reflect",
"Intl",
"URL",
"URLSearchParams",
"Headers",
"Request",
"Response",
"FormData",
"Blob",
"File",
"AbortController",
"AbortSignal",
"TextEncoder",
"TextDecoder",
"Atomics",
"parseInt",
"parseFloat",
"isNaN",
"isFinite",
"encodeURI",
"encodeURIComponent",
"decodeURI",
"decodeURIComponent",
"setTimeout",
"setInterval",
"clearTimeout",
"clearInterval",
"setImmediate",
"clearImmediate",
"queueMicrotask",
"structuredClone",
"fetch",
"atob",
"btoa",
"Buffer",
"crypto",
"performance",
];
/**
* @type {Map<string, { compiled: import("node:vm").Script, mtimeMs: number }>}
*/
const scriptCache = new Map();
export function clearScriptCache() {
scriptCache.clear();
}
function pickBuiltins() {
/** @type {Record<string, unknown>} */
const builtins = {};
for (const name of BUILTIN_NAMES) {
if (typeof globalThis[name] !== "undefined") {
builtins[name] = globalThis[name];
}
}
return builtins;
}
function transformEsmToCjs(source) {
let code = source;
code = code.replace(
/^\s*import\s+(\w+)\s+from\s+["']([^"']+)["'];?\s*$/gm,
'const $1 = require("$2");',
);
code = code.replace(
/^\s*import\s+\*\s+as\s+(\w+)\s+from\s+["']([^"']+)["'];?\s*$/gm,
'const $1 = require("$2");',
);
code = code.replace(
/^\s*import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["'];?\s*$/gm,
"const {$1} = require(\"$2\");",
);
code = code.replace(
/^\s*import\s+["']([^"']+)["'];?\s*$/gm,
'require("$1");',
);
const replaced = code.replace(
/^\s*export\s+default\s+/m,
"module.exports.default = ",
);
if (replaced === code) {
throw new Error("script must have a default export");
}
return replaced;
}
function wrapScriptSource(source, filename) {
const body = transformEsmToCjs(source);
return `"use strict";
var module = { exports: {} };
var exports = module.exports;
${body}
if (typeof module.exports.default !== "function") {
throw new TypeError(${JSON.stringify(filename)} + " default export must be a function");
}
module.exports.default;
`;
}
/**
* @param {import("pino").Logger} logger
*/
function createConsole(logger) {
const write = (level) => (...args) => {
if (args.length === 0) {
logger[level]("");
return;
}
const [first, ...rest] = args;
if (typeof first === "string") {
if (rest.length === 0) {
logger[level](first);
} else {
logger[level]({ args: rest }, first);
}
return;
}
logger[level]({ args });
};
return {
log: write("info"),
info: write("info"),
warn: write("warn"),
error: write("error"),
debug: write("debug"),
trace: write("trace"),
};
}
function createRestrictedRequire() {
return function restrictedRequire(id) {
if (typeof id !== "string" || !ALLOWED_MODULES.has(id)) {
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
}
return hostRequire(id);
};
}
/**
* @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts
*/
function createScriptSandbox({ log, script, workflowName }) {
const scriptLog = log.child({ workflow: workflowName, script });
const sandbox = {
...pickBuiltins(),
log: scriptLog,
console: createConsole(scriptLog),
require: createRestrictedRequire(),
};
vm.createContext(sandbox, {
name: `scrunner:${workflowName}:${script}`,
codeGeneration: { strings: false, wasm: false },
});
return sandbox;
}
function loadCompiledScript(script) {
const filePath = fileURLToPath(new URL(`./scripts/${script}`, import.meta.url));
const { mtimeMs } = fs.statSync(filePath);
const cached = scriptCache.get(script);
if (cached && cached.mtimeMs === mtimeMs) {
return cached.compiled;
}
const source = fs.readFileSync(filePath, "utf8");
const compiled = new vm.Script(wrapScriptSource(source, script), {
filename: filePath,
});
scriptCache.set(script, { compiled, mtimeMs });
return compiled;
}
/**
* Evaluate a workflow script's default export inside a restricted vm context.
*
* @param {string} script
* @param {unknown} ctx
* @param {{ log: import("pino").Logger, workflowName: string }} opts
*/
export async function runScript(script, ctx, { log, workflowName }) {
const compiled = loadCompiledScript(script);
const sandbox = createScriptSandbox({ log, script, workflowName });
const fn = compiled.runInContext(sandbox);
return await fn(ctx);
}
+4
View File
@@ -0,0 +1,4 @@
export default function main(context) {
log.info({ data: context.data }, "add-one");
return { data: (context.data || 0) + 1 };
}
+4
View File
@@ -0,0 +1,4 @@
export default function main(context) {
log.info({ input: context.input }, "add-ten");
return context.input + 10;
}
+10
View File
@@ -0,0 +1,10 @@
// this script will get current time
export default function getCurrentTime() {
return {
data: {
datetime: new Date().toISOString(),
processId: "1234"
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import jsonata from "jsonata";
export default function jsonataFn(context) {
const expression = jsonata(context.config.expression);
return expression.evaluate(context.data)
}
+14
View File
@@ -0,0 +1,14 @@
import axios from "axios";
export default function ntfy(ctx) {
log.info({ ctx }, "ntfy");
const headers = {}
if(ctx.data?.title) {
headers.Title = ctx.data.title
}
axios.post(ctx.config?.url || "https://ntfy.sh/scrunner", ctx.data?.message || "Hello from scrunner", {
headers: headers
})
return {sent: "true"}
}
+237
View File
@@ -0,0 +1,237 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
const MAX_JSON_BYTES = 64 * 1024;
/**
* @param {unknown} value
* @returns {string | null}
*/
export function serialize(value) {
if (value === undefined || value === null) return null;
let json;
try {
json = JSON.stringify(value);
} catch {
json = JSON.stringify({ truncated: true, reason: "unserializable" });
}
if (Buffer.byteLength(json, "utf8") <= MAX_JSON_BYTES) return json;
return JSON.stringify({
truncated: true,
preview: json.slice(0, 1024),
});
}
/**
* @param {string | null} value
* @returns {unknown}
*/
function deserialize(value) {
if (value == null) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function nowIso() {
return new Date().toISOString();
}
/**
* @param {{
* owner: string,
* workflow: string,
* workflowName?: string | null,
* trigger: { type: string, detail?: string | null },
* input?: unknown,
* parentRunId?: string | null,
* }} opts
*/
export async function startRun({
owner,
workflow,
workflowName = null,
trigger,
input = null,
parentRunId = null,
}) {
const id = randomUUID();
const started_at = nowIso();
await db("workflow_runs").insert({
id,
owner,
workflow,
workflow_name: workflowName ?? null,
trigger_type: trigger.type,
trigger_detail: trigger.detail ?? null,
status: "running",
started_at,
input: serialize(input),
parent_run_id: parentRunId ?? null,
});
return { id, started_at };
}
/**
* @param {string} id
* @param {"success" | "failed"} status
* @param {unknown} [output]
* @param {Error | unknown} [err]
*/
export async function finishRun(id, status, output = null, err = null) {
const finished_at = nowIso();
const row = await db("workflow_runs").where({ id }).first("started_at");
const duration_ms = row
? Date.parse(finished_at) - Date.parse(row.started_at)
: null;
await db("workflow_runs")
.where({ id })
.update({
status,
finished_at,
duration_ms,
output: serialize(output),
error: err
? err instanceof Error
? err.message
: String(err)
: null,
});
}
/**
* @param {{
* runId: string,
* index: number,
* script: string,
* config?: unknown,
* }} opts
*/
export async function startStep({ runId, index, script, config = null }) {
const id = randomUUID();
const started_at = nowIso();
await db("step_runs").insert({
id,
run_id: runId,
step_index: index,
script,
config: serialize(config),
status: "running",
started_at,
});
return { id, started_at };
}
/**
* @param {string} id
* @param {"success" | "failed"} status
* @param {unknown} [output]
* @param {Error | unknown} [err]
*/
export async function finishStep(id, status, output = null, err = null) {
const finished_at = nowIso();
const row = await db("step_runs").where({ id }).first("started_at");
const duration_ms = row
? Date.parse(finished_at) - Date.parse(row.started_at)
: null;
await db("step_runs")
.where({ id })
.update({
status,
finished_at,
duration_ms,
output: serialize(output),
error: err
? err instanceof Error
? err.message
: String(err)
: null,
});
}
/**
* @param {Array<{
* runId: string,
* stepId?: string | null,
* ts: string,
* level: number,
* msg?: string | null,
* payload?: unknown,
* }>} rows
*/
export async function insertLogs(rows) {
if (!rows.length) return;
await db("logs").insert(
rows.map((r) => ({
run_id: r.runId,
step_id: r.stepId ?? null,
ts: r.ts,
level: r.level,
msg: r.msg ?? null,
payload: serialize(r.payload),
})),
);
}
/**
* @param {{
* owner?: string,
* workflow?: string,
* status?: string,
* limit?: number,
* before?: string,
* }} [filters]
*/
export async function listRuns(filters = {}) {
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
if (filters.owner) q = q.where("owner", filters.owner);
if (filters.workflow) q = q.where("workflow", filters.workflow);
if (filters.status) 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) => ({
...row,
input: deserialize(row.input),
output: deserialize(row.output),
}));
}
/**
* @param {string} id
*/
export async function getRun(id) {
const run = await db("workflow_runs").where({ id }).first();
if (!run) return null;
const steps = await db("step_runs")
.where({ run_id: id })
.orderBy("step_index", "asc");
const logs = await db("logs").where({ run_id: id }).orderBy("ts", "asc").orderBy("id", "asc");
return {
...run,
input: deserialize(run.input),
output: deserialize(run.output),
steps: steps.map((s) => ({
...s,
config: deserialize(s.config),
output: deserialize(s.output),
})),
logs: logs.map((l) => ({
...l,
payload: deserialize(l.payload),
})),
};
}
/**
* @param {number} days
* @returns {Promise<number>} number of deleted runs
*/
export async function pruneOlderThan(days) {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
return db("workflow_runs").where("started_at", "<", cutoff).del();
}
+10
View File
@@ -0,0 +1,10 @@
name: cron example
description: |
this workflow triggered by cron
scripts:
- script: ntfy.js
config:
url: https://ntfy.sh/scrunner
triggers:
- type: cron
schedule: "*/20 * * * *"
+9
View File
@@ -0,0 +1,9 @@
name: manual trigger
scripts:
- add-one.js
- add-ten.js
triggers:
- type: HTTP
method: POST
path: /mt
+5
View File
@@ -0,0 +1,5 @@
scripts:
- time-to-ntfy-example.yaml
- test.yaml
- manual-trigger.yaml
- cron-example.yaml
+12
View File
@@ -0,0 +1,12 @@
name: manual trigger
scripts:
- get-current-time.js
- script: jsonata.js
config:
expression: '{"data": {"message": datetime & " " & processId}}'
- ntfy.js
triggers:
- type: HTTP
method: POST
path: /mt
@@ -0,0 +1,12 @@
name: time to ntfy example
description: |
this workflow will send a message to ntfy with the current time
scripts:
- get-current-time.js
- script: ntfy.js
config:
url: https://ntfy.sh/scrunner
triggers:
- type: HTTP
method: POST
path: /time-to-ntfy