rspace-online/modules/rschedule/mod.ts

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} &bull; ${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 &bull; <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" },
],
};