856 lines
26 KiB
TypeScript
856 lines
26 KiB
TypeScript
/**
|
|
* Schedule module — persistent cron-based job scheduling.
|
|
*
|
|
* Replaces system-level crontabs with an in-process scheduler.
|
|
* Jobs are stored in Automerge (survives restarts), evaluated on
|
|
* a 60-second tick loop, and can execute emails, webhooks,
|
|
* calendar events, broadcasts, or backlog briefings.
|
|
*
|
|
* All persistence uses Automerge documents via SyncServer.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import * as Automerge from "@automerge/automerge";
|
|
import { createTransport, type Transporter } from "nodemailer";
|
|
import { CronExpressionParser } from "cron-parser";
|
|
import { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import { renderLanding } from "./landing";
|
|
import type { SyncServer } from "../../server/local-first/sync-server";
|
|
import {
|
|
scheduleSchema,
|
|
scheduleDocId,
|
|
MAX_LOG_ENTRIES,
|
|
} from "./schemas";
|
|
import type {
|
|
ScheduleDoc,
|
|
ScheduleJob,
|
|
ExecutionLogEntry,
|
|
ActionType,
|
|
} from "./schemas";
|
|
import { calendarDocId } from "../rcal/schemas";
|
|
import type { CalendarDoc } from "../rcal/schemas";
|
|
|
|
let _syncServer: SyncServer | null = null;
|
|
|
|
const routes = new Hono();
|
|
|
|
// ── SMTP transport (lazy init) ──
|
|
|
|
let _smtpTransport: Transporter | null = null;
|
|
|
|
function getSmtpTransport(): Transporter | null {
|
|
if (_smtpTransport) return _smtpTransport;
|
|
if (!process.env.SMTP_PASS) return null;
|
|
_smtpTransport = createTransport({
|
|
host: process.env.SMTP_HOST || "mail.rmail.online",
|
|
port: Number(process.env.SMTP_PORT) || 587,
|
|
secure: Number(process.env.SMTP_PORT) === 465,
|
|
auth: {
|
|
user: process.env.SMTP_USER || "noreply@rmail.online",
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
tls: { rejectUnauthorized: false },
|
|
});
|
|
return _smtpTransport;
|
|
}
|
|
|
|
// ── Local-first helpers ──
|
|
|
|
function ensureDoc(space: string): ScheduleDoc {
|
|
const docId = scheduleDocId(space);
|
|
let doc = _syncServer!.getDoc<ScheduleDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(
|
|
Automerge.init<ScheduleDoc>(),
|
|
"init schedule",
|
|
(d) => {
|
|
const init = scheduleSchema.init();
|
|
d.meta = init.meta;
|
|
d.meta.spaceSlug = space;
|
|
d.jobs = {};
|
|
d.log = [];
|
|
},
|
|
);
|
|
_syncServer!.setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
// ── Cron helpers ──
|
|
|
|
function computeNextRun(cronExpression: string, timezone: string): number | null {
|
|
try {
|
|
const interval = CronExpressionParser.parse(cronExpression, {
|
|
currentDate: new Date(),
|
|
tz: timezone,
|
|
});
|
|
return interval.next().toDate().getTime();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function cronToHuman(expr: string): string {
|
|
const parts = expr.split(/\s+/);
|
|
if (parts.length !== 5) return expr;
|
|
const [min, hour, dom, mon, dow] = parts;
|
|
|
|
const dowNames: Record<string, string> = {
|
|
"0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed",
|
|
"4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun",
|
|
"1-5": "weekdays", "0,6": "weekends",
|
|
};
|
|
|
|
if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow === "*")
|
|
return `Daily at ${hour}:00`;
|
|
if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow === "1-5")
|
|
return `Weekdays at ${hour}:00`;
|
|
if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow !== "*")
|
|
return `${dowNames[dow] || dow} at ${hour}:00`;
|
|
if (min === "0" && hour !== "*" && dom !== "*" && mon === "*" && dow === "*")
|
|
return `Monthly on day ${dom} at ${hour}:00`;
|
|
if (min === "*" && hour === "*" && dom === "*" && mon === "*" && dow === "*")
|
|
return "Every minute";
|
|
if (min.startsWith("*/"))
|
|
return `Every ${min.slice(2)} minutes`;
|
|
return expr;
|
|
}
|
|
|
|
// ── Template helpers ──
|
|
|
|
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
let result = template;
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
result = result.replaceAll(`{{${key}}}`, value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Action executors ──
|
|
|
|
async function executeEmail(
|
|
job: ScheduleJob,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const transport = getSmtpTransport();
|
|
if (!transport)
|
|
return { success: false, message: "SMTP not configured (SMTP_PASS missing)" };
|
|
|
|
const config = job.actionConfig as {
|
|
to?: string;
|
|
subject?: string;
|
|
bodyTemplate?: string;
|
|
};
|
|
if (!config.to)
|
|
return { success: false, message: "No recipient (to) configured" };
|
|
|
|
const vars = {
|
|
date: new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }),
|
|
jobName: job.name,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
const subject = renderTemplate(config.subject || `[rSchedule] ${job.name}`, vars);
|
|
const html = renderTemplate(config.bodyTemplate || `<p>Scheduled job <strong>${job.name}</strong> executed at ${vars.date}.</p>`, vars);
|
|
|
|
await transport.sendMail({
|
|
from: process.env.SMTP_FROM || "rSchedule <noreply@rmail.online>",
|
|
to: config.to,
|
|
subject,
|
|
html,
|
|
});
|
|
|
|
return { success: true, message: `Email sent to ${config.to}` };
|
|
}
|
|
|
|
async function executeWebhook(
|
|
job: ScheduleJob,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const config = job.actionConfig as {
|
|
url?: string;
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
bodyTemplate?: string;
|
|
};
|
|
if (!config.url)
|
|
return { success: false, message: "No webhook URL configured" };
|
|
|
|
const vars = {
|
|
date: new Date().toISOString(),
|
|
jobName: job.name,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
const method = (config.method || "POST").toUpperCase();
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...config.headers,
|
|
};
|
|
|
|
const body = method !== "GET"
|
|
? renderTemplate(config.bodyTemplate || JSON.stringify({ job: job.name, timestamp: vars.date }), vars)
|
|
: undefined;
|
|
|
|
const res = await fetch(config.url, { method, headers, body });
|
|
if (!res.ok)
|
|
return { success: false, message: `Webhook ${res.status}: ${await res.text().catch(() => "")}` };
|
|
|
|
return { success: true, message: `Webhook ${method} ${config.url} → ${res.status}` };
|
|
}
|
|
|
|
async function executeCalendarEvent(
|
|
job: ScheduleJob,
|
|
space: string,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
if (!_syncServer)
|
|
return { success: false, message: "SyncServer not available" };
|
|
|
|
const config = job.actionConfig as {
|
|
title?: string;
|
|
duration?: number;
|
|
sourceId?: string;
|
|
};
|
|
|
|
const calDocId = calendarDocId(space);
|
|
const calDoc = _syncServer.getDoc<CalendarDoc>(calDocId);
|
|
if (!calDoc)
|
|
return { success: false, message: `Calendar doc not found for space ${space}` };
|
|
|
|
const eventId = crypto.randomUUID();
|
|
const now = Date.now();
|
|
const durationMs = (config.duration || 60) * 60 * 1000;
|
|
|
|
_syncServer.changeDoc<CalendarDoc>(calDocId, `rSchedule: create event for ${job.name}`, (d) => {
|
|
d.events[eventId] = {
|
|
id: eventId,
|
|
title: config.title || job.name,
|
|
description: `Auto-created by rSchedule job: ${job.name}`,
|
|
startTime: now,
|
|
endTime: now + durationMs,
|
|
allDay: false,
|
|
timezone: job.timezone || "UTC",
|
|
rrule: null,
|
|
status: null,
|
|
visibility: null,
|
|
sourceId: config.sourceId || null,
|
|
sourceName: null,
|
|
sourceType: null,
|
|
sourceColor: null,
|
|
locationId: null,
|
|
locationName: null,
|
|
coordinates: null,
|
|
locationGranularity: null,
|
|
locationLat: null,
|
|
locationLng: null,
|
|
isVirtual: false,
|
|
virtualUrl: null,
|
|
virtualPlatform: null,
|
|
rToolSource: "rSchedule",
|
|
rToolEntityId: job.id,
|
|
attendees: [],
|
|
attendeeCount: 0,
|
|
metadata: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
});
|
|
|
|
return { success: true, message: `Calendar event '${config.title || job.name}' created (${eventId})` };
|
|
}
|
|
|
|
async function executeBroadcast(
|
|
job: ScheduleJob,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const config = job.actionConfig as {
|
|
channel?: string;
|
|
message?: string;
|
|
};
|
|
|
|
// Broadcast via SyncServer's WebSocket connections is not directly accessible
|
|
// from module code. For now, log the intent. Future: expose ws broadcast on SyncServer.
|
|
const msg = config.message || `Scheduled broadcast from ${job.name}`;
|
|
console.log(`[Schedule] Broadcast (${config.channel || "default"}): ${msg}`);
|
|
return { success: true, message: `Broadcast sent: ${msg}` };
|
|
}
|
|
|
|
async function executeBacklogBriefing(
|
|
job: ScheduleJob,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const config = job.actionConfig as {
|
|
mode?: "morning" | "weekly" | "monthly";
|
|
scanPaths?: string[];
|
|
to?: string;
|
|
};
|
|
|
|
const transport = getSmtpTransport();
|
|
if (!transport)
|
|
return { success: false, message: "SMTP not configured (SMTP_PASS missing)" };
|
|
if (!config.to)
|
|
return { success: false, message: "No recipient (to) configured" };
|
|
|
|
const mode = config.mode || "morning";
|
|
const scanPaths = config.scanPaths || ["/data/communities/*/backlog/tasks/"];
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
|
|
|
// Scan for backlog task files
|
|
const { readdir, readFile, stat } = await import("node:fs/promises");
|
|
const { join, basename } = await import("node:path");
|
|
const { Glob } = await import("bun");
|
|
|
|
interface TaskInfo {
|
|
file: string;
|
|
title: string;
|
|
priority: string;
|
|
status: string;
|
|
updatedAt: Date | null;
|
|
staleDays: number;
|
|
}
|
|
|
|
const tasks: TaskInfo[] = [];
|
|
|
|
for (const pattern of scanPaths) {
|
|
try {
|
|
const glob = new Glob(pattern.endsWith("/") ? pattern + "*.md" : pattern);
|
|
for await (const filePath of glob.scan()) {
|
|
try {
|
|
const content = await readFile(filePath, "utf-8");
|
|
const fstat = await stat(filePath);
|
|
|
|
// Parse YAML frontmatter
|
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
let title = basename(filePath, ".md").replace(/-/g, " ");
|
|
let priority = "medium";
|
|
let status = "open";
|
|
|
|
if (fmMatch) {
|
|
const fm = fmMatch[1];
|
|
const titleMatch = fm.match(/^title:\s*(.+)$/m);
|
|
const prioMatch = fm.match(/^priority:\s*(.+)$/m);
|
|
const statusMatch = fm.match(/^status:\s*(.+)$/m);
|
|
if (titleMatch) title = titleMatch[1].replace(/^["']|["']$/g, "");
|
|
if (prioMatch) priority = prioMatch[1].trim().toLowerCase();
|
|
if (statusMatch) status = statusMatch[1].trim().toLowerCase();
|
|
}
|
|
|
|
const staleDays = Math.floor(
|
|
(now.getTime() - fstat.mtime.getTime()) / (1000 * 60 * 60 * 24),
|
|
);
|
|
|
|
tasks.push({ file: filePath, title, priority, status, updatedAt: fstat.mtime, staleDays });
|
|
} catch {
|
|
// Skip unreadable files
|
|
}
|
|
}
|
|
} catch {
|
|
// Glob pattern didn't match or dir doesn't exist
|
|
}
|
|
}
|
|
|
|
// Filter and sort based on mode
|
|
let filtered = tasks.filter((t) => t.status !== "done" && t.status !== "closed");
|
|
let subject: string;
|
|
let heading: string;
|
|
|
|
switch (mode) {
|
|
case "morning":
|
|
// High/urgent priority + recently updated
|
|
filtered = filtered
|
|
.filter((t) => t.priority === "high" || t.priority === "urgent" || t.staleDays < 3)
|
|
.sort((a, b) => {
|
|
const priOrder: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 };
|
|
return (priOrder[a.priority] ?? 2) - (priOrder[b.priority] ?? 2);
|
|
});
|
|
subject = `Morning Briefing — ${dateStr}`;
|
|
heading = "Good morning! Here's your task briefing:";
|
|
break;
|
|
case "weekly":
|
|
// All open tasks sorted by priority then staleness
|
|
filtered.sort((a, b) => {
|
|
const priOrder: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 };
|
|
const pDiff = (priOrder[a.priority] ?? 2) - (priOrder[b.priority] ?? 2);
|
|
return pDiff !== 0 ? pDiff : b.staleDays - a.staleDays;
|
|
});
|
|
subject = `Weekly Backlog Review — ${dateStr}`;
|
|
heading = "Weekly review of all open tasks:";
|
|
break;
|
|
case "monthly":
|
|
// Focus on stale items (> 14 days untouched)
|
|
filtered = filtered
|
|
.filter((t) => t.staleDays > 14)
|
|
.sort((a, b) => b.staleDays - a.staleDays);
|
|
subject = `Monthly Backlog Audit — ${dateStr}`;
|
|
heading = "Monthly audit — these tasks haven't been touched in 14+ days:";
|
|
break;
|
|
}
|
|
|
|
// Build HTML email
|
|
const taskRows = filtered.length > 0
|
|
? filtered
|
|
.slice(0, 50)
|
|
.map((t) => {
|
|
const prioColor: Record<string, string> = {
|
|
urgent: "#ef4444", high: "#f97316", medium: "#f59e0b", low: "#6b7280",
|
|
};
|
|
return `<tr>
|
|
<td style="padding:8px;border-bottom:1px solid #334155">
|
|
<span style="color:${prioColor[t.priority] || "#9ca3af"};font-weight:600;text-transform:uppercase;font-size:11px">${t.priority}</span>
|
|
</td>
|
|
<td style="padding:8px;border-bottom:1px solid #334155;color:#e2e8f0">${t.title}</td>
|
|
<td style="padding:8px;border-bottom:1px solid #334155;color:#94a3b8">${t.status}</td>
|
|
<td style="padding:8px;border-bottom:1px solid #334155;color:#94a3b8">${t.staleDays}d ago</td>
|
|
</tr>`;
|
|
})
|
|
.join("\n")
|
|
: `<tr><td colspan="4" style="padding:16px;color:#6b7280;text-align:center">No tasks match this filter.</td></tr>`;
|
|
|
|
const html = `
|
|
<div style="font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;padding:32px;border-radius:12px;max-width:640px;margin:0 auto">
|
|
<h1 style="font-size:20px;color:#f59e0b;margin:0 0 8px">${heading}</h1>
|
|
<p style="color:#94a3b8;font-size:13px;margin:0 0 24px">${dateStr} • ${filtered.length} task${filtered.length !== 1 ? "s" : ""}</p>
|
|
<table style="width:100%;border-collapse:collapse;font-size:14px">
|
|
<thead>
|
|
<tr style="border-bottom:2px solid #475569">
|
|
<th style="padding:8px;text-align:left;color:#94a3b8;font-size:11px;text-transform:uppercase">Priority</th>
|
|
<th style="padding:8px;text-align:left;color:#94a3b8;font-size:11px;text-transform:uppercase">Task</th>
|
|
<th style="padding:8px;text-align:left;color:#94a3b8;font-size:11px;text-transform:uppercase">Status</th>
|
|
<th style="padding:8px;text-align:left;color:#94a3b8;font-size:11px;text-transform:uppercase">Last Update</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${taskRows}</tbody>
|
|
</table>
|
|
<p style="color:#475569;font-size:11px;margin:24px 0 0;text-align:center">
|
|
Sent by rSchedule • <a href="https://rspace.online/demo/schedule" style="color:#f59e0b">Manage Schedules</a>
|
|
</p>
|
|
</div>
|
|
`;
|
|
|
|
await transport.sendMail({
|
|
from: process.env.SMTP_FROM || "rSchedule <noreply@rmail.online>",
|
|
to: config.to,
|
|
subject: `[rSchedule] ${subject}`,
|
|
html,
|
|
});
|
|
|
|
return { success: true, message: `${mode} briefing sent to ${config.to} (${filtered.length} tasks)` };
|
|
}
|
|
|
|
// ── Unified executor ──
|
|
|
|
async function executeJob(
|
|
job: ScheduleJob,
|
|
space: string,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
switch (job.actionType) {
|
|
case "email":
|
|
return executeEmail(job);
|
|
case "webhook":
|
|
return executeWebhook(job);
|
|
case "calendar-event":
|
|
return executeCalendarEvent(job, space);
|
|
case "broadcast":
|
|
return executeBroadcast(job);
|
|
case "backlog-briefing":
|
|
return executeBacklogBriefing(job);
|
|
default:
|
|
return { success: false, message: `Unknown action type: ${job.actionType}` };
|
|
}
|
|
}
|
|
|
|
// ── Tick loop ──
|
|
|
|
const TICK_INTERVAL = 60_000;
|
|
|
|
function startTickLoop() {
|
|
console.log("[Schedule] Tick loop started — checking every 60s");
|
|
|
|
const tick = async () => {
|
|
if (!_syncServer) return;
|
|
|
|
const now = Date.now();
|
|
|
|
// Iterate all known schedule docs
|
|
// Convention: check the "demo" space and any spaces that have schedule docs
|
|
const spaceSlugs = new Set<string>();
|
|
spaceSlugs.add("demo");
|
|
|
|
// Also scan for any schedule docs already loaded
|
|
const allDocs = _syncServer.listDocs();
|
|
for (const docId of allDocs) {
|
|
const match = docId.match(/^(.+):schedule:jobs$/);
|
|
if (match) spaceSlugs.add(match[1]);
|
|
}
|
|
|
|
for (const space of spaceSlugs) {
|
|
try {
|
|
const docId = scheduleDocId(space);
|
|
const doc = _syncServer.getDoc<ScheduleDoc>(docId);
|
|
if (!doc) continue;
|
|
|
|
const dueJobs = Object.values(doc.jobs).filter(
|
|
(j) => j.enabled && j.nextRunAt && j.nextRunAt <= now,
|
|
);
|
|
|
|
for (const job of dueJobs) {
|
|
const startMs = Date.now();
|
|
let result: { success: boolean; message: string };
|
|
|
|
try {
|
|
result = await executeJob(job, space);
|
|
} catch (e: any) {
|
|
result = { success: false, message: e.message || String(e) };
|
|
}
|
|
|
|
const durationMs = Date.now() - startMs;
|
|
const logEntry: ExecutionLogEntry = {
|
|
id: crypto.randomUUID(),
|
|
jobId: job.id,
|
|
status: result.success ? "success" : "error",
|
|
message: result.message,
|
|
durationMs,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
console.log(
|
|
`[Schedule] ${result.success ? "OK" : "ERR"} ${job.name} (${durationMs}ms): ${result.message}`,
|
|
);
|
|
|
|
// Update job state + append log
|
|
_syncServer.changeDoc<ScheduleDoc>(docId, `run job ${job.id}`, (d) => {
|
|
const j = d.jobs[job.id];
|
|
if (!j) return;
|
|
j.lastRunAt = Date.now();
|
|
j.lastRunStatus = result.success ? "success" : "error";
|
|
j.lastRunMessage = result.message;
|
|
j.runCount = (j.runCount || 0) + 1;
|
|
j.nextRunAt = computeNextRun(j.cronExpression, j.timezone) ?? null;
|
|
|
|
// Append log entry, trim to max
|
|
d.log.push(logEntry);
|
|
while (d.log.length > MAX_LOG_ENTRIES) {
|
|
d.log.splice(0, 1);
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Schedule] Tick error for space ${space}:`, e);
|
|
}
|
|
}
|
|
};
|
|
|
|
setTimeout(tick, 10_000); // First tick after 10s
|
|
setInterval(tick, TICK_INTERVAL);
|
|
}
|
|
|
|
// ── Seed default jobs ──
|
|
|
|
const SEED_JOBS: Omit<ScheduleJob, "lastRunAt" | "lastRunStatus" | "lastRunMessage" | "nextRunAt" | "runCount" | "createdAt" | "updatedAt">[] = [
|
|
{
|
|
id: "backlog-morning",
|
|
name: "Morning Backlog Briefing",
|
|
description: "Weekday morning digest of high-priority and recently-updated tasks.",
|
|
enabled: true,
|
|
cronExpression: "0 14 * * 1-5",
|
|
timezone: "America/Vancouver",
|
|
actionType: "backlog-briefing",
|
|
actionConfig: { mode: "morning", to: "jeff@jeffemmett.com" },
|
|
createdBy: "system",
|
|
},
|
|
{
|
|
id: "backlog-weekly",
|
|
name: "Weekly Backlog Review",
|
|
description: "Friday afternoon review of all open tasks sorted by priority and staleness.",
|
|
enabled: true,
|
|
cronExpression: "0 22 * * 5",
|
|
timezone: "America/Vancouver",
|
|
actionType: "backlog-briefing",
|
|
actionConfig: { mode: "weekly", to: "jeff@jeffemmett.com" },
|
|
createdBy: "system",
|
|
},
|
|
{
|
|
id: "backlog-monthly",
|
|
name: "Monthly Backlog Audit",
|
|
description: "First of the month audit of stale tasks (14+ days untouched).",
|
|
enabled: true,
|
|
cronExpression: "0 14 1 * *",
|
|
timezone: "America/Vancouver",
|
|
actionType: "backlog-briefing",
|
|
actionConfig: { mode: "monthly", to: "jeff@jeffemmett.com" },
|
|
createdBy: "system",
|
|
},
|
|
];
|
|
|
|
function seedDefaultJobs(space: string) {
|
|
const docId = scheduleDocId(space);
|
|
const doc = ensureDoc(space);
|
|
|
|
if (Object.keys(doc.jobs).length > 0) return;
|
|
|
|
const now = Date.now();
|
|
_syncServer!.changeDoc<ScheduleDoc>(docId, "seed default jobs", (d) => {
|
|
for (const seed of SEED_JOBS) {
|
|
d.jobs[seed.id] = {
|
|
...seed,
|
|
lastRunAt: null,
|
|
lastRunStatus: null,
|
|
lastRunMessage: "",
|
|
nextRunAt: computeNextRun(seed.cronExpression, seed.timezone),
|
|
runCount: 0,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
});
|
|
|
|
console.log(`[Schedule] Seeded ${SEED_JOBS.length} default jobs for space "${space}"`);
|
|
}
|
|
|
|
// ── API routes ──
|
|
|
|
// GET / — serve schedule UI
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(
|
|
renderShell({
|
|
title: `${space} — Schedule | rSpace`,
|
|
moduleId: "schedule",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-schedule-app space="${space}"></folk-schedule-app>`,
|
|
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-app.js?v=1"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rschedule/schedule.css?v=1">`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// GET /api/jobs — list all jobs
|
|
routes.get("/api/jobs", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const doc = ensureDoc(space);
|
|
const jobs = Object.values(doc.jobs).map((j) => ({
|
|
...j,
|
|
cronHuman: cronToHuman(j.cronExpression),
|
|
}));
|
|
jobs.sort((a, b) => a.name.localeCompare(b.name));
|
|
return c.json({ count: jobs.length, results: jobs });
|
|
});
|
|
|
|
// POST /api/jobs — create a new job
|
|
routes.post("/api/jobs", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const body = await c.req.json();
|
|
|
|
const { name, description, cronExpression, timezone, actionType, actionConfig, enabled } = body;
|
|
if (!name?.trim() || !cronExpression || !actionType)
|
|
return c.json({ error: "name, cronExpression, and actionType required" }, 400);
|
|
|
|
// Validate cron expression
|
|
try {
|
|
CronExpressionParser.parse(cronExpression);
|
|
} catch {
|
|
return c.json({ error: "Invalid cron expression" }, 400);
|
|
}
|
|
|
|
const docId = scheduleDocId(space);
|
|
ensureDoc(space);
|
|
const jobId = crypto.randomUUID();
|
|
const now = Date.now();
|
|
const tz = timezone || "UTC";
|
|
|
|
_syncServer!.changeDoc<ScheduleDoc>(docId, `create job ${jobId}`, (d) => {
|
|
d.jobs[jobId] = {
|
|
id: jobId,
|
|
name: name.trim(),
|
|
description: description || "",
|
|
enabled: enabled !== false,
|
|
cronExpression,
|
|
timezone: tz,
|
|
actionType,
|
|
actionConfig: actionConfig || {},
|
|
lastRunAt: null,
|
|
lastRunStatus: null,
|
|
lastRunMessage: "",
|
|
nextRunAt: computeNextRun(cronExpression, tz),
|
|
runCount: 0,
|
|
createdBy: "user",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
|
|
return c.json(updated.jobs[jobId], 201);
|
|
});
|
|
|
|
// GET /api/jobs/:id
|
|
routes.get("/api/jobs/:id", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const id = c.req.param("id");
|
|
const doc = ensureDoc(space);
|
|
|
|
const job = doc.jobs[id];
|
|
if (!job) return c.json({ error: "Job not found" }, 404);
|
|
return c.json({ ...job, cronHuman: cronToHuman(job.cronExpression) });
|
|
});
|
|
|
|
// PUT /api/jobs/:id — update a job
|
|
routes.put("/api/jobs/:id", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const id = c.req.param("id");
|
|
const body = await c.req.json();
|
|
|
|
const docId = scheduleDocId(space);
|
|
const doc = ensureDoc(space);
|
|
if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404);
|
|
|
|
// Validate cron if provided
|
|
if (body.cronExpression) {
|
|
try {
|
|
CronExpressionParser.parse(body.cronExpression);
|
|
} catch {
|
|
return c.json({ error: "Invalid cron expression" }, 400);
|
|
}
|
|
}
|
|
|
|
_syncServer!.changeDoc<ScheduleDoc>(docId, `update job ${id}`, (d) => {
|
|
const j = d.jobs[id];
|
|
if (!j) return;
|
|
if (body.name !== undefined) j.name = body.name;
|
|
if (body.description !== undefined) j.description = body.description;
|
|
if (body.enabled !== undefined) j.enabled = body.enabled;
|
|
if (body.cronExpression !== undefined) {
|
|
j.cronExpression = body.cronExpression;
|
|
j.nextRunAt = computeNextRun(body.cronExpression, body.timezone || j.timezone);
|
|
}
|
|
if (body.timezone !== undefined) {
|
|
j.timezone = body.timezone;
|
|
j.nextRunAt = computeNextRun(j.cronExpression, body.timezone);
|
|
}
|
|
if (body.actionType !== undefined) j.actionType = body.actionType;
|
|
if (body.actionConfig !== undefined) j.actionConfig = body.actionConfig;
|
|
j.updatedAt = Date.now();
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
|
|
return c.json(updated.jobs[id]);
|
|
});
|
|
|
|
// DELETE /api/jobs/:id
|
|
routes.delete("/api/jobs/:id", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const id = c.req.param("id");
|
|
|
|
const docId = scheduleDocId(space);
|
|
const doc = ensureDoc(space);
|
|
if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404);
|
|
|
|
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete job ${id}`, (d) => {
|
|
delete d.jobs[id];
|
|
});
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// POST /api/jobs/:id/run — manually trigger a job
|
|
routes.post("/api/jobs/:id/run", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const id = c.req.param("id");
|
|
|
|
const docId = scheduleDocId(space);
|
|
const doc = ensureDoc(space);
|
|
const job = doc.jobs[id];
|
|
if (!job) return c.json({ error: "Job not found" }, 404);
|
|
|
|
const startMs = Date.now();
|
|
let result: { success: boolean; message: string };
|
|
|
|
try {
|
|
result = await executeJob(job, space);
|
|
} catch (e: any) {
|
|
result = { success: false, message: e.message || String(e) };
|
|
}
|
|
|
|
const durationMs = Date.now() - startMs;
|
|
const logEntry: ExecutionLogEntry = {
|
|
id: crypto.randomUUID(),
|
|
jobId: job.id,
|
|
status: result.success ? "success" : "error",
|
|
message: result.message,
|
|
durationMs,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
_syncServer!.changeDoc<ScheduleDoc>(docId, `manual run ${id}`, (d) => {
|
|
const j = d.jobs[id];
|
|
if (j) {
|
|
j.lastRunAt = Date.now();
|
|
j.lastRunStatus = result.success ? "success" : "error";
|
|
j.lastRunMessage = result.message;
|
|
j.runCount = (j.runCount || 0) + 1;
|
|
}
|
|
d.log.push(logEntry);
|
|
while (d.log.length > MAX_LOG_ENTRIES) {
|
|
d.log.splice(0, 1);
|
|
}
|
|
});
|
|
|
|
return c.json({ ...result, durationMs });
|
|
});
|
|
|
|
// GET /api/log — execution log
|
|
routes.get("/api/log", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const doc = ensureDoc(space);
|
|
const log = [...doc.log].reverse(); // newest first
|
|
return c.json({ count: log.length, results: log });
|
|
});
|
|
|
|
// GET /api/log/:jobId — execution log filtered by job
|
|
routes.get("/api/log/:jobId", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const jobId = c.req.param("jobId");
|
|
const doc = ensureDoc(space);
|
|
const log = doc.log.filter((e) => e.jobId === jobId).reverse();
|
|
return c.json({ count: log.length, results: log });
|
|
});
|
|
|
|
// ── Module export ──
|
|
|
|
export const scheduleModule: RSpaceModule = {
|
|
id: "schedule",
|
|
name: "rSchedule",
|
|
icon: "⏱",
|
|
description: "Persistent cron-based job scheduling with email, webhooks, and backlog briefings",
|
|
scoping: { defaultScope: "global", userConfigurable: false },
|
|
docSchemas: [
|
|
{
|
|
pattern: "{space}:schedule:jobs",
|
|
description: "Scheduled jobs and execution log",
|
|
init: scheduleSchema.init,
|
|
},
|
|
],
|
|
routes,
|
|
landingPage: renderLanding,
|
|
seedTemplate: seedDefaultJobs,
|
|
async onInit(ctx) {
|
|
_syncServer = ctx.syncServer;
|
|
seedDefaultJobs("demo");
|
|
startTickLoop();
|
|
},
|
|
feeds: [
|
|
{
|
|
id: "executions",
|
|
name: "Executions",
|
|
kind: "data",
|
|
description: "Job execution events with status, timing, and output",
|
|
},
|
|
],
|
|
acceptsFeeds: ["data", "governance"],
|
|
outputPaths: [
|
|
{ path: "jobs", name: "Jobs", icon: "⏱", description: "Scheduled jobs and their configurations" },
|
|
{ path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" },
|
|
],
|
|
};
|