Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(git check-ignore *)",
"Bash(rm -f bun.lockb)",
"Bash(rm -rf node_modules)",
"Bash(echo \"exit:$?\")",
"Bash(cd \"/Users/arjunkrishna/Developer/My Projects/nodrix\" && rm -f /tmp/kysely-test-bundle.js && echo \"=== .gitignore lock entries ===\" && grep -n \"lock\" .gitignore)",
"Bash(curl -s \"https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://nodrix.live&strategy=mobile&category=performance\")",
"WebFetch(domain:pagespeed.web.dev)",
"mcp__plugin_cloudflare_cloudflare-docs__search_cloudflare_documentation"
],
"additionalDirectories": [
"/private/tmp"
]
}
}
43 changes: 43 additions & 0 deletions shared/blocks/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Action block manifests. Per-kind run logic lives in the worker engine.

import type { BlockManifest } from './index';

export const ACTION_CATALOG = [
{
kind: 'set_variable',
category: 'action',
label: 'Set variable',
description: 'Write a value to a variable (queued as a control write).',
icon: 'M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75',
executable: true,
ports: { in: ['in'], out: ['out'] },
fields: [
{ key: 'variable', label: 'Variable', type: 'variable', required: true },
{ key: 'value', label: 'Value', type: 'text', placeholder: 'value' },
],
},
{
kind: 'call_integration',
category: 'action',
label: 'Call integration',
description: 'Invoke a configured integration (webhook, HTTP, email, …).',
icon: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z',
executable: true,
ports: { in: ['in'], out: ['out'] },
fields: [
{ key: 'integration_id', label: 'Integration', type: 'integration', required: true },
],
},
{
kind: 'emit_event',
category: 'action',
label: 'Emit event',
description: 'Fire a named event that other event-triggered automations can react to.',
icon: 'M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.835 2.535M10.34 6.66a23.847 23.847 0 0 0 8.835-2.535m0 0A23.74 23.74 0 0 0 18.795 3m.38 1.125a23.91 23.91 0 0 1 1.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 0 0 1.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 0 1 0 3.46',
executable: true,
ports: { in: ['in'] },
fields: [
{ key: 'event', label: 'Event name', type: 'text', required: true, mono: true, placeholder: 'event name' },
],
},
] as const satisfies readonly BlockManifest[];
125 changes: 125 additions & 0 deletions shared/blocks/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// The automation flow-graph model + pure helpers, shared by the worker engine
// (executor, scheduler, hot path) and the web editor (canvas, validation, save).

import { VALID_TRIGGER_KINDS, VALID_ACTION_KINDS } from './index';

export type GraphNode = {
id: string;
kind: string; // trigger/action kind from the catalog
config: Record<string, unknown>;
x?: number; // canvas position (editor only)
y?: number;
};

export type GraphEdge = {
from: string; // source node id
to: string; // target node id
port?: string; // source output port; default 'out'
};

export type AutomationGraph = { nodes: GraphNode[]; edges: GraphEdge[] };

// Builds a linear trigger → action chain from legacy parts. Shared by the worker
// convert-on-read shim and the editor's "open a pre-graph automation" path.
export function buildLinearGraph(
triggerKind: string | undefined,
triggerConfig: Record<string, unknown>,
actions: unknown[]
): AutomationGraph {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];

if (triggerKind && VALID_TRIGGER_KINDS.has(triggerKind)) {
nodes.push({ id: 'trigger', kind: triggerKind, config: triggerConfig ?? {} });
}

let prev: string | null = nodes.length ? 'trigger' : null;
let i = 0;
for (const raw of actions) {
if (!raw || typeof raw !== 'object') continue;
const { type, ...config } = raw as Record<string, unknown> & { type?: unknown };
if (typeof type !== 'string' || !VALID_ACTION_KINDS.has(type)) continue;
const id = `a${i++}`;
nodes.push({ id, kind: type, config });
if (prev) edges.push({ from: prev, to: id, port: 'out' });
prev = id;
}

return { nodes, edges };
}

export function isGraph(v: unknown): v is AutomationGraph {
return !!v && typeof v === 'object'
&& Array.isArray((v as AutomationGraph).nodes)
&& Array.isArray((v as AutomationGraph).edges);
}

export function triggerNodes(graph: AutomationGraph): GraphNode[] {
return graph.nodes.filter((n) => VALID_TRIGGER_KINDS.has(n.kind));
}

export function actionNodes(graph: AutomationGraph): GraphNode[] {
return graph.nodes.filter((n) => VALID_ACTION_KINDS.has(n.kind));
}

export function countActionNodes(graph: AutomationGraph): number {
return actionNodes(graph).length;
}

// Entrypoint: the first node (trigger, or the head of the action chain when a
// row has no valid trigger — keeps an orphaned automation runnable).
export function entryNode(graph: AutomationGraph): GraphNode | undefined {
return graph.nodes[0];
}

export function nodesById(graph: AutomationGraph): Map<string, GraphNode> {
return new Map(graph.nodes.map((n) => [n.id, n]));
}

export function outgoingEdges(graph: AutomationGraph, nodeId: string): GraphEdge[] {
return graph.edges.filter((e) => e.from === nodeId);
}

// ",kind,kind," membership string for the denormalized trigger_kinds column.
export function serializeTriggerKinds(graph: AutomationGraph): string {
const kinds = [...new Set(triggerNodes(graph).map((n) => n.kind))];
return kinds.length ? `,${kinds.join(',')},` : '';
}

