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:
parent
acc0faca01
commit
59b1ae2d05
|
|
@ -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
|
||||
|
||||
|
|
|
|||
5
bun.lock
5
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* rSchedule module — dark theme */
|
||||
folk-schedule-app {
|
||||
display: block;
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
@ -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 — 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">⏰</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">📧</span>
|
||||
</div>
|
||||
<h3>Email Actions</h3>
|
||||
<p>Send scheduled emails via SMTP — 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">🔗</span>
|
||||
</div>
|
||||
<h3>Webhook Actions</h3>
|
||||
<p>Fire HTTP requests on schedule — 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">📋</span>
|
||||
</div>
|
||||
<h3>Backlog Briefings</h3>
|
||||
<p>Automated task digests from your Backlog — 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 — 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">← Back to rSpace</a>
|
||||
</p>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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} • ${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",
|
||||
},
|
||||
],
|
||||
outputPaths: [
|
||||
{ path: "jobs", name: "Jobs", icon: "⏱", description: "Scheduled jobs and their configurations" },
|
||||
{ path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" },
|
||||
],
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue