@meetploy/cli 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dev.js +48 -1
- package/dist/index.js +288 -3
- package/package.json +1 -1
package/dist/dev.js
CHANGED
|
@@ -7,9 +7,9 @@ import 'esbuild';
|
|
|
7
7
|
import 'chokidar';
|
|
8
8
|
import { promisify } from 'util';
|
|
9
9
|
import { parse } from 'yaml';
|
|
10
|
+
import { randomUUID, createHmac, pbkdf2Sync, timingSafeEqual, randomBytes } from 'crypto';
|
|
10
11
|
import { serve } from '@hono/node-server';
|
|
11
12
|
import { Hono } from 'hono';
|
|
12
|
-
import { randomUUID, createHmac, pbkdf2Sync, timingSafeEqual, randomBytes } from 'crypto';
|
|
13
13
|
import 'os';
|
|
14
14
|
import { getCookie, deleteCookie, setCookie } from 'hono/cookie';
|
|
15
15
|
import Database from 'better-sqlite3';
|
|
@@ -380,6 +380,7 @@ function createDashboardRoutes(app, dbManager2, config) {
|
|
|
380
380
|
state: config.state,
|
|
381
381
|
fs: config.fs,
|
|
382
382
|
workflow: config.workflow,
|
|
383
|
+
cron: config.cron,
|
|
383
384
|
auth: config.auth
|
|
384
385
|
});
|
|
385
386
|
});
|
|
@@ -904,6 +905,36 @@ function createDashboardRoutes(app, dbManager2, config) {
|
|
|
904
905
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
905
906
|
}
|
|
906
907
|
});
|
|
908
|
+
app.get("/api/cron/:binding/executions", (c) => {
|
|
909
|
+
const binding = c.req.param("binding");
|
|
910
|
+
const cronExpression = config.cron?.[binding];
|
|
911
|
+
const limit = parseInt(c.req.query("limit") ?? "20", 10);
|
|
912
|
+
if (!cronExpression) {
|
|
913
|
+
return c.json({ error: "Cron binding not found" }, 404);
|
|
914
|
+
}
|
|
915
|
+
try {
|
|
916
|
+
const db = dbManager2.emulatorDb;
|
|
917
|
+
const executions = db.prepare(`SELECT id, cron_name, cron_expression, status, error, duration_ms, triggered_at, completed_at, created_at
|
|
918
|
+
FROM cron_executions
|
|
919
|
+
WHERE cron_name = ?
|
|
920
|
+
ORDER BY triggered_at DESC
|
|
921
|
+
LIMIT ?`).all(binding, limit);
|
|
922
|
+
return c.json({
|
|
923
|
+
cronExpression,
|
|
924
|
+
executions: executions.map((e) => ({
|
|
925
|
+
id: e.id,
|
|
926
|
+
status: e.status.toUpperCase(),
|
|
927
|
+
error: e.error,
|
|
928
|
+
durationMs: e.duration_ms,
|
|
929
|
+
triggeredAt: new Date(e.triggered_at * 1e3).toISOString(),
|
|
930
|
+
completedAt: e.completed_at ? new Date(e.completed_at * 1e3).toISOString() : null,
|
|
931
|
+
createdAt: new Date(e.created_at * 1e3).toISOString()
|
|
932
|
+
}))
|
|
933
|
+
});
|
|
934
|
+
} catch (err) {
|
|
935
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
936
|
+
}
|
|
937
|
+
});
|
|
907
938
|
if (hasDashboard) {
|
|
908
939
|
app.get("/assets/*", (c) => {
|
|
909
940
|
const path = c.req.path;
|
|
@@ -1654,6 +1685,22 @@ CREATE TABLE IF NOT EXISTS fs_entries (
|
|
|
1654
1685
|
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
|
1655
1686
|
PRIMARY KEY (fs_name, key)
|
|
1656
1687
|
);
|
|
1688
|
+
|
|
1689
|
+
-- Cron executions table
|
|
1690
|
+
CREATE TABLE IF NOT EXISTS cron_executions (
|
|
1691
|
+
id TEXT PRIMARY KEY,
|
|
1692
|
+
cron_name TEXT NOT NULL,
|
|
1693
|
+
cron_expression TEXT NOT NULL,
|
|
1694
|
+
status TEXT DEFAULT 'running',
|
|
1695
|
+
error TEXT,
|
|
1696
|
+
duration_ms INTEGER,
|
|
1697
|
+
triggered_at INTEGER NOT NULL,
|
|
1698
|
+
completed_at INTEGER,
|
|
1699
|
+
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
|
1700
|
+
);
|
|
1701
|
+
|
|
1702
|
+
CREATE INDEX IF NOT EXISTS idx_cron_executions_name
|
|
1703
|
+
ON cron_executions(cron_name, triggered_at);
|
|
1657
1704
|
`;
|
|
1658
1705
|
function initializeDatabases(projectDir) {
|
|
1659
1706
|
const dataDir = ensureDataDir(projectDir);
|
package/dist/index.js
CHANGED
|
@@ -147,6 +147,22 @@ function validateBindings(bindings, bindingType, configFile) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
+
function validateCronBindings(bindings, configFile) {
|
|
151
|
+
if (bindings === void 0) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const [bindingName, cronExpression] of Object.entries(bindings)) {
|
|
155
|
+
if (!BINDING_NAME_REGEX.test(bindingName)) {
|
|
156
|
+
throw new Error(`Invalid cron binding name '${bindingName}' in ${configFile}. Binding names must be uppercase with underscores (e.g., HOURLY_CLEANUP, DAILY_REPORT)`);
|
|
157
|
+
}
|
|
158
|
+
if (typeof cronExpression !== "string") {
|
|
159
|
+
throw new Error(`Cron binding '${bindingName}' in ${configFile} must have a string value (cron expression)`);
|
|
160
|
+
}
|
|
161
|
+
if (!CRON_EXPRESSION_REGEX.test(cronExpression.trim())) {
|
|
162
|
+
throw new Error(`Invalid cron expression '${cronExpression}' for binding '${bindingName}' in ${configFile}. Must be a valid cron expression with 5 fields (e.g., "0 * * * *", "*/5 * * * *")`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
150
166
|
function validateRelativePath(path, fieldName, configFile) {
|
|
151
167
|
if (path === void 0) {
|
|
152
168
|
return void 0;
|
|
@@ -202,6 +218,7 @@ function validatePloyConfig(config, configFile = "ploy.yaml", options = {}) {
|
|
|
202
218
|
validateBindings(config.state, "state", configFile);
|
|
203
219
|
validateBindings(config.fs, "fs", configFile);
|
|
204
220
|
validateBindings(config.workflow, "workflow", configFile);
|
|
221
|
+
validateCronBindings(config.cron, configFile);
|
|
205
222
|
if (config.ai !== void 0 && typeof config.ai !== "boolean") {
|
|
206
223
|
throw new Error(`'ai' in ${configFile} must be a boolean`);
|
|
207
224
|
}
|
|
@@ -277,7 +294,7 @@ function readAndValidatePloyConfigSync(projectDir, configPath, validationOptions
|
|
|
277
294
|
return validatePloyConfig(config, configFile, validationOptions);
|
|
278
295
|
}
|
|
279
296
|
function hasBindings(config) {
|
|
280
|
-
return !!(config.env ?? config.db ?? config.queue ?? config.cache ?? config.state ?? config.fs ?? config.workflow ?? config.ai ?? config.auth);
|
|
297
|
+
return !!(config.env ?? config.db ?? config.queue ?? config.cache ?? config.state ?? config.fs ?? config.workflow ?? config.cron ?? config.ai ?? config.auth);
|
|
281
298
|
}
|
|
282
299
|
function parseDotEnv(content) {
|
|
283
300
|
const result = {};
|
|
@@ -339,7 +356,7 @@ function getWorkerEntryPoint(projectDir, config) {
|
|
|
339
356
|
}
|
|
340
357
|
throw new Error("Could not find worker entry point. Specify 'main' in ploy.yaml");
|
|
341
358
|
}
|
|
342
|
-
var readFileAsync, DEFAULT_COMPATIBILITY_FLAGS, DEFAULT_COMPATIBILITY_DATE, BINDING_NAME_REGEX, RESOURCE_NAME_REGEX;
|
|
359
|
+
var readFileAsync, DEFAULT_COMPATIBILITY_FLAGS, DEFAULT_COMPATIBILITY_DATE, BINDING_NAME_REGEX, RESOURCE_NAME_REGEX, CRON_EXPRESSION_REGEX;
|
|
343
360
|
var init_ploy_config = __esm({
|
|
344
361
|
"../tools/dist/ploy-config.js"() {
|
|
345
362
|
readFileAsync = promisify(readFile$1);
|
|
@@ -347,6 +364,7 @@ var init_ploy_config = __esm({
|
|
|
347
364
|
DEFAULT_COMPATIBILITY_DATE = "2025-04-02";
|
|
348
365
|
BINDING_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/;
|
|
349
366
|
RESOURCE_NAME_REGEX = /^[a-z][a-z0-9_]*$/;
|
|
367
|
+
CRON_EXPRESSION_REGEX = /^(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)$/;
|
|
350
368
|
}
|
|
351
369
|
});
|
|
352
370
|
|
|
@@ -1280,6 +1298,37 @@ function generateWrapperCode(config, mockServiceUrl, envVars) {
|
|
|
1280
1298
|
}
|
|
1281
1299
|
}
|
|
1282
1300
|
}` : "";
|
|
1301
|
+
const cronHandlerCode = config.cron ? `
|
|
1302
|
+
// Handle cron trigger delivery
|
|
1303
|
+
if (request.headers.get("X-Ploy-Cron-Trigger") === "true") {
|
|
1304
|
+
const cronExpression = request.headers.get("X-Ploy-Cron-Expression");
|
|
1305
|
+
const scheduledTime = parseInt(request.headers.get("X-Ploy-Cron-Scheduled-Time") || String(Date.now()), 10);
|
|
1306
|
+
|
|
1307
|
+
if (cronExpression && userWorker.scheduled) {
|
|
1308
|
+
let noRetryFlag = false;
|
|
1309
|
+
const event = {
|
|
1310
|
+
cron: cronExpression,
|
|
1311
|
+
scheduledTime,
|
|
1312
|
+
noRetry() { noRetryFlag = true; }
|
|
1313
|
+
};
|
|
1314
|
+
try {
|
|
1315
|
+
await userWorker.scheduled(event, injectedEnv, ctx);
|
|
1316
|
+
return new Response(JSON.stringify({ success: true, noRetry: noRetryFlag }), {
|
|
1317
|
+
headers: { "Content-Type": "application/json" }
|
|
1318
|
+
});
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
return new Response(JSON.stringify({ success: false, error: String(error) }), {
|
|
1321
|
+
status: 500,
|
|
1322
|
+
headers: { "Content-Type": "application/json" }
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
return new Response(JSON.stringify({ success: false, error: "No scheduled handler defined" }), {
|
|
1328
|
+
status: 404,
|
|
1329
|
+
headers: { "Content-Type": "application/json" }
|
|
1330
|
+
});
|
|
1331
|
+
}` : "";
|
|
1283
1332
|
const queueHandlerCode = config.queue ? `
|
|
1284
1333
|
// Handle queue message delivery
|
|
1285
1334
|
if (request.headers.get("X-Ploy-Queue-Delivery") === "true") {
|
|
@@ -1323,6 +1372,7 @@ ${bindings.join("\n")}
|
|
|
1323
1372
|
export default {
|
|
1324
1373
|
async fetch(request, env, ctx) {
|
|
1325
1374
|
const injectedEnv = { ...env, ...ployBindings };${envVarsCode}
|
|
1375
|
+
${cronHandlerCode}
|
|
1326
1376
|
${workflowHandlerCode}
|
|
1327
1377
|
${queueHandlerCode}
|
|
1328
1378
|
|
|
@@ -1693,6 +1743,177 @@ var init_workerd_config = __esm({
|
|
|
1693
1743
|
"../emulator/dist/config/workerd-config.js"() {
|
|
1694
1744
|
}
|
|
1695
1745
|
});
|
|
1746
|
+
function parseCronField(field, min, max) {
|
|
1747
|
+
const values = /* @__PURE__ */ new Set();
|
|
1748
|
+
for (const part of field.split(",")) {
|
|
1749
|
+
const trimmed = part.trim();
|
|
1750
|
+
if (trimmed === "*") {
|
|
1751
|
+
for (let i = min; i <= max; i++) {
|
|
1752
|
+
values.add(i);
|
|
1753
|
+
}
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
const stepMatch = trimmed.match(/^(.+)\/(\d+)$/);
|
|
1757
|
+
if (stepMatch) {
|
|
1758
|
+
const step = parseInt(stepMatch[2], 10);
|
|
1759
|
+
const base = stepMatch[1];
|
|
1760
|
+
let start = min;
|
|
1761
|
+
let end = max;
|
|
1762
|
+
if (base !== "*") {
|
|
1763
|
+
const rangeMatch2 = base.match(/^(\d+)-(\d+)$/);
|
|
1764
|
+
if (rangeMatch2) {
|
|
1765
|
+
start = parseInt(rangeMatch2[1], 10);
|
|
1766
|
+
end = parseInt(rangeMatch2[2], 10);
|
|
1767
|
+
} else {
|
|
1768
|
+
start = parseInt(base, 10);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
if (start < min || start > max || end < min || end > max) {
|
|
1772
|
+
throw new Error(`Cron field range ${start}-${end} is out of bounds (${min}-${max})`);
|
|
1773
|
+
}
|
|
1774
|
+
for (let i = start; i <= end; i += step) {
|
|
1775
|
+
values.add(i);
|
|
1776
|
+
}
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/);
|
|
1780
|
+
if (rangeMatch) {
|
|
1781
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
1782
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
1783
|
+
if (start < min || start > max || end < min || end > max) {
|
|
1784
|
+
throw new Error(`Cron field range ${start}-${end} is out of bounds (${min}-${max})`);
|
|
1785
|
+
}
|
|
1786
|
+
for (let i = start; i <= end; i++) {
|
|
1787
|
+
values.add(i);
|
|
1788
|
+
}
|
|
1789
|
+
continue;
|
|
1790
|
+
}
|
|
1791
|
+
const num = parseInt(trimmed, 10);
|
|
1792
|
+
if (isNaN(num)) {
|
|
1793
|
+
throw new Error(`Invalid cron field value: '${trimmed}' (expected number, range, step, or wildcard)`);
|
|
1794
|
+
}
|
|
1795
|
+
if (num < min || num > max) {
|
|
1796
|
+
throw new Error(`Cron field value ${num} is out of range (${min}-${max})`);
|
|
1797
|
+
}
|
|
1798
|
+
values.add(num);
|
|
1799
|
+
}
|
|
1800
|
+
return { values };
|
|
1801
|
+
}
|
|
1802
|
+
function parseCronExpression(expression) {
|
|
1803
|
+
const parts = expression.trim().split(/\s+/);
|
|
1804
|
+
if (parts.length !== 5) {
|
|
1805
|
+
throw new Error(`Invalid cron expression: ${expression} (expected 5 fields)`);
|
|
1806
|
+
}
|
|
1807
|
+
return [
|
|
1808
|
+
parseCronField(parts[0], 0, 59),
|
|
1809
|
+
parseCronField(parts[1], 0, 23),
|
|
1810
|
+
parseCronField(parts[2], 1, 31),
|
|
1811
|
+
parseCronField(parts[3], 1, 12),
|
|
1812
|
+
parseCronField(parts[4], 0, 6)
|
|
1813
|
+
];
|
|
1814
|
+
}
|
|
1815
|
+
function cronMatchesDate(fields, date) {
|
|
1816
|
+
const minute = date.getMinutes();
|
|
1817
|
+
const hour = date.getHours();
|
|
1818
|
+
const dayOfMonth = date.getDate();
|
|
1819
|
+
const month = date.getMonth() + 1;
|
|
1820
|
+
const dayOfWeek = date.getDay();
|
|
1821
|
+
return fields[0].values.has(minute) && fields[1].values.has(hour) && fields[2].values.has(dayOfMonth) && fields[3].values.has(month) && fields[4].values.has(dayOfWeek);
|
|
1822
|
+
}
|
|
1823
|
+
function createCronScheduler(db, cronBindings, workerUrl) {
|
|
1824
|
+
let interval = null;
|
|
1825
|
+
const parsedCrons = /* @__PURE__ */ new Map();
|
|
1826
|
+
let lastCheckedMinute = -1;
|
|
1827
|
+
for (const [name, expression] of Object.entries(cronBindings)) {
|
|
1828
|
+
try {
|
|
1829
|
+
const fields = parseCronExpression(expression);
|
|
1830
|
+
parsedCrons.set(name, { expression, fields });
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
error(`Failed to parse cron expression for '${name}': ${err instanceof Error ? err.message : String(err)}`);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
async function checkCrons() {
|
|
1836
|
+
const now = /* @__PURE__ */ new Date();
|
|
1837
|
+
const currentMinute = Math.floor(now.getTime() / 6e4);
|
|
1838
|
+
if (currentMinute === lastCheckedMinute) {
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
lastCheckedMinute = currentMinute;
|
|
1842
|
+
for (const [name, cron] of parsedCrons) {
|
|
1843
|
+
if (cronMatchesDate(cron.fields, now)) {
|
|
1844
|
+
await triggerScheduledHandler(db, name, cron.expression, now, workerUrl);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
return {
|
|
1849
|
+
start() {
|
|
1850
|
+
if (interval) {
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
log(`Cron scheduler started with ${parsedCrons.size} trigger(s)`);
|
|
1854
|
+
for (const [name, cron] of parsedCrons) {
|
|
1855
|
+
log(` ${name}: ${cron.expression}`);
|
|
1856
|
+
}
|
|
1857
|
+
interval = setInterval(() => {
|
|
1858
|
+
checkCrons().catch((err) => {
|
|
1859
|
+
error(`Cron check error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1860
|
+
});
|
|
1861
|
+
}, 15e3);
|
|
1862
|
+
checkCrons().catch(() => {
|
|
1863
|
+
});
|
|
1864
|
+
},
|
|
1865
|
+
stop() {
|
|
1866
|
+
if (interval) {
|
|
1867
|
+
clearInterval(interval);
|
|
1868
|
+
interval = null;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
async function triggerScheduledHandler(db, cronName, cronExpression, triggerTime, workerUrl) {
|
|
1874
|
+
const executionId = randomUUID();
|
|
1875
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1876
|
+
db.prepare(`INSERT INTO cron_executions (id, cron_name, cron_expression, status, triggered_at)
|
|
1877
|
+
VALUES (?, ?, ?, 'running', ?)`).run(executionId, cronName, cronExpression, now);
|
|
1878
|
+
log(`[cron] Triggering '${cronName}' (${cronExpression}) execution=${executionId}`);
|
|
1879
|
+
try {
|
|
1880
|
+
const response = await fetch(workerUrl, {
|
|
1881
|
+
method: "POST",
|
|
1882
|
+
headers: {
|
|
1883
|
+
"Content-Type": "application/json",
|
|
1884
|
+
"X-Ploy-Cron-Trigger": "true",
|
|
1885
|
+
"X-Ploy-Cron-Name": cronName,
|
|
1886
|
+
"X-Ploy-Cron-Expression": cronExpression,
|
|
1887
|
+
"X-Ploy-Cron-Execution-Id": executionId,
|
|
1888
|
+
"X-Ploy-Cron-Scheduled-Time": String(triggerTime.getTime())
|
|
1889
|
+
},
|
|
1890
|
+
body: JSON.stringify({
|
|
1891
|
+
cron: cronExpression,
|
|
1892
|
+
scheduledTime: triggerTime.getTime()
|
|
1893
|
+
})
|
|
1894
|
+
});
|
|
1895
|
+
const completedAt = Math.floor(Date.now() / 1e3);
|
|
1896
|
+
const durationMs = Date.now() - triggerTime.getTime();
|
|
1897
|
+
if (response.ok) {
|
|
1898
|
+
db.prepare(`UPDATE cron_executions SET status = 'completed', completed_at = ?, duration_ms = ? WHERE id = ?`).run(completedAt, durationMs, executionId);
|
|
1899
|
+
log(`[cron] '${cronName}' completed successfully (${durationMs}ms)`);
|
|
1900
|
+
} else {
|
|
1901
|
+
const errorText = await response.text();
|
|
1902
|
+
db.prepare(`UPDATE cron_executions SET status = 'failed', completed_at = ?, duration_ms = ?, error = ? WHERE id = ?`).run(completedAt, durationMs, errorText, executionId);
|
|
1903
|
+
error(`[cron] '${cronName}' failed: ${errorText}`);
|
|
1904
|
+
}
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
const completedAt = Math.floor(Date.now() / 1e3);
|
|
1907
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1908
|
+
db.prepare(`UPDATE cron_executions SET status = 'failed', completed_at = ?, error = ? WHERE id = ?`).run(completedAt, errorMessage, executionId);
|
|
1909
|
+
error(`[cron] '${cronName}' error: ${errorMessage}`);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
var init_cron_service = __esm({
|
|
1913
|
+
"../emulator/dist/services/cron-service.js"() {
|
|
1914
|
+
init_logger();
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1696
1917
|
function getProjectHash(projectDir) {
|
|
1697
1918
|
return createHash("sha256").update(projectDir).digest("hex").slice(0, 12);
|
|
1698
1919
|
}
|
|
@@ -2035,6 +2256,7 @@ function createDashboardRoutes(app, dbManager2, config) {
|
|
|
2035
2256
|
state: config.state,
|
|
2036
2257
|
fs: config.fs,
|
|
2037
2258
|
workflow: config.workflow,
|
|
2259
|
+
cron: config.cron,
|
|
2038
2260
|
auth: config.auth
|
|
2039
2261
|
});
|
|
2040
2262
|
});
|
|
@@ -2559,6 +2781,36 @@ function createDashboardRoutes(app, dbManager2, config) {
|
|
|
2559
2781
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
2560
2782
|
}
|
|
2561
2783
|
});
|
|
2784
|
+
app.get("/api/cron/:binding/executions", (c) => {
|
|
2785
|
+
const binding = c.req.param("binding");
|
|
2786
|
+
const cronExpression = config.cron?.[binding];
|
|
2787
|
+
const limit = parseInt(c.req.query("limit") ?? "20", 10);
|
|
2788
|
+
if (!cronExpression) {
|
|
2789
|
+
return c.json({ error: "Cron binding not found" }, 404);
|
|
2790
|
+
}
|
|
2791
|
+
try {
|
|
2792
|
+
const db = dbManager2.emulatorDb;
|
|
2793
|
+
const executions = db.prepare(`SELECT id, cron_name, cron_expression, status, error, duration_ms, triggered_at, completed_at, created_at
|
|
2794
|
+
FROM cron_executions
|
|
2795
|
+
WHERE cron_name = ?
|
|
2796
|
+
ORDER BY triggered_at DESC
|
|
2797
|
+
LIMIT ?`).all(binding, limit);
|
|
2798
|
+
return c.json({
|
|
2799
|
+
cronExpression,
|
|
2800
|
+
executions: executions.map((e) => ({
|
|
2801
|
+
id: e.id,
|
|
2802
|
+
status: e.status.toUpperCase(),
|
|
2803
|
+
error: e.error,
|
|
2804
|
+
durationMs: e.duration_ms,
|
|
2805
|
+
triggeredAt: new Date(e.triggered_at * 1e3).toISOString(),
|
|
2806
|
+
completedAt: e.completed_at ? new Date(e.completed_at * 1e3).toISOString() : null,
|
|
2807
|
+
createdAt: new Date(e.created_at * 1e3).toISOString()
|
|
2808
|
+
}))
|
|
2809
|
+
});
|
|
2810
|
+
} catch (err) {
|
|
2811
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
2812
|
+
}
|
|
2813
|
+
});
|
|
2562
2814
|
if (hasDashboard) {
|
|
2563
2815
|
app.get("/assets/*", (c) => {
|
|
2564
2816
|
const path = c.req.path;
|
|
@@ -3463,6 +3715,22 @@ CREATE TABLE IF NOT EXISTS fs_entries (
|
|
|
3463
3715
|
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
|
3464
3716
|
PRIMARY KEY (fs_name, key)
|
|
3465
3717
|
);
|
|
3718
|
+
|
|
3719
|
+
-- Cron executions table
|
|
3720
|
+
CREATE TABLE IF NOT EXISTS cron_executions (
|
|
3721
|
+
id TEXT PRIMARY KEY,
|
|
3722
|
+
cron_name TEXT NOT NULL,
|
|
3723
|
+
cron_expression TEXT NOT NULL,
|
|
3724
|
+
status TEXT DEFAULT 'running',
|
|
3725
|
+
error TEXT,
|
|
3726
|
+
duration_ms INTEGER,
|
|
3727
|
+
triggered_at INTEGER NOT NULL,
|
|
3728
|
+
completed_at INTEGER,
|
|
3729
|
+
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
|
3730
|
+
);
|
|
3731
|
+
|
|
3732
|
+
CREATE INDEX IF NOT EXISTS idx_cron_executions_name
|
|
3733
|
+
ON cron_executions(cron_name, triggered_at);
|
|
3466
3734
|
`;
|
|
3467
3735
|
}
|
|
3468
3736
|
});
|
|
@@ -3479,6 +3747,7 @@ var init_emulator = __esm({
|
|
|
3479
3747
|
init_env();
|
|
3480
3748
|
init_ploy_config2();
|
|
3481
3749
|
init_workerd_config();
|
|
3750
|
+
init_cron_service();
|
|
3482
3751
|
init_mock_server();
|
|
3483
3752
|
init_queue_service();
|
|
3484
3753
|
init_logger();
|
|
@@ -3494,6 +3763,7 @@ var init_emulator = __esm({
|
|
|
3494
3763
|
workerdProcess = null;
|
|
3495
3764
|
fileWatcher = null;
|
|
3496
3765
|
queueProcessor = null;
|
|
3766
|
+
cronScheduler = null;
|
|
3497
3767
|
resolvedEnvVars = {};
|
|
3498
3768
|
constructor(options = {}) {
|
|
3499
3769
|
const port = options.port ?? 8787;
|
|
@@ -3561,6 +3831,12 @@ var init_emulator = __esm({
|
|
|
3561
3831
|
this.queueProcessor.start();
|
|
3562
3832
|
debug("Queue processor started", this.options.verbose);
|
|
3563
3833
|
}
|
|
3834
|
+
if (this.config.cron) {
|
|
3835
|
+
const workerUrl2 = `http://${this.options.host}:${String(this.options.port)}`;
|
|
3836
|
+
this.cronScheduler = createCronScheduler(this.dbManager.emulatorDb, this.config.cron, workerUrl2);
|
|
3837
|
+
this.cronScheduler.start();
|
|
3838
|
+
debug("Cron scheduler started", this.options.verbose);
|
|
3839
|
+
}
|
|
3564
3840
|
success(`Emulator running at http://${this.options.host}:${String(this.options.port)}`);
|
|
3565
3841
|
log(` Dashboard: http://${this.options.host}:${String(this.mockServer.port)}`);
|
|
3566
3842
|
if (Object.keys(this.resolvedEnvVars).length > 0) {
|
|
@@ -3581,6 +3857,11 @@ var init_emulator = __esm({
|
|
|
3581
3857
|
if (this.config.workflow) {
|
|
3582
3858
|
log(` Workflow bindings: ${Object.keys(this.config.workflow).join(", ")}`);
|
|
3583
3859
|
}
|
|
3860
|
+
if (this.config.cron) {
|
|
3861
|
+
for (const [name, expr] of Object.entries(this.config.cron)) {
|
|
3862
|
+
log(` Cron: ${name} = ${expr}`);
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3584
3865
|
this.setupSignalHandlers();
|
|
3585
3866
|
} catch (err) {
|
|
3586
3867
|
error(`Failed to start emulator: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -3705,6 +3986,10 @@ var init_emulator = __esm({
|
|
|
3705
3986
|
process.on("SIGTERM", handler);
|
|
3706
3987
|
}
|
|
3707
3988
|
async stop() {
|
|
3989
|
+
if (this.cronScheduler) {
|
|
3990
|
+
this.cronScheduler.stop();
|
|
3991
|
+
this.cronScheduler = null;
|
|
3992
|
+
}
|
|
3708
3993
|
if (this.queueProcessor) {
|
|
3709
3994
|
this.queueProcessor.stop();
|
|
3710
3995
|
this.queueProcessor = null;
|
|
@@ -5641,7 +5926,7 @@ async function typesCommand(options = {}) {
|
|
|
5641
5926
|
console.error("Error: ploy.yaml not found in current directory");
|
|
5642
5927
|
process.exit(1);
|
|
5643
5928
|
}
|
|
5644
|
-
const hasBindings2 = config.env || config.ai || config.db || config.queue || config.cache || config.state || config.workflow;
|
|
5929
|
+
const hasBindings2 = config.env || config.ai || config.db || config.queue || config.cache || config.state || config.workflow || config.cron;
|
|
5645
5930
|
if (!hasBindings2) {
|
|
5646
5931
|
console.log("No bindings found in ploy.yaml. Generating empty Env.");
|
|
5647
5932
|
}
|