feat: add rSchedule module — persistent cron-based job scheduling

New module providing in-process, Automerge-backed job scheduling to
replace system-level crontabs. Includes email, webhook, calendar-event,
broadcast, and backlog-briefing action types with a 60-second tick loop.

- modules/rschedule/ — schemas, mod, landing page, web component UI
- Seed jobs: morning/weekly/monthly backlog briefings
- SMTP env vars added to docker-compose for email actions
- ONTOLOGY.md updated (26+ modules, rSchedule in Planning & Spatial)
- Also: Twenty CRM docker-compose aligned to rspace-internal network

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 14:34:05 -08:00
parent acc0faca01
commit 59b1ae2d05
12 changed files with 1720 additions and 25 deletions

View File

@ -11,7 +11,7 @@
│ rSpace Platform │
│ Spaces · Canvas · Modules · Flows · Nesting │
├─────────────────────────────────────────────────────────┤
│ rApps (25+ Modules) │
│ rApps (26+ Modules) │
│ Information · Economic · Democratic · Creative │
└─────────────────────────────────────────────────────────┘
```
@ -245,7 +245,7 @@ redirects to the unified server with subdomain-based space routing.
## 3. rApps — Module Layer
25+ modules organized by function:
26+ modules organized by function:
### Information
@ -265,6 +265,7 @@ redirects to the unified server with subdomain-based space routing.
| **rMaps** | rmaps.online | Geographic mapping & location hierarchy |
| **rTrips** | rtrips.online | Trip planning with itineraries |
| **rWork** | rwork.online | Task boards & project management |
| **rSchedule** | rschedule.online | Persistent cron-based job scheduling with email, webhooks & briefings |
### Communication

View File

@ -26,6 +26,7 @@
"@tiptap/starter-kit": "^3.20.0",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.5.0",
"cron-parser": "^5.5.0",
"hono": "^4.11.7",
"imapflow": "^1.0.170",
"lowlight": "^3.3.0",
@ -601,6 +602,8 @@
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cron-parser": ["cron-parser@5.5.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@ -725,6 +728,8 @@
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"mailparser": ["mailparser@3.9.3", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.2", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.13", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ=="],
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],

View File

@ -4,10 +4,10 @@
# Prerequisites:
# - rspace-online stack running (creates rspace-online_rspace-internal network)
# - Traefik running on traefik-public network
# - .env with INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET
# - .env with POSTGRES_PASSWORD + APP_SECRET
#
# Secrets fetched from Infisical (twenty-crm project):
# POSTGRES_PASSWORD, APP_SECRET, ADMIN_PASSWORD
# All services use rspace-internal network for inter-container communication.
# This avoids Docker br_netfilter issues with freshly-created bridge networks.
services:
twenty-ch-server:
@ -24,17 +24,13 @@ services:
- NODE_ENV=production
- SERVER_URL=https://crm.rspace.online
- FRONT_BASE_URL=https://crm.rspace.online
- PORT=3000
- NODE_PORT=3000
# ── Database ──
- PG_DATABASE_URL=postgres://twenty:${POSTGRES_PASSWORD}@twenty-ch-db:5432/twenty
- PG_DATABASE_URL=postgres://postgres:${POSTGRES_PASSWORD}@twenty-ch-db:5432/default
# ── Redis ──
- REDIS_URL=redis://twenty-ch-redis:6379
# ── Auth ──
- APP_SECRET=${APP_SECRET}
- ACCESS_TOKEN_SECRET=${APP_SECRET}
- LOGIN_TOKEN_SECRET=${APP_SECRET}
- REFRESH_TOKEN_SECRET=${APP_SECRET}
- FILE_TOKEN_SECRET=${APP_SECRET}
# ── Storage ──
- STORAGE_TYPE=local
- STORAGE_LOCAL_PATH=.local-storage
@ -54,7 +50,6 @@ services:
networks:
- traefik-public
- rspace-internal
- twenty-internal
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"]
interval: 30s
@ -74,13 +69,9 @@ services:
condition: service_healthy
environment:
- NODE_ENV=production
- PG_DATABASE_URL=postgres://twenty:${POSTGRES_PASSWORD}@twenty-ch-db:5432/twenty
- PG_DATABASE_URL=postgres://postgres:${POSTGRES_PASSWORD}@twenty-ch-db:5432/default
- REDIS_URL=redis://twenty-ch-redis:6379
- APP_SECRET=${APP_SECRET}
- ACCESS_TOKEN_SECRET=${APP_SECRET}
- LOGIN_TOKEN_SECRET=${APP_SECRET}
- REFRESH_TOKEN_SECRET=${APP_SECRET}
- FILE_TOKEN_SECRET=${APP_SECRET}
- STORAGE_TYPE=local
- STORAGE_LOCAL_PATH=.local-storage
- SERVER_URL=https://crm.rspace.online
@ -88,35 +79,35 @@ services:
volumes:
- twenty-ch-server-data:/app/.local-storage
networks:
- twenty-internal
- rspace-internal
twenty-ch-db:
image: postgres:16-alpine
container_name: twenty-ch-db
restart: unless-stopped
environment:
- POSTGRES_DB=twenty
- POSTGRES_USER=twenty
- POSTGRES_DB=default
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- twenty-ch-pgdata:/var/lib/postgresql/data
networks:
- twenty-internal
- rspace-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U twenty -d twenty"]
test: ["CMD-SHELL", "pg_isready -U postgres -d default"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
twenty-ch-redis:
image: redis:7-alpine
image: redis:7
container_name: twenty-ch-redis
restart: unless-stopped
volumes:
- twenty-ch-redis-data:/data
networks:
- twenty-internal
- rspace-internal
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
@ -134,4 +125,3 @@ networks:
rspace-internal:
name: rspace-online_rspace-internal
external: true
twenty-internal:

View File

@ -38,6 +38,10 @@ services:
- IMAP_HOST=mail.rmail.online
- IMAP_PORT=993
- IMAP_TLS_REJECT_UNAUTHORIZED=false
- SMTP_HOST=${SMTP_HOST:-mail.rmail.online}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
- SMTP_PASS=${SMTP_PASS}
- TWENTY_API_URL=http://twenty-ch-server:3000
- OLLAMA_URL=http://ollama:11434
- INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID}

View File

@ -0,0 +1,602 @@
/**
* <folk-schedule-app> schedule management UI.
*
* Job list with create/edit forms, execution log viewer,
* and manual run triggers. REST-based (no Automerge client sync).
*/
interface JobData {
id: string;
name: string;
description: string;
enabled: boolean;
cronExpression: string;
cronHuman?: string;
timezone: string;
actionType: string;
actionConfig: Record<string, unknown>;
lastRunAt: number | null;
lastRunStatus: "success" | "error" | null;
lastRunMessage: string;
nextRunAt: number | null;
runCount: number;
createdBy: string;
createdAt: number;
updatedAt: number;
}
interface LogEntry {
id: string;
jobId: string;
status: "success" | "error";
message: string;
durationMs: number;
timestamp: number;
}
const ACTION_TYPES = [
{ value: "email", label: "Email" },
{ value: "webhook", label: "Webhook" },
{ value: "calendar-event", label: "Calendar Event" },
{ value: "broadcast", label: "Broadcast" },
{ value: "backlog-briefing", label: "Backlog Briefing" },
];
const CRON_PRESETS = [
{ label: "Every minute", value: "* * * * *" },
{ label: "Every 5 minutes", value: "*/5 * * * *" },
{ label: "Hourly", value: "0 * * * *" },
{ label: "Daily at 9am", value: "0 9 * * *" },
{ label: "Weekday mornings", value: "0 9 * * 1-5" },
{ label: "Weekly (Monday 9am)", value: "0 9 * * 1" },
{ label: "Monthly (1st at 9am)", value: "0 9 1 * *" },
{ label: "Custom", value: "" },
];
class FolkScheduleApp extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private jobs: JobData[] = [];
private log: LogEntry[] = [];
private view: "jobs" | "log" | "form" = "jobs";
private editingJob: JobData | null = null;
private loading = false;
private runningJobId: string | null = null;
// Form state
private formName = "";
private formDescription = "";
private formCron = "0 9 * * 1-5";
private formTimezone = "America/Vancouver";
private formActionType = "email";
private formEnabled = true;
private formConfig: Record<string, string> = {};
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadJobs();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/schedule/);
return match ? match[0] : "";
}
private async loadJobs() {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/jobs`);
if (res.ok) {
const data = await res.json();
this.jobs = data.results || [];
}
} catch { this.jobs = []; }
this.loading = false;
this.render();
}
private async loadLog() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/log`);
if (res.ok) {
const data = await res.json();
this.log = data.results || [];
}
} catch { this.log = []; }
this.render();
}
private async toggleJob(id: string, enabled: boolean) {
const base = this.getApiBase();
await fetch(`${base}/api/jobs/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
await this.loadJobs();
}
private async deleteJob(id: string) {
if (!confirm("Delete this scheduled job?")) return;
const base = this.getApiBase();
await fetch(`${base}/api/jobs/${id}`, { method: "DELETE" });
await this.loadJobs();
}
private async runJob(id: string) {
this.runningJobId = id;
this.render();
const base = this.getApiBase();
try {
const res = await fetch(`${base}/api/jobs/${id}/run`, { method: "POST" });
const result = await res.json();
alert(result.success ? `Success: ${result.message}` : `Error: ${result.message}`);
} catch (e: any) {
alert(`Run failed: ${e.message}`);
}
this.runningJobId = null;
await this.loadJobs();
}
private async submitForm() {
const base = this.getApiBase();
const payload: Record<string, unknown> = {
name: this.formName,
description: this.formDescription,
cronExpression: this.formCron,
timezone: this.formTimezone,
actionType: this.formActionType,
actionConfig: { ...this.formConfig },
enabled: this.formEnabled,
};
const isEdit = !!this.editingJob;
const url = isEdit ? `${base}/api/jobs/${this.editingJob!.id}` : `${base}/api/jobs`;
const method = isEdit ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Request failed" }));
alert(err.error || "Failed to save job");
return;
}
this.view = "jobs";
this.editingJob = null;
await this.loadJobs();
}
private openCreateForm() {
this.editingJob = null;
this.formName = "";
this.formDescription = "";
this.formCron = "0 9 * * 1-5";
this.formTimezone = "America/Vancouver";
this.formActionType = "email";
this.formEnabled = true;
this.formConfig = {};
this.view = "form";
this.render();
}
private openEditForm(job: JobData) {
this.editingJob = job;
this.formName = job.name;
this.formDescription = job.description;
this.formCron = job.cronExpression;
this.formTimezone = job.timezone;
this.formActionType = job.actionType;
this.formEnabled = job.enabled;
this.formConfig = {};
if (job.actionConfig) {
for (const [k, v] of Object.entries(job.actionConfig)) {
this.formConfig[k] = String(v);
}
}
this.view = "form";
this.render();
}
private formatTime(ts: number | null): string {
if (!ts) return "—";
const d = new Date(ts);
const now = Date.now();
const diff = now - ts;
if (diff < 60_000) return "just now";
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
private formatFuture(ts: number | null): string {
if (!ts) return "—";
const diff = ts - Date.now();
if (diff < 0) return "overdue";
if (diff < 60_000) return "< 1m";
if (diff < 3600_000) return `in ${Math.floor(diff / 60_000)}m`;
if (diff < 86400_000) return `in ${Math.floor(diff / 3600_000)}h`;
return `in ${Math.floor(diff / 86400_000)}d`;
}
private renderActionConfigFields(): string {
switch (this.formActionType) {
case "email":
return `
<label class="s-label">To <input type="email" class="s-input" data-config="to" value="${this.esc(this.formConfig.to || "")}" placeholder="user@example.com"></label>
<label class="s-label">Subject <input type="text" class="s-input" data-config="subject" value="${this.esc(this.formConfig.subject || "")}" placeholder="[rSchedule] {{jobName}}"></label>
<label class="s-label">Body (HTML) <textarea class="s-input s-textarea" data-config="bodyTemplate" placeholder="<p>Hello from {{jobName}}</p>">${this.esc(this.formConfig.bodyTemplate || "")}</textarea></label>
`;
case "webhook":
return `
<label class="s-label">URL <input type="url" class="s-input" data-config="url" value="${this.esc(this.formConfig.url || "")}" placeholder="https://example.com/webhook"></label>
<label class="s-label">Method
<select class="s-input" data-config="method">
<option value="POST" ${this.formConfig.method === "POST" || !this.formConfig.method ? "selected" : ""}>POST</option>
<option value="GET" ${this.formConfig.method === "GET" ? "selected" : ""}>GET</option>
<option value="PUT" ${this.formConfig.method === "PUT" ? "selected" : ""}>PUT</option>
</select>
</label>
<label class="s-label">Body Template <textarea class="s-input s-textarea" data-config="bodyTemplate" placeholder='{"job":"{{jobName}}","ts":"{{timestamp}}"}'>${this.esc(this.formConfig.bodyTemplate || "")}</textarea></label>
`;
case "calendar-event":
return `
<label class="s-label">Event Title <input type="text" class="s-input" data-config="title" value="${this.esc(this.formConfig.title || "")}" placeholder="Recurring meeting"></label>
<label class="s-label">Duration (minutes) <input type="number" class="s-input" data-config="duration" value="${this.esc(this.formConfig.duration || "60")}" min="5" max="1440"></label>
`;
case "broadcast":
return `
<label class="s-label">Channel <input type="text" class="s-input" data-config="channel" value="${this.esc(this.formConfig.channel || "")}" placeholder="default"></label>
<label class="s-label">Message <textarea class="s-input s-textarea" data-config="message" placeholder="Scheduled broadcast">${this.esc(this.formConfig.message || "")}</textarea></label>
`;
case "backlog-briefing":
return `
<label class="s-label">Mode
<select class="s-input" data-config="mode">
<option value="morning" ${this.formConfig.mode === "morning" || !this.formConfig.mode ? "selected" : ""}>Morning</option>
<option value="weekly" ${this.formConfig.mode === "weekly" ? "selected" : ""}>Weekly</option>
<option value="monthly" ${this.formConfig.mode === "monthly" ? "selected" : ""}>Monthly</option>
</select>
</label>
<label class="s-label">To <input type="email" class="s-input" data-config="to" value="${this.esc(this.formConfig.to || "")}" placeholder="user@example.com"></label>
`;
default:
return `<p style="color:#94a3b8">No configuration needed for this action type.</p>`;
}
}
private esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
private render() {
const styles = `
<style>
:host { display:block; font-family:system-ui,-apple-system,sans-serif; color:#e2e8f0; }
.s-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:12px; }
.s-title { font-size:1.5rem; font-weight:700; margin:0; }
.s-tabs { display:flex; gap:4px; background:rgba(30,41,59,0.6); border-radius:8px; padding:3px; }
.s-tab { padding:6px 16px; border-radius:6px; border:none; background:transparent; color:#94a3b8; cursor:pointer; font-size:13px; transition:all 0.15s; }
.s-tab:hover { color:#e2e8f0; }
.s-tab.active { background:rgba(245,158,11,0.15); color:#f59e0b; }
.s-btn { padding:8px 16px; border-radius:8px; border:none; cursor:pointer; font-size:13px; font-weight:600; transition:all 0.15s; }
.s-btn-primary { background:linear-gradient(135deg,#f59e0b,#f97316); color:#0f172a; }
.s-btn-primary:hover { opacity:0.9; }
.s-btn-secondary { background:rgba(51,65,85,0.6); color:#e2e8f0; }
.s-btn-secondary:hover { background:rgba(71,85,105,0.6); }
.s-btn-danger { background:rgba(239,68,68,0.15); color:#ef4444; }
.s-btn-danger:hover { background:rgba(239,68,68,0.25); }
.s-btn-sm { padding:4px 10px; font-size:12px; }
.s-btn:disabled { opacity:0.5; cursor:not-allowed; }
.s-table { width:100%; border-collapse:collapse; }
.s-table th { text-align:left; padding:10px 12px; font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:#64748b; border-bottom:2px solid #1e293b; }
.s-table td { padding:10px 12px; border-bottom:1px solid rgba(30,41,59,0.6); font-size:14px; vertical-align:middle; }
.s-table tr:hover td { background:rgba(30,41,59,0.3); }
.s-status { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.s-status-success { background:#22c55e; }
.s-status-error { background:#ef4444; }
.s-status-null { background:#475569; }
.s-toggle { position:relative; width:36px; height:20px; cursor:pointer; }
.s-toggle input { opacity:0; width:0; height:0; position:absolute; }
.s-toggle .slider { position:absolute; inset:0; background:#334155; border-radius:20px; transition:0.2s; }
.s-toggle .slider::before { content:''; position:absolute; height:14px; width:14px; left:3px; bottom:3px; background:#94a3b8; border-radius:50%; transition:0.2s; }
.s-toggle input:checked + .slider { background:rgba(245,158,11,0.3); }
.s-toggle input:checked + .slider::before { transform:translateX(16px); background:#f59e0b; }
.s-card { background:rgba(15,23,42,0.6); border:1px solid rgba(30,41,59,0.8); border-radius:12px; padding:24px; margin-bottom:16px; }
.s-form-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.s-form-full { grid-column:1/-1; }
.s-label { display:flex; flex-direction:column; gap:6px; font-size:13px; color:#94a3b8; font-weight:500; }
.s-input { padding:8px 12px; background:rgba(30,41,59,0.8); border:1px solid #334155; border-radius:6px; color:#e2e8f0; font-size:14px; font-family:inherit; }
.s-input:focus { outline:none; border-color:#f59e0b; }
.s-textarea { min-height:80px; resize:vertical; }
.s-actions { display:flex; gap:6px; flex-wrap:wrap; }
.s-empty { text-align:center; padding:48px 20px; color:#64748b; }
.s-loading { text-align:center; padding:48px; color:#94a3b8; }
.s-log-item { display:flex; gap:12px; padding:10px 0; border-bottom:1px solid rgba(30,41,59,0.6); font-size:13px; align-items:flex-start; }
.s-log-time { color:#64748b; white-space:nowrap; min-width:120px; }
.s-log-msg { color:#cbd5e1; flex:1; word-break:break-word; }
.s-log-dur { color:#64748b; white-space:nowrap; }
@media (max-width:768px) {
.s-form-grid { grid-template-columns:1fr; }
.s-table { font-size:12px; }
.s-table th, .s-table td { padding:8px 6px; }
}
</style>
`;
if (this.loading) {
this.shadow.innerHTML = `${styles}<div class="s-loading">Loading schedule...</div>`;
return;
}
let content = "";
if (this.view === "jobs") {
content = this.renderJobList();
} else if (this.view === "log") {
content = this.renderLog();
} else if (this.view === "form") {
content = this.renderForm();
}
this.shadow.innerHTML = `
${styles}
<div class="s-header">
<h1 class="s-title">rSchedule</h1>
<div class="s-tabs">
<button class="s-tab ${this.view === "jobs" ? "active" : ""}" data-view="jobs">Jobs</button>
<button class="s-tab ${this.view === "log" ? "active" : ""}" data-view="log">Execution Log</button>
</div>
${this.view === "jobs" ? `<button class="s-btn s-btn-primary" data-action="create">+ New Job</button>` : ""}
</div>
${content}
`;
this.attachListeners();
}
private renderJobList(): string {
if (this.jobs.length === 0) {
return `<div class="s-empty"><p>No scheduled jobs yet.</p><p style="margin-top:8px"><button class="s-btn s-btn-primary" data-action="create">Create your first job</button></p></div>`;
}
const rows = this.jobs.map((j) => `
<tr>
<td>
<label class="s-toggle">
<input type="checkbox" ${j.enabled ? "checked" : ""} data-toggle="${j.id}">
<span class="slider"></span>
</label>
</td>
<td>
<strong style="color:#e2e8f0">${this.esc(j.name)}</strong>
${j.description ? `<br><span style="color:#64748b;font-size:12px">${this.esc(j.description)}</span>` : ""}
</td>
<td style="font-family:monospace;font-size:12px;color:#94a3b8" title="${this.esc(j.cronExpression)}">${this.esc(j.cronHuman || j.cronExpression)}</td>
<td style="color:#94a3b8;font-size:13px">${this.esc(j.timezone)}</td>
<td><span style="background:rgba(245,158,11,0.1);color:#f59e0b;padding:2px 8px;border-radius:4px;font-size:12px">${this.esc(j.actionType)}</span></td>
<td>
<span class="s-status s-status-${j.lastRunStatus || "null"}"></span>
<span style="color:#94a3b8;font-size:13px">${this.formatTime(j.lastRunAt)}</span>
</td>
<td style="color:#94a3b8;font-size:13px">${this.formatFuture(j.nextRunAt)}</td>
<td>
<div class="s-actions">
<button class="s-btn s-btn-secondary s-btn-sm" data-run="${j.id}" ${this.runningJobId === j.id ? "disabled" : ""}>${this.runningJobId === j.id ? "Running..." : "Run Now"}</button>
<button class="s-btn s-btn-secondary s-btn-sm" data-edit="${j.id}">Edit</button>
<button class="s-btn s-btn-danger s-btn-sm" data-delete="${j.id}">Delete</button>
</div>
</td>
</tr>
`).join("");
return `
<div style="overflow-x:auto">
<table class="s-table">
<thead>
<tr>
<th style="width:50px">On</th>
<th>Job</th>
<th>Schedule</th>
<th>Timezone</th>
<th>Action</th>
<th>Last Run</th>
<th>Next Run</th>
<th style="width:200px">Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
private renderLog(): string {
if (this.log.length === 0) {
return `<div class="s-empty"><p>No execution log entries yet.</p><p style="color:#64748b;margin-top:8px">Jobs will log their results here after they run.</p></div>`;
}
const jobNames = new Map(this.jobs.map((j) => [j.id, j.name]));
const entries = this.log.map((e) => `
<div class="s-log-item">
<span class="s-status s-status-${e.status}" style="margin-top:4px"></span>
<span class="s-log-time">${new Date(e.timestamp).toLocaleString()}</span>
<span style="color:#f59e0b;font-weight:500;min-width:140px">${this.esc(jobNames.get(e.jobId) || e.jobId)}</span>
<span class="s-log-msg">${this.esc(e.message)}</span>
<span class="s-log-dur">${e.durationMs}ms</span>
</div>
`).join("");
return `<div class="s-card">${entries}</div>`;
}
private renderForm(): string {
const isEdit = !!this.editingJob;
const presetOptions = CRON_PRESETS.map((p) =>
`<option value="${p.value}" ${this.formCron === p.value ? "selected" : ""}>${p.label}</option>`
).join("");
const actionOptions = ACTION_TYPES.map((a) =>
`<option value="${a.value}" ${this.formActionType === a.value ? "selected" : ""}>${a.label}</option>`
).join("");
return `
<div class="s-card">
<h2 style="margin:0 0 20px;font-size:1.1rem">${isEdit ? "Edit Job" : "Create New Job"}</h2>
<div class="s-form-grid">
<label class="s-label">Name <input type="text" class="s-input" id="f-name" value="${this.esc(this.formName)}" placeholder="My scheduled job"></label>
<label class="s-label">
Enabled
<label class="s-toggle" style="margin-top:4px">
<input type="checkbox" id="f-enabled" ${this.formEnabled ? "checked" : ""}>
<span class="slider"></span>
</label>
</label>
<label class="s-label s-form-full">Description <input type="text" class="s-input" id="f-desc" value="${this.esc(this.formDescription)}" placeholder="What this job does..."></label>
<label class="s-label">Cron Preset
<select class="s-input" id="f-preset">${presetOptions}</select>
</label>
<label class="s-label">Cron Expression <input type="text" class="s-input" id="f-cron" value="${this.esc(this.formCron)}" placeholder="* * * * *" style="font-family:monospace"></label>
<label class="s-label">Timezone <input type="text" class="s-input" id="f-tz" value="${this.esc(this.formTimezone)}" placeholder="America/Vancouver"></label>
<label class="s-label">Action Type
<select class="s-input" id="f-action">${actionOptions}</select>
</label>
<div class="s-form-full" style="border-top:1px solid #1e293b;padding-top:16px;margin-top:8px">
<h3 style="font-size:14px;color:#94a3b8;margin:0 0 12px">Action Configuration</h3>
<div class="s-form-grid" id="f-config-fields">
${this.renderActionConfigFields()}
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:24px">
<button class="s-btn s-btn-primary" data-action="submit">${isEdit ? "Update Job" : "Create Job"}</button>
<button class="s-btn s-btn-secondary" data-action="cancel">Cancel</button>
</div>
</div>
`;
}
private attachListeners() {
// Tab switching
this.shadow.querySelectorAll<HTMLButtonElement>("[data-view]").forEach((btn) => {
btn.addEventListener("click", () => {
this.view = btn.dataset.view as "jobs" | "log";
if (this.view === "log") this.loadLog();
else this.render();
});
});
// Create button
this.shadow.querySelectorAll<HTMLButtonElement>("[data-action='create']").forEach((btn) => {
btn.addEventListener("click", () => this.openCreateForm());
});
// Toggle
this.shadow.querySelectorAll<HTMLInputElement>("[data-toggle]").forEach((input) => {
input.addEventListener("change", () => {
this.toggleJob(input.dataset.toggle!, input.checked);
});
});
// Run
this.shadow.querySelectorAll<HTMLButtonElement>("[data-run]").forEach((btn) => {
btn.addEventListener("click", () => this.runJob(btn.dataset.run!));
});
// Edit
this.shadow.querySelectorAll<HTMLButtonElement>("[data-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
const job = this.jobs.find((j) => j.id === btn.dataset.edit);
if (job) this.openEditForm(job);
});
});
// Delete
this.shadow.querySelectorAll<HTMLButtonElement>("[data-delete]").forEach((btn) => {
btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!));
});
// Form: cancel
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel']")?.addEventListener("click", () => {
this.view = "jobs";
this.render();
});
// Form: submit
this.shadow.querySelector<HTMLButtonElement>("[data-action='submit']")?.addEventListener("click", () => {
this.collectFormData();
this.submitForm();
});
// Form: preset selector
this.shadow.querySelector<HTMLSelectElement>("#f-preset")?.addEventListener("change", (e) => {
const val = (e.target as HTMLSelectElement).value;
if (val) {
this.formCron = val;
const cronInput = this.shadow.querySelector<HTMLInputElement>("#f-cron");
if (cronInput) cronInput.value = val;
}
});
// Form: action type change -> re-render config fields
this.shadow.querySelector<HTMLSelectElement>("#f-action")?.addEventListener("change", (e) => {
this.collectFormData();
this.formActionType = (e.target as HTMLSelectElement).value;
this.formConfig = {}; // reset config for new action type
const container = this.shadow.querySelector<HTMLDivElement>("#f-config-fields");
if (container) container.innerHTML = this.renderActionConfigFields();
this.attachConfigListeners();
});
this.attachConfigListeners();
}
private attachConfigListeners() {
this.shadow.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>("[data-config]").forEach((el) => {
el.addEventListener("input", () => {
this.formConfig[el.dataset.config!] = el.value;
});
el.addEventListener("change", () => {
this.formConfig[el.dataset.config!] = el.value;
});
});
}
private collectFormData() {
const getName = this.shadow.querySelector<HTMLInputElement>("#f-name");
const getDesc = this.shadow.querySelector<HTMLInputElement>("#f-desc");
const getCron = this.shadow.querySelector<HTMLInputElement>("#f-cron");
const getTz = this.shadow.querySelector<HTMLInputElement>("#f-tz");
const getAction = this.shadow.querySelector<HTMLSelectElement>("#f-action");
const getEnabled = this.shadow.querySelector<HTMLInputElement>("#f-enabled");
if (getName) this.formName = getName.value;
if (getDesc) this.formDescription = getDesc.value;
if (getCron) this.formCron = getCron.value;
if (getTz) this.formTimezone = getTz.value;
if (getAction) this.formActionType = getAction.value;
if (getEnabled) this.formEnabled = getEnabled.checked;
// Collect config fields
this.shadow.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>("[data-config]").forEach((el) => {
this.formConfig[el.dataset.config!] = el.value;
});
}
}
customElements.define("folk-schedule-app", FolkScheduleApp);

View File

@ -0,0 +1,6 @@
/* rSchedule module — dark theme */
folk-schedule-app {
display: block;
min-height: 400px;
padding: 20px;
}

View File

@ -0,0 +1,115 @@
/**
* rSchedule landing page persistent job scheduling for rSpace.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline" style="color:#f59e0b;background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.2)">
Persistent Scheduling
</span>
<h1 class="rl-heading" style="background:linear-gradient(to right,#f59e0b,#f97316,#ef4444);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Automate your rSpace, on your schedule.
</h1>
<p class="rl-subtitle">
Cron-powered job scheduling with email, webhooks, calendar events, and backlog briefings &mdash; all managed from within rSpace.
</p>
<p class="rl-subtext">
rSchedule replaces system-level crontabs with an <span style="color:#f59e0b;font-weight:600">in-process, persistent scheduler</span>.
Jobs survive restarts, fire on a 60-second tick loop, and are fully configurable through the UI.
</p>
<div class="rl-cta-row">
<a href="#" class="rl-cta-primary" id="ml-primary"
style="background:linear-gradient(to right,#f59e0b,#f97316);color:#0b1120"
onclick="document.querySelector('.rl-hero').closest('[data-space]')?.getAttribute('data-space') ? window.location.href='/' + document.querySelector('.rl-hero').closest('[data-space]').getAttribute('data-space') + '/schedule' : void 0; return false;">
Open Scheduler
</a>
<a href="#features" class="rl-cta-secondary">Learn More</a>
</div>
</div>
<!-- Features (4-card grid) -->
<section class="rl-section" id="features" style="border-top:none">
<div class="rl-container">
<div class="rl-grid-4">
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(245,158,11,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#9200;</span>
</div>
<h3>Cron Expressions</h3>
<p>Standard cron syntax with timezone support. Schedule anything from every minute to once a year.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(249,115,22,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128231;</span>
</div>
<h3>Email Actions</h3>
<p>Send scheduled emails via SMTP &mdash; morning briefings, weekly digests, monthly audits.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(239,68,68,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128279;</span>
</div>
<h3>Webhook Actions</h3>
<p>Fire HTTP requests on schedule &mdash; trigger builds, sync data, or ping external services.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(52,211,153,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128203;</span>
</div>
<h3>Backlog Briefings</h3>
<p>Automated task digests from your Backlog &mdash; morning, weekly, and monthly summaries delivered by email.</p>
</div>
</div>
</div>
</section>
<!-- How it works -->
<section class="rl-section">
<div class="rl-container">
<h2 style="text-align:center;font-size:1.5rem;margin-bottom:2rem;color:#e2e8f0">How it works</h2>
<div class="rl-grid-2">
<div class="rl-card" style="padding:2rem">
<h3 style="color:#f59e0b">Persistent Jobs</h3>
<p>Jobs are stored in Automerge documents &mdash; they survive container restarts and server reboots. No more lost crontabs.</p>
</div>
<div class="rl-card" style="padding:2rem">
<h3 style="color:#f97316">60-Second Tick Loop</h3>
<p>A lightweight in-process loop checks every 60 seconds for due jobs. No external scheduler process needed.</p>
</div>
</div>
</div>
</section>
<!-- Ecosystem integration -->
<section class="rl-section">
<div class="rl-container">
<h2 style="text-align:center;font-size:1.5rem;margin-bottom:2rem;color:#e2e8f0">Ecosystem Integration</h2>
<div class="rl-grid-3">
<div class="rl-card rl-card--center" style="padding:1.5rem">
<h3>rCal</h3>
<p>Create recurring calendar events automatically via the calendar-event action type.</p>
</div>
<div class="rl-card rl-card--center" style="padding:1.5rem">
<h3>rInbox</h3>
<p>Schedule email delivery through shared SMTP infrastructure.</p>
</div>
<div class="rl-card rl-card--center" style="padding:1.5rem">
<h3>Backlog</h3>
<p>Scan backlog tasks and generate automated priority briefings on any cadence.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="rl-section" style="text-align:center;padding:4rem 0">
<h2 class="rl-heading" style="font-size:1.75rem;background:linear-gradient(to right,#f59e0b,#f97316);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Stop managing crontabs. Start scheduling from rSpace.
</h2>
<p style="color:rgba(148,163,184,0.8);margin-top:1rem">
<a href="/" style="color:#f59e0b;text-decoration:none">&larr; Back to rSpace</a>
</p>
</section>
`;
}

854
modules/rschedule/mod.ts Normal file
View File

@ -0,0 +1,854 @@
/**
* 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",
},
],
outputPaths: [
{ path: "jobs", name: "Jobs", icon: "⏱", description: "Scheduled jobs and their configurations" },
{ path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" },
],
};

View File

@ -0,0 +1,88 @@
/**
* rSchedule Automerge document schemas.
*
* Granularity: one Automerge document per space (all jobs + execution log).
* DocId format: {space}:schedule:jobs
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
export type ActionType = 'email' | 'webhook' | 'calendar-event' | 'broadcast' | 'backlog-briefing';
export interface ScheduleJob {
id: string;
name: string;
description: string;
enabled: boolean;
// Timing
cronExpression: string;
timezone: string;
// Action
actionType: ActionType;
actionConfig: Record<string, unknown>;
// Execution state
lastRunAt: number | null;
lastRunStatus: 'success' | 'error' | null;
lastRunMessage: string;
nextRunAt: number | null;
runCount: number;
// Metadata
createdBy: string;
createdAt: number;
updatedAt: number;
}
export interface ExecutionLogEntry {
id: string;
jobId: string;
status: 'success' | 'error';
message: string;
durationMs: number;
timestamp: number;
}
export interface ScheduleDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
jobs: Record<string, ScheduleJob>;
log: ExecutionLogEntry[];
}
// ── Schema registration ──
export const scheduleSchema: DocSchema<ScheduleDoc> = {
module: 'schedule',
collection: 'jobs',
version: 1,
init: (): ScheduleDoc => ({
meta: {
module: 'schedule',
collection: 'jobs',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
jobs: {},
log: [],
}),
};
// ── Helpers ──
export function scheduleDocId(space: string) {
return `${space}:schedule:jobs` as const;
}
/** Maximum execution log entries to keep per doc */
export const MAX_LOG_ENTRIES = 200;

View File

@ -34,6 +34,7 @@
"@tiptap/starter-kit": "^3.20.0",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.5.0",
"cron-parser": "^5.5.0",
"hono": "^4.11.7",
"imapflow": "^1.0.170",
"lowlight": "^3.3.0",

View File

@ -68,6 +68,7 @@ import { photosModule } from "../modules/rphotos/mod";
import { socialsModule } from "../modules/rsocials/mod";
import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { scheduleModule } from "../modules/rschedule/mod";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
import { renderShell, renderModuleLanding, renderOnboarding } from "./shell";
@ -104,6 +105,7 @@ registerModule(photosModule);
registerModule(socialsModule);
registerModule(docsModule);
registerModule(designModule);
registerModule(scheduleModule);
// ── Config ──
const PORT = Number(process.env.PORT) || 3000;

View File

@ -707,6 +707,33 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rphotos/photos.css"),
);
// Build schedule module component
await build({
configFile: false,
root: resolve(__dirname, "modules/rschedule/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rschedule"),
lib: {
entry: resolve(__dirname, "modules/rschedule/components/folk-schedule-app.ts"),
formats: ["es"],
fileName: () => "folk-schedule-app.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-schedule-app.js",
},
},
},
});
// Copy schedule CSS
mkdirSync(resolve(__dirname, "dist/modules/rschedule"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/rschedule/components/schedule.css"),
resolve(__dirname, "dist/modules/rschedule/schedule.css"),
);
// ── Demo infrastructure ──
// Build demo-sync-vanilla library