// Validates a graph for saving. Returns an error message, or null if valid.
// Enforces: ≥1 trigger, known node kinds, edges reference real nodes, and DAG
// (no cycles) — the executor relies on acyclicity for its bounded traversal.
export function graphError(graph: AutomationGraph): string | null {
if (triggerNodes(graph).length === 0) return 'Add at least one trigger.';

const ids = new Set(graph.nodes.map((n) => n.id));
for (const n of graph.nodes) {
if (!VALID_TRIGGER_KINDS.has(n.kind) && !VALID_ACTION_KINDS.has(n.kind)) {
return `Unknown block kind: ${n.kind}`;
}
}
for (const e of graph.edges) {
if (!ids.has(e.from) || !ids.has(e.to)) return 'An edge references a missing node.';
}
if (hasCycle(graph)) return 'Connections must not form a loop.';
return null;
}

function hasCycle(graph: AutomationGraph): boolean {
const adj = new Map<string, string[]>();
for (const e of graph.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to);

const state = new Map<string, 0 | 1 | 2>(); // 0=visiting, 2=done
const dfs = (id: string): boolean => {
state.set(id, 0);
for (const to of adj.get(id) ?? []) {
const s = state.get(to);
if (s === 0) return true; // back-edge → cycle
if (s === undefined && dfs(to)) return true;
}
state.set(id, 2);
return false;
};
for (const n of graph.nodes) if (state.get(n.id) === undefined && dfs(n.id)) return true;
return false;
}
97 changes: 97 additions & 0 deletions shared/blocks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Manifest-driven catalog of automation blocks (triggers + actions). Per-kind
// eval/run lives in the worker engine, so these are pure metadata; the kind enums
// and validation sets all derive from here. Worker-safe (no fetch/DOM).

import type { SummaryDescriptor } from '@nodrix/integrations-shared';
import { TRIGGER_CATALOG } from './triggers';
import { ACTION_CATALOG } from './actions';

export { TRIGGER_CATALOG } from './triggers';
export { ACTION_CATALOG } from './actions';
export * from './graph';

// ─── Manifest types ─────────────────────────────────────────────────────────

export type BlockCategory = 'trigger' | 'condition' | 'action';

// Superset of integration ConnField types; drives the editor field renderer.
export type BlockFieldType =
| 'text'
| 'textarea'
| 'json'
| 'select'
| 'number'
| 'boolean'
| 'variable'
| 'integration'
| 'time'
| 'weekdays';

export type BlockField = {
key: string;
label: string;
type: BlockFieldType;
required?: boolean;
placeholder?: string;
hint?: string;
mono?: boolean;
options?: readonly string[];
default?: string | number | boolean;
};

// Graph ports: triggers are entrypoints (out only), actions are in→out,
// conditions fan out via named ports (e.g. true/false).
export type BlockPorts = {
in?: readonly string[];
out?: readonly string[];
};

export type BlockManifest = {
kind: string;
category: BlockCategory;
label: string;
description: string;
icon: string; // 24x24 outline path
executable: boolean; // false = "coming soon", not run by the engine yet
ports: BlockPorts;
fields: readonly BlockField[];
summary?: SummaryDescriptor;
};

// ─── Derived kinds ──────────────────────────────────────────────────────────

export type TriggerKind = (typeof TRIGGER_CATALOG)[number]['kind'];
export type ActionKind = (typeof ACTION_CATALOG)[number]['kind'];
export type BlockKind = TriggerKind | ActionKind;

// Non-empty tuples for allowlists (worker validation, z.enum, …).
export const TRIGGER_KINDS = TRIGGER_CATALOG.map((t) => t.kind) as [TriggerKind, ...TriggerKind[]];
export const ACTION_KINDS = ACTION_CATALOG.map((a) => a.kind) as [ActionKind, ...ActionKind[]];

export const VALID_TRIGGER_KINDS: ReadonlySet<string> = new Set(TRIGGER_KINDS);
export const VALID_ACTION_KINDS: ReadonlySet<string> = new Set(ACTION_KINDS);

export const EXECUTABLE_ACTIONS = ACTION_CATALOG.filter((a) => a.executable);
export const COMING_SOON_ACTIONS = ACTION_CATALOG.filter((a) => !a.executable);
export const EXECUTABLE_TRIGGERS = TRIGGER_CATALOG.filter((t) => t.executable);
export const COMING_SOON_TRIGGERS = TRIGGER_CATALOG.filter((t) => !t.executable);

// ─── Lookups ──────────────────────────────────────────────────────────────────

export function triggerSpec(kind: string): BlockManifest {
return TRIGGER_CATALOG.find((t) => t.kind === kind) ?? TRIGGER_CATALOG[0];
}

export function actionSpec(kind: string): BlockManifest {
return ACTION_CATALOG.find((a) => a.kind === kind) ?? ACTION_CATALOG[0];
}

export function blockSpec(category: BlockCategory, kind: string): BlockManifest | undefined {
const catalog = category === 'trigger' ? TRIGGER_CATALOG : category === 'action' ? ACTION_CATALOG : [];
return catalog.find((b) => b.kind === kind);
}

// Find a block by kind across catalogs (the editor only knows a node's kind).
export function findBlock(kind: string): BlockManifest | undefined {
return TRIGGER_CATALOG.find((b) => b.kind === kind) ?? ACTION_CATALOG.find((b) => b.kind === kind);
}
12 changes: 12 additions & 0 deletions shared/blocks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@nodrix/blocks-shared",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./index.ts"
},
"dependencies": {
"@nodrix/integrations-shared": "*"
}
}
Loading