feat(rschedule): n8n-style automation canvas at /:space/rschedule/reminders

Visual workflow builder with drag-and-drop node palette (15 node types across
triggers, conditions, and actions), SVG canvas with Bezier wiring, config panel,
REST-persisted CRUD, topological execution engine, cron tick loop integration,
webhook trigger endpoint, and two demo workflows (proximity notification +
document sign-off pipeline).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 11:42:05 -07:00
parent 61b25e299f
commit 8bc7787d37
5 changed files with 2490 additions and 0 deletions

View File

@ -0,0 +1,551 @@
/* rSchedule Automation Canvas — n8n-style workflow builder */
folk-automation-canvas {
display: block;
height: calc(100vh - 60px);
}
.ac-root {
display: flex;
flex-direction: column;
height: 100%;
font-family: system-ui, -apple-system, sans-serif;
color: var(--rs-text-primary, #e2e8f0);
}
/* ── Toolbar ── */
.ac-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
min-height: 46px;
border-bottom: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-bg-surface, #1a1a2e);
z-index: 10;
}
.ac-toolbar__title {
font-size: 15px;
font-weight: 600;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.ac-toolbar__title input {
background: transparent;
border: 1px solid transparent;
color: var(--rs-text-primary, #e2e8f0);
font-size: 15px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
width: 200px;
}
.ac-toolbar__title input:hover,
.ac-toolbar__title input:focus {
border-color: var(--rs-border-strong, #3d3d5c);
outline: none;
}
.ac-toolbar__actions {
display: flex;
gap: 6px;
align-items: center;
}
.ac-btn {
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--rs-input-border, #3d3d5c);
background: var(--rs-input-bg, #16162a);
color: var(--rs-text-primary, #e2e8f0);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.ac-btn:hover {
border-color: var(--rs-border-strong, #4d4d6c);
}
.ac-btn--run {
background: #3b82f622;
border-color: #3b82f655;
color: #60a5fa;
}
.ac-btn--run:hover {
background: #3b82f633;
border-color: #3b82f6;
}
.ac-btn--save {
background: #10b98122;
border-color: #10b98155;
color: #34d399;
}
.ac-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--rs-text-muted, #94a3b8);
}
.ac-toggle input[type="checkbox"] {
accent-color: #10b981;
}
.ac-save-indicator {
font-size: 11px;
color: var(--rs-text-muted, #64748b);
}
/* ── Canvas area ── */
.ac-canvas-area {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
/* ── Left sidebar — node palette ── */
.ac-palette {
width: 200px;
min-width: 200px;
border-right: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-bg-surface, #1a1a2e);
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.ac-palette__group-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rs-text-muted, #94a3b8);
margin-bottom: 4px;
}
.ac-palette__card {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-input-bg, #16162a);
cursor: grab;
font-size: 12px;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 4px;
}
.ac-palette__card:hover {
border-color: #6366f1;
background: #6366f111;
}
.ac-palette__card:active {
cursor: grabbing;
}
.ac-palette__card-icon {
font-size: 16px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.ac-palette__card-label {
font-weight: 500;
color: var(--rs-text-primary, #e2e8f0);
}
/* ── SVG canvas ── */
.ac-canvas {
flex: 1;
position: relative;
overflow: hidden;
cursor: grab;
background: var(--rs-canvas-bg, #0f0f23);
}
.ac-canvas.grabbing {
cursor: grabbing;
}
.ac-canvas.wiring {
cursor: crosshair;
}
.ac-canvas svg {
display: block;
width: 100%;
height: 100%;
}
/* ── Right sidebar — config ── */
.ac-config {
width: 0;
overflow: hidden;
border-left: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-bg-surface, #1a1a2e);
display: flex;
flex-direction: column;
transition: width 0.2s ease;
}
.ac-config.open {
width: 280px;
min-width: 280px;
}
.ac-config__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--rs-border, #2d2d44);
font-weight: 600;
font-size: 13px;
}
.ac-config__header-close {
background: none;
border: none;
color: var(--rs-text-muted, #94a3b8);
font-size: 16px;
cursor: pointer;
margin-left: auto;
padding: 2px;
}
.ac-config__body {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
flex: 1;
}
.ac-config__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ac-config__field label {
font-size: 11px;
font-weight: 500;
color: var(--rs-text-muted, #94a3b8);
}
.ac-config__field input,
.ac-config__field select,
.ac-config__field textarea {
width: 100%;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--rs-input-border, #3d3d5c);
background: var(--rs-input-bg, #16162a);
color: var(--rs-text-primary, #e2e8f0);
font-size: 12px;
font-family: inherit;
box-sizing: border-box;
}
.ac-config__field textarea {
resize: vertical;
min-height: 60px;
}
.ac-config__field input:focus,
.ac-config__field select:focus,
.ac-config__field textarea:focus {
border-color: #3b82f6;
outline: none;
}
.ac-config__delete {
margin-top: 12px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #ef444455;
background: #ef444422;
color: #f87171;
font-size: 12px;
cursor: pointer;
}
.ac-config__delete:hover {
background: #ef444433;
}
/* ── Execution log in config panel ── */
.ac-exec-log {
margin-top: 12px;
border-top: 1px solid var(--rs-border, #2d2d44);
padding-top: 12px;
}
.ac-exec-log__title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rs-text-muted, #94a3b8);
margin-bottom: 8px;
}
.ac-exec-log__entry {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 11px;
color: var(--rs-text-muted, #94a3b8);
}
.ac-exec-log__dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.ac-exec-log__dot.success { background: #22c55e; }
.ac-exec-log__dot.error { background: #ef4444; }
.ac-exec-log__dot.running { background: #3b82f6; animation: ac-pulse 1s infinite; }
@keyframes ac-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Zoom controls ── */
.ac-zoom-controls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
align-items: center;
gap: 4px;
background: var(--rs-bg-surface, #1a1a2e);
border: 1px solid var(--rs-border-strong, #3d3d5c);
border-radius: 8px;
padding: 4px 6px;
z-index: 5;
}
.ac-zoom-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--rs-text-primary, #e2e8f0);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.ac-zoom-btn:hover {
background: var(--rs-bg-surface-raised, #252545);
}
.ac-zoom-level {
font-size: 11px;
color: var(--rs-text-muted, #94a3b8);
min-width: 36px;
text-align: center;
}
/* ── Node styles in SVG ── */
.ac-node { cursor: pointer; }
.ac-node.selected > foreignObject > div {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
.ac-node foreignObject > div {
border-radius: 10px;
border: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-bg-surface, #1a1a2e);
overflow: hidden;
font-size: 12px;
transition: border-color 0.15s;
}
.ac-node:hover foreignObject > div {
border-color: #4f46e5 !important;
}
.ac-node-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--rs-border, #2d2d44);
cursor: move;
}
.ac-node-icon {
font-size: 14px;
}
.ac-node-label {
font-weight: 600;
font-size: 12px;
color: var(--rs-text-primary, #e2e8f0);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ac-node-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.ac-node-status.idle { background: #4b5563; }
.ac-node-status.running { background: #3b82f6; animation: ac-pulse 1s infinite; }
.ac-node-status.success { background: #22c55e; }
.ac-node-status.error { background: #ef4444; }
.ac-node-ports {
padding: 6px 10px;
display: flex;
justify-content: space-between;
min-height: 28px;
}
.ac-node-inputs,
.ac-node-outputs {
display: flex;
flex-direction: column;
gap: 4px;
}
.ac-node-outputs {
align-items: flex-end;
}
.ac-port-label {
font-size: 10px;
color: var(--rs-text-muted, #94a3b8);
}
/* ── Port handles in SVG ── */
.ac-port-group { cursor: crosshair; }
.ac-port-dot {
transition: r 0.15s, filter 0.15s;
}
.ac-port-group:hover .ac-port-dot {
r: 7;
filter: drop-shadow(0 0 4px currentColor);
}
.ac-port-group--wiring-source .ac-port-dot {
r: 7;
filter: drop-shadow(0 0 6px currentColor);
}
.ac-port-group--wiring-target .ac-port-dot {
r: 7;
animation: port-pulse 0.8s ease-in-out infinite;
}
.ac-port-group--wiring-dimmed .ac-port-dot {
opacity: 0.2;
}
@keyframes port-pulse {
0%, 100% { filter: drop-shadow(0 0 3px currentColor); }
50% { filter: drop-shadow(0 0 8px currentColor); }
}
/* ── Edge styles ── */
.ac-edge-group { pointer-events: stroke; }
.ac-edge-path {
fill: none;
stroke-width: 2;
}
.ac-edge-hit {
fill: none;
stroke: transparent;
stroke-width: 16;
cursor: pointer;
}
.ac-edge-path.running {
animation: ac-edge-flow 1s linear infinite;
stroke-dasharray: 8 4;
}
@keyframes ac-edge-flow {
to { stroke-dashoffset: -24; }
}
/* ── Wiring temp line ── */
.ac-wiring-temp {
fill: none;
stroke: #6366f1;
stroke-width: 2;
stroke-dasharray: 6 4;
opacity: 0.7;
pointer-events: none;
}
/* ── Workflow selector ── */
.ac-workflow-select {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--rs-input-border, #3d3d5c);
background: var(--rs-input-bg, #16162a);
color: var(--rs-text-primary, #e2e8f0);
font-size: 12px;
}
/* ── Mobile ── */
@media (max-width: 768px) {
.ac-palette {
width: 160px;
min-width: 160px;
padding: 8px;
}
.ac-config.open {
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
z-index: 20;
min-width: unset;
}
.ac-toolbar {
flex-wrap: wrap;
padding: 8px 12px;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,11 @@ import type {
ExecutionLogEntry,
ActionType,
Reminder,
Workflow,
WorkflowNode,
WorkflowEdge,
} from "./schemas";
import { NODE_CATALOG } from "./schemas";
import { calendarDocId } from "../rcal/schemas";
import type { CalendarDoc, ScheduledItemMetadata } from "../rcal/schemas";
@ -73,6 +77,7 @@ function ensureDoc(space: string): ScheduleDoc {
d.meta.spaceSlug = space;
d.jobs = {};
d.reminders = {};
d.workflows = {};
d.log = [];
},
);
@ -667,6 +672,38 @@ function startTickLoop() {
console.error(`[Schedule] Reminder email error for "${reminder.title}":`, e);
}
}
// ── Process due automation workflows ──
const workflows = Object.values(doc.workflows || {});
for (const wf of workflows) {
if (!wf.enabled) continue;
const cronNodes = wf.nodes.filter(n => n.type === "trigger-cron");
for (const cronNode of cronNodes) {
const expr = String(cronNode.config.cronExpression || "");
const tz = String(cronNode.config.timezone || "UTC");
if (!expr) continue;
try {
const interval = CronExpressionParser.parse(expr, {
currentDate: new Date(now - TICK_INTERVAL),
tz,
});
const nextDate = interval.next().toDate();
if (nextDate.getTime() <= now) {
console.log(`[Schedule] Running cron workflow "${wf.name}" for space ${space}`);
const results = await executeWorkflow(wf, space);
const allOk = results.every(r => r.status !== "error");
_syncServer.changeDoc<ScheduleDoc>(docId, `tick workflow ${wf.id}`, (d) => {
const w = d.workflows[wf.id];
if (!w) return;
w.lastRunAt = Date.now();
w.lastRunStatus = allOk ? "success" : "error";
w.runCount = (w.runCount || 0) + 1;
w.updatedAt = Date.now();
});
}
} catch { /* invalid cron — skip */ }
}
}
} catch (e) {
console.error(`[Schedule] Tick error for space ${space}:`, e);
}
@ -1318,6 +1355,569 @@ routes.post("/api/reminders/:id/snooze", async (c) => {
return c.json(updated.reminders[id]);
});
// ── Automation canvas page route ──
routes.get("/reminders", (c) => {
const space = c.req.param("space") || "demo";
return c.html(
renderShell({
title: `${space} — Automations | rSpace`,
moduleId: "rschedule",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-automation-canvas space="${space}"></folk-automation-canvas>`,
scripts: `<script type="module" src="/modules/rschedule/folk-automation-canvas.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rschedule/automation-canvas.css">`,
}),
);
});
// ── Workflow CRUD API ──
routes.get("/api/workflows", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const doc = ensureDoc(dataSpace);
// Ensure workflows field exists on older docs
const workflows = Object.values(doc.workflows || {});
workflows.sort((a, b) => a.name.localeCompare(b.name));
return c.json({ count: workflows.length, results: workflows });
});
routes.post("/api/workflows", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const body = await c.req.json();
const docId = scheduleDocId(dataSpace);
ensureDoc(dataSpace);
const wfId = crypto.randomUUID();
const now = Date.now();
const workflow: Workflow = {
id: wfId,
name: body.name || "New Workflow",
enabled: body.enabled !== false,
nodes: body.nodes || [],
edges: body.edges || [],
lastRunAt: null,
lastRunStatus: null,
runCount: 0,
createdAt: now,
updatedAt: now,
};
_syncServer!.changeDoc<ScheduleDoc>(docId, `create workflow ${wfId}`, (d) => {
if (!d.workflows) d.workflows = {} as any;
(d.workflows as any)[wfId] = workflow;
});
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
return c.json(updated.workflows[wfId], 201);
});
routes.get("/api/workflows/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace);
const wf = doc.workflows?.[id];
if (!wf) return c.json({ error: "Workflow not found" }, 404);
return c.json(wf);
});
routes.put("/api/workflows/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const body = await c.req.json();
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404);
_syncServer!.changeDoc<ScheduleDoc>(docId, `update workflow ${id}`, (d) => {
const wf = d.workflows[id];
if (!wf) return;
if (body.name !== undefined) wf.name = body.name;
if (body.enabled !== undefined) wf.enabled = body.enabled;
if (body.nodes !== undefined) {
// Replace the nodes array
while (wf.nodes.length > 0) wf.nodes.splice(0, 1);
for (const n of body.nodes) wf.nodes.push(n);
}
if (body.edges !== undefined) {
while (wf.edges.length > 0) wf.edges.splice(0, 1);
for (const e of body.edges) wf.edges.push(e);
}
wf.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
return c.json(updated.workflows[id]);
});
routes.delete("/api/workflows/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404);
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete workflow ${id}`, (d) => {
delete d.workflows[id];
});
return c.json({ ok: true });
});
// ── Workflow execution engine ──
interface NodeResult {
nodeId: string;
status: "success" | "error" | "skipped";
message: string;
durationMs: number;
outputData?: unknown;
}
function topologicalSort(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] {
const adj = new Map<string, string[]>();
const inDegree = new Map<string, number>();
for (const n of nodes) {
adj.set(n.id, []);
inDegree.set(n.id, 0);
}
for (const e of edges) {
adj.get(e.fromNode)?.push(e.toNode);
inDegree.set(e.toNode, (inDegree.get(e.toNode) || 0) + 1);
}
const queue: string[] = [];
for (const [id, deg] of inDegree) {
if (deg === 0) queue.push(id);
}
const sorted: string[] = [];
while (queue.length > 0) {
const id = queue.shift()!;
sorted.push(id);
for (const neighbor of adj.get(id) || []) {
const newDeg = (inDegree.get(neighbor) || 1) - 1;
inDegree.set(neighbor, newDeg);
if (newDeg === 0) queue.push(neighbor);
}
}
const nodeMap = new Map(nodes.map(n => [n.id, n]));
return sorted.map(id => nodeMap.get(id)!).filter(Boolean);
}
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
async function executeWorkflowNode(
node: WorkflowNode,
inputData: unknown,
space: string,
): Promise<{ success: boolean; message: string; outputData?: unknown }> {
const cfg = node.config;
switch (node.type) {
// ── Triggers ──
case "trigger-cron":
return { success: true, message: "Cron triggered", outputData: { timestamp: Date.now() } };
case "trigger-data-change":
return { success: true, message: `Watching ${cfg.module || "any"} module`, outputData: inputData || {} };
case "trigger-webhook":
return { success: true, message: "Webhook received", outputData: inputData || {} };
case "trigger-manual":
return { success: true, message: "Manual trigger fired", outputData: { timestamp: Date.now() } };
case "trigger-proximity": {
const data = inputData as { lat?: number; lng?: number } | undefined;
if (!data?.lat || !data?.lng) return { success: true, message: "No location data", outputData: { distance: null } };
const dist = haversineKm(data.lat, data.lng, Number(cfg.lat) || 0, Number(cfg.lng) || 0);
const inRange = dist <= (Number(cfg.radiusKm) || 1);
return { success: true, message: `Distance: ${dist.toFixed(2)}km (${inRange ? "in range" : "out of range"})`, outputData: { distance: dist, inRange } };
}
// ── Conditions ──
case "condition-compare": {
const val = String(inputData ?? "");
const cmp = String(cfg.compareValue ?? "");
let result = false;
switch (cfg.operator) {
case "equals": result = val === cmp; break;
case "not-equals": result = val !== cmp; break;
case "greater-than": result = Number(val) > Number(cmp); break;
case "less-than": result = Number(val) < Number(cmp); break;
case "contains": result = val.includes(cmp); break;
}
return { success: true, message: `Compare: ${result}`, outputData: result };
}
case "condition-geofence": {
const coords = inputData as { lat?: number; lng?: number } | undefined;
if (!coords?.lat || !coords?.lng) return { success: true, message: "No coords", outputData: false };
const dist = haversineKm(coords.lat, coords.lng, Number(cfg.centerLat) || 0, Number(cfg.centerLng) || 0);
const inside = dist <= (Number(cfg.radiusKm) || 5);
return { success: true, message: `Geofence: ${inside ? "inside" : "outside"} (${dist.toFixed(2)}km)`, outputData: inside };
}
case "condition-time-window": {
const now = new Date();
const hour = now.getHours();
const day = now.getDay();
const startH = Number(cfg.startHour) || 0;
const endH = Number(cfg.endHour) || 23;
const days = String(cfg.days || "0,1,2,3,4,5,6").split(",").map(Number);
const inWindow = hour >= startH && hour < endH && days.includes(day);
return { success: true, message: `Time window: ${inWindow ? "in" : "outside"}`, outputData: inWindow };
}
case "condition-data-filter": {
const data = inputData as Record<string, unknown> | undefined;
const field = String(cfg.field || "");
const val = data?.[field];
let match = false;
switch (cfg.operator) {
case "equals": match = String(val) === String(cfg.value); break;
case "not-equals": match = String(val) !== String(cfg.value); break;
case "contains": match = String(val ?? "").includes(String(cfg.value ?? "")); break;
case "exists": match = val !== undefined && val !== null; break;
}
return { success: true, message: `Filter: ${match ? "match" : "no match"}`, outputData: match ? data : null };
}
// ── Actions ──
case "action-send-email": {
const transport = getSmtpTransport();
if (!transport) return { success: false, message: "SMTP not configured" };
if (!cfg.to) return { success: false, message: "No recipient" };
const vars: Record<string, string> = {
date: new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }),
timestamp: new Date().toISOString(),
...(typeof inputData === "object" && inputData !== null
? Object.fromEntries(Object.entries(inputData as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
: {}),
};
const subject = renderTemplate(String(cfg.subject || "Automation Notification"), vars);
const html = renderTemplate(String(cfg.bodyTemplate || `<p>Automation executed at ${vars.date}.</p>`), vars);
await transport.sendMail({
from: process.env.SMTP_FROM || "rSchedule <noreply@rmail.online>",
to: String(cfg.to),
subject,
html,
});
return { success: true, message: `Email sent to ${cfg.to}` };
}
case "action-post-webhook": {
if (!cfg.url) return { success: false, message: "No URL configured" };
const method = String(cfg.method || "POST").toUpperCase();
const vars: Record<string, string> = {
timestamp: new Date().toISOString(),
...(typeof inputData === "object" && inputData !== null
? Object.fromEntries(Object.entries(inputData as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
: {}),
};
const body = renderTemplate(String(cfg.bodyTemplate || JSON.stringify({ timestamp: vars.timestamp })), vars);
const res = await fetch(String(cfg.url), {
method,
headers: { "Content-Type": "application/json" },
body: method !== "GET" ? body : undefined,
});
if (!res.ok) return { success: false, message: `Webhook ${res.status}` };
return { success: true, message: `Webhook ${method} ${cfg.url} -> ${res.status}`, outputData: await res.json().catch(() => null) };
}
case "action-create-event": {
if (!_syncServer) return { success: false, message: "SyncServer unavailable" };
const calDocId = calendarDocId(space);
const calDoc = _syncServer.getDoc<CalendarDoc>(calDocId);
if (!calDoc) return { success: false, message: "Calendar doc not found" };
const eventId = crypto.randomUUID();
const now = Date.now();
const durationMs = (Number(cfg.durationMinutes) || 60) * 60 * 1000;
_syncServer.changeDoc<CalendarDoc>(calDocId, `automation: create event`, (d) => {
d.events[eventId] = {
id: eventId,
title: String(cfg.title || "Automation Event"),
description: "Created by rSchedule automation",
startTime: now,
endTime: now + durationMs,
allDay: false,
timezone: "UTC",
rrule: null,
status: null,
visibility: null,
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: node.id,
attendees: [],
attendeeCount: 0,
metadata: null,
createdAt: now,
updatedAt: now,
};
});
return { success: true, message: `Event created: ${cfg.title || "Automation Event"}`, outputData: { eventId } };
}
case "action-create-task":
return { success: true, message: `Task "${cfg.title || "New task"}" queued`, outputData: { taskTitle: cfg.title } };
case "action-send-notification":
console.log(`[Automation] Notification: ${cfg.title || "Notification"}${cfg.message || ""}`);
return { success: true, message: `Notification: ${cfg.title}` };
case "action-update-data":
return { success: true, message: `Data update queued for ${cfg.module || "unknown"}` };
default:
return { success: false, message: `Unknown node type: ${node.type}` };
}
}
async function executeWorkflow(
workflow: Workflow,
space: string,
triggerData?: unknown,
): Promise<NodeResult[]> {
const sorted = topologicalSort(workflow.nodes, workflow.edges);
const results: NodeResult[] = [];
const nodeOutputs = new Map<string, unknown>();
for (const node of sorted) {
const startMs = Date.now();
// Gather input data from upstream edges
let inputData: unknown = triggerData;
const incomingEdges = workflow.edges.filter(e => e.toNode === node.id);
if (incomingEdges.length > 0) {
// For conditions that output booleans: if the upstream condition result is false
// and this node connects via the "true" port, skip it
const upstreamNode = workflow.nodes.find(n => n.id === incomingEdges[0].fromNode);
const upstreamOutput = nodeOutputs.get(incomingEdges[0].fromNode);
if (upstreamNode?.type.startsWith("condition-")) {
const port = incomingEdges[0].fromPort;
// Boolean result from condition
if (port === "true" && upstreamOutput === false) {
results.push({ nodeId: node.id, status: "skipped", message: "Condition false, skipping true branch", durationMs: 0 });
continue;
}
if (port === "false" && upstreamOutput === true) {
results.push({ nodeId: node.id, status: "skipped", message: "Condition true, skipping false branch", durationMs: 0 });
continue;
}
if (port === "inside" && upstreamOutput === false) {
results.push({ nodeId: node.id, status: "skipped", message: "Outside geofence, skipping inside branch", durationMs: 0 });
continue;
}
if (port === "outside" && upstreamOutput === true) {
results.push({ nodeId: node.id, status: "skipped", message: "Inside geofence, skipping outside branch", durationMs: 0 });
continue;
}
if (port === "in-window" && upstreamOutput === false) {
results.push({ nodeId: node.id, status: "skipped", message: "Outside time window", durationMs: 0 });
continue;
}
if (port === "match" && upstreamOutput === null) {
results.push({ nodeId: node.id, status: "skipped", message: "Data filter: no match", durationMs: 0 });
continue;
}
if (port === "no-match" && upstreamOutput !== null) {
results.push({ nodeId: node.id, status: "skipped", message: "Data filter: matched", durationMs: 0 });
continue;
}
}
// Use upstream output as input
if (upstreamOutput !== undefined) inputData = upstreamOutput;
}
try {
const result = await executeWorkflowNode(node, inputData, space);
const durationMs = Date.now() - startMs;
nodeOutputs.set(node.id, result.outputData);
results.push({
nodeId: node.id,
status: result.success ? "success" : "error",
message: result.message,
durationMs,
outputData: result.outputData,
});
} catch (e: any) {
results.push({
nodeId: node.id,
status: "error",
message: e.message || String(e),
durationMs: Date.now() - startMs,
});
}
}
return results;
}
// POST /api/workflows/:id/run — manual execute
routes.post("/api/workflows/:id/run", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const wf = doc.workflows?.[id];
if (!wf) return c.json({ error: "Workflow not found" }, 404);
const results = await executeWorkflow(wf, dataSpace);
const allOk = results.every(r => r.status !== "error");
_syncServer!.changeDoc<ScheduleDoc>(docId, `run workflow ${id}`, (d) => {
const w = d.workflows[id];
if (!w) return;
w.lastRunAt = Date.now();
w.lastRunStatus = allOk ? "success" : "error";
w.runCount = (w.runCount || 0) + 1;
w.updatedAt = Date.now();
});
return c.json({ success: allOk, results });
});
// POST /api/workflows/webhook/:hookId — external webhook trigger
routes.post("/api/workflows/webhook/:hookId", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const hookId = c.req.param("hookId");
const doc = ensureDoc(dataSpace);
let payload: unknown = {};
try { payload = await c.req.json(); } catch { /* empty payload */ }
// Find workflows with a trigger-webhook node matching this hookId
const matches: Workflow[] = [];
for (const wf of Object.values(doc.workflows || {})) {
if (!wf.enabled) continue;
for (const node of wf.nodes) {
if (node.type === "trigger-webhook" && node.config.hookId === hookId) {
matches.push(wf);
break;
}
}
}
if (matches.length === 0) return c.json({ error: "No matching workflow" }, 404);
const allResults: { workflowId: string; results: NodeResult[] }[] = [];
for (const wf of matches) {
const results = await executeWorkflow(wf, dataSpace, payload);
allResults.push({ workflowId: wf.id, results });
}
return c.json({ triggered: matches.length, results: allResults });
});
// ── Demo workflow seeds ──
function seedDemoWorkflows(space: string) {
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
if (Object.keys(doc.workflows || {}).length > 0) return;
const now = Date.now();
const demo1Id = "demo-arriving-home";
const demo1: Workflow = {
id: demo1Id,
name: "Arriving Home Notification",
enabled: false,
nodes: [
{ id: "n1", type: "trigger-proximity", label: "Location Proximity", position: { x: 50, y: 100 }, config: { lat: "49.2827", lng: "-123.1207", radiusKm: "1" } },
{ id: "n2", type: "condition-geofence", label: "Geofence Check", position: { x: 350, y: 100 }, config: { centerLat: "49.2827", centerLng: "-123.1207", radiusKm: "2" } },
{ id: "n3", type: "action-send-email", label: "Notify Family", position: { x: 650, y: 80 }, config: { to: "family@example.com", subject: "Almost home!", bodyTemplate: "<p>I'll be home in about {{distance}}km.</p>" } },
],
edges: [
{ id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" },
{ id: "e2", fromNode: "n1", fromPort: "distance", toNode: "n2", toPort: "coords" },
{ id: "e3", fromNode: "n2", fromPort: "inside", toNode: "n3", toPort: "trigger" },
],
lastRunAt: null,
lastRunStatus: null,
runCount: 0,
createdAt: now,
updatedAt: now,
};
const demo2Id = "demo-signoff-pipeline";
const demo2: Workflow = {
id: demo2Id,
name: "Document Sign-off Pipeline",
enabled: false,
nodes: [
{ id: "n1", type: "trigger-data-change", label: "Watch Sign-offs", position: { x: 50, y: 100 }, config: { module: "rnotes", field: "status" } },
{ id: "n2", type: "condition-compare", label: "Status = Signed", position: { x: 350, y: 100 }, config: { operator: "equals", compareValue: "signed" } },
{ id: "n3", type: "action-create-event", label: "Schedule Review", position: { x: 650, y: 60 }, config: { title: "Document Review Meeting", durationMinutes: "30" } },
{ id: "n4", type: "action-send-notification", label: "Notify Comms", position: { x: 650, y: 180 }, config: { title: "Sign-off Complete", message: "Document has been signed off", level: "success" } },
],
edges: [
{ id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" },
{ id: "e2", fromNode: "n1", fromPort: "data", toNode: "n2", toPort: "value" },
{ id: "e3", fromNode: "n2", fromPort: "true", toNode: "n3", toPort: "trigger" },
{ id: "e4", fromNode: "n2", fromPort: "true", toNode: "n4", toPort: "trigger" },
],
lastRunAt: null,
lastRunStatus: null,
runCount: 0,
createdAt: now,
updatedAt: now,
};
_syncServer!.changeDoc<ScheduleDoc>(docId, "seed demo workflows", (d) => {
if (!d.workflows) d.workflows = {} as any;
(d.workflows as any)[demo1Id] = demo1;
(d.workflows as any)[demo2Id] = demo2;
});
console.log(`[Schedule] Seeded 2 demo workflows for space "${space}"`);
}
// ── Module export ──
export const scheduleModule: RSpaceModule = {
@ -1339,6 +1939,7 @@ export const scheduleModule: RSpaceModule = {
async onInit(ctx) {
_syncServer = ctx.syncServer;
seedDefaultJobs("demo");
seedDemoWorkflows("demo");
startTickLoop();
},
feeds: [
@ -1353,6 +1954,7 @@ export const scheduleModule: RSpaceModule = {
outputPaths: [
{ path: "jobs", name: "Jobs", icon: "⏱", description: "Scheduled jobs and their configurations" },
{ path: "reminders", name: "Reminders", icon: "🔔", description: "Scheduled reminders with email notifications" },
{ path: "workflows", name: "Automations", icon: "🔀", description: "Visual automation workflows with triggers, conditions, and actions" },
{ path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" },
],
};

View File

@ -75,6 +75,285 @@ export interface Reminder {
updatedAt: number;
}
// ── Workflow / Automation types ──
export type AutomationNodeType =
// Triggers
| 'trigger-cron'
| 'trigger-data-change'
| 'trigger-webhook'
| 'trigger-manual'
| 'trigger-proximity'
// Conditions
| 'condition-compare'
| 'condition-geofence'
| 'condition-time-window'
| 'condition-data-filter'
// Actions
| 'action-send-email'
| 'action-post-webhook'
| 'action-create-event'
| 'action-create-task'
| 'action-send-notification'
| 'action-update-data';
export type AutomationNodeCategory = 'trigger' | 'condition' | 'action';
export interface WorkflowNodePort {
name: string;
type: 'trigger' | 'data' | 'boolean';
}
export interface WorkflowNode {
id: string;
type: AutomationNodeType;
label: string;
position: { x: number; y: number };
config: Record<string, unknown>;
// Runtime state (not persisted to Automerge — set during execution)
runtimeStatus?: 'idle' | 'running' | 'success' | 'error';
runtimeMessage?: string;
runtimeDurationMs?: number;
}
export interface WorkflowEdge {
id: string;
fromNode: string;
fromPort: string;
toNode: string;
toPort: string;
}
export interface Workflow {
id: string;
name: string;
enabled: boolean;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
lastRunAt: number | null;
lastRunStatus: 'success' | 'error' | null;
runCount: number;
createdAt: number;
updatedAt: number;
}
export interface AutomationNodeDef {
type: AutomationNodeType;
category: AutomationNodeCategory;
label: string;
icon: string;
description: string;
inputs: WorkflowNodePort[];
outputs: WorkflowNodePort[];
configSchema: { key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'cron'; options?: string[]; placeholder?: string }[];
}
export const NODE_CATALOG: AutomationNodeDef[] = [
// ── Triggers ──
{
type: 'trigger-cron',
category: 'trigger',
label: 'Cron Schedule',
icon: '⏰',
description: 'Fire on a cron schedule',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'timestamp', type: 'data' }],
configSchema: [
{ key: 'cronExpression', label: 'Cron Expression', type: 'cron', placeholder: '0 9 * * *' },
{ key: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/Vancouver' },
],
},
{
type: 'trigger-data-change',
category: 'trigger',
label: 'Data Change',
icon: '📊',
description: 'Fire when data changes in any rApp',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
configSchema: [
{ key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] },
{ key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' },
],
},
{
type: 'trigger-webhook',
category: 'trigger',
label: 'Webhook Incoming',
icon: '🔗',
description: 'Fire when an external webhook is received',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'payload', type: 'data' }],
configSchema: [
{ key: 'hookId', label: 'Hook ID', type: 'text', placeholder: 'auto-generated' },
],
},
{
type: 'trigger-manual',
category: 'trigger',
label: 'Manual Trigger',
icon: '👆',
description: 'Fire manually via button click',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }],
configSchema: [],
},
{
type: 'trigger-proximity',
category: 'trigger',
label: 'Location Proximity',
icon: '📍',
description: 'Fire when a location approaches a point',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'distance', type: 'data' }],
configSchema: [
{ key: 'lat', label: 'Latitude', type: 'number', placeholder: '49.2827' },
{ key: 'lng', label: 'Longitude', type: 'number', placeholder: '-123.1207' },
{ key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '1' },
],
},
// ── Conditions ──
{
type: 'condition-compare',
category: 'condition',
label: 'Compare Values',
icon: '⚖️',
description: 'Compare two values',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'value', type: 'data' }],
outputs: [{ name: 'true', type: 'trigger' }, { name: 'false', type: 'trigger' }],
configSchema: [
{ key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'greater-than', 'less-than', 'contains'] },
{ key: 'compareValue', label: 'Compare To', type: 'text', placeholder: 'value' },
],
},
{
type: 'condition-geofence',
category: 'condition',
label: 'Geofence Check',
icon: '🗺️',
description: 'Check if coordinates are within a radius',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'coords', type: 'data' }],
outputs: [{ name: 'inside', type: 'trigger' }, { name: 'outside', type: 'trigger' }],
configSchema: [
{ key: 'centerLat', label: 'Center Lat', type: 'number', placeholder: '49.2827' },
{ key: 'centerLng', label: 'Center Lng', type: 'number', placeholder: '-123.1207' },
{ key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '5' },
],
},
{
type: 'condition-time-window',
category: 'condition',
label: 'Time Window',
icon: '🕐',
description: 'Check if current time is within a window',
inputs: [{ name: 'trigger', type: 'trigger' }],
outputs: [{ name: 'in-window', type: 'trigger' }, { name: 'outside', type: 'trigger' }],
configSchema: [
{ key: 'startHour', label: 'Start Hour (0-23)', type: 'number', placeholder: '9' },
{ key: 'endHour', label: 'End Hour (0-23)', type: 'number', placeholder: '17' },
{ key: 'days', label: 'Days', type: 'text', placeholder: '1,2,3,4,5' },
],
},
{
type: 'condition-data-filter',
category: 'condition',
label: 'Data Filter',
icon: '🔍',
description: 'Filter data by field value',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'match', type: 'trigger' }, { name: 'no-match', type: 'trigger' }, { name: 'filtered', type: 'data' }],
configSchema: [
{ key: 'field', label: 'Field Path', type: 'text', placeholder: 'status' },
{ key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'contains', 'exists'] },
{ key: 'value', label: 'Value', type: 'text', placeholder: 'completed' },
],
},
// ── Actions ──
{
type: 'action-send-email',
category: 'action',
label: 'Send Email',
icon: '📧',
description: 'Send an email via SMTP',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
configSchema: [
{ key: 'to', label: 'To', type: 'text', placeholder: 'user@example.com' },
{ key: 'subject', label: 'Subject', type: 'text', placeholder: 'Notification from rSpace' },
{ key: 'bodyTemplate', label: 'Body (HTML)', type: 'textarea', placeholder: '<p>Hello {{name}}</p>' },
],
},
{
type: 'action-post-webhook',
category: 'action',
label: 'POST Webhook',
icon: '🌐',
description: 'Send an HTTP POST to an external URL',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'response', type: 'data' }],
configSchema: [
{ key: 'url', label: 'URL', type: 'text', placeholder: 'https://api.example.com/hook' },
{ key: 'method', label: 'Method', type: 'select', options: ['POST', 'PUT', 'PATCH'] },
{ key: 'bodyTemplate', label: 'Body Template', type: 'textarea', placeholder: '{"event": "{{event}}"}' },
],
},
{
type: 'action-create-event',
category: 'action',
label: 'Create Calendar Event',
icon: '📅',
description: 'Create an event in rCal',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'eventId', type: 'data' }],
configSchema: [
{ key: 'title', label: 'Event Title', type: 'text', placeholder: 'Meeting' },
{ key: 'durationMinutes', label: 'Duration (min)', type: 'number', placeholder: '60' },
],
},
{
type: 'action-create-task',
category: 'action',
label: 'Create Task',
icon: '✅',
description: 'Create a task in rWork',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }],
configSchema: [
{ key: 'title', label: 'Task Title', type: 'text', placeholder: 'New task' },
{ key: 'description', label: 'Description', type: 'textarea', placeholder: 'Task details...' },
{ key: 'priority', label: 'Priority', type: 'select', options: ['low', 'medium', 'high', 'urgent'] },
],
},
{
type: 'action-send-notification',
category: 'action',
label: 'Send Notification',
icon: '🔔',
description: 'Send an in-app notification',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }],
configSchema: [
{ key: 'title', label: 'Title', type: 'text', placeholder: 'Notification' },
{ key: 'message', label: 'Message', type: 'textarea', placeholder: 'Something happened...' },
{ key: 'level', label: 'Level', type: 'select', options: ['info', 'warning', 'error', 'success'] },
],
},
{
type: 'action-update-data',
category: 'action',
label: 'Update Data',
icon: '💾',
description: 'Update data in an rApp document',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
configSchema: [
{ key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork'] },
{ key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] },
{ key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' },
],
},
];
export interface ScheduleDoc {
meta: {
module: string;
@ -85,6 +364,7 @@ export interface ScheduleDoc {
};
jobs: Record<string, ScheduleJob>;
reminders: Record<string, Reminder>;
workflows: Record<string, Workflow>;
log: ExecutionLogEntry[];
}
@ -104,6 +384,7 @@ export const scheduleSchema: DocSchema<ScheduleDoc> = {
},
jobs: {},
reminders: {},
workflows: {},
log: [],
}),
};

View File

@ -526,6 +526,24 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rtrips/trips.css"),
);
// Build trips demo page script
await build({
configFile: false,
root: resolve(__dirname, "modules/rtrips/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rtrips"),
lib: {
entry: resolve(__dirname, "modules/rtrips/components/trips-demo.ts"),
formats: ["es"],
fileName: () => "trips-demo.js",
},
rollupOptions: {
output: { entryFileNames: "trips-demo.js" },
},
},
});
// Build cal module component
await build({
configFile: false,
@ -916,6 +934,37 @@ export default defineConfig({
},
});
// Build schedule automation canvas component
await build({
configFile: false,
root: resolve(__dirname, "modules/rschedule/components"),
resolve: {
alias: {
"../schemas": resolve(__dirname, "modules/rschedule/schemas.ts"),
},
},
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rschedule"),
lib: {
entry: resolve(__dirname, "modules/rschedule/components/folk-automation-canvas.ts"),
formats: ["es"],
fileName: () => "folk-automation-canvas.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-automation-canvas.js",
},
},
},
});
// Copy automation canvas CSS
copyFileSync(
resolve(__dirname, "modules/rschedule/components/automation-canvas.css"),
resolve(__dirname, "dist/modules/rschedule/automation-canvas.css"),
);
// ── Demo infrastructure ──
// Build demo-sync-vanilla library