diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7b0c17c --- /dev/null +++ b/.claude/settings.json @@ -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" + ] + } +} diff --git a/internal b/internal index 0b559bb..49283a0 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 0b559bbe377d8fe28240793ef7b134646625ddb4 +Subproject commit 49283a0b7013e67e8b5d12c271cec650524fc793 diff --git a/shared/blocks/actions.ts b/shared/blocks/actions.ts new file mode 100644 index 0000000..2e45fde --- /dev/null +++ b/shared/blocks/actions.ts @@ -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[]; diff --git a/shared/blocks/graph.ts b/shared/blocks/graph.ts new file mode 100644 index 0000000..6a2d7c6 --- /dev/null +++ b/shared/blocks/graph.ts @@ -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; + 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, + 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 & { 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 { + 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(); + for (const e of graph.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to); + + const state = new Map(); // 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; +} diff --git a/shared/blocks/index.ts b/shared/blocks/index.ts new file mode 100644 index 0000000..986ab3f --- /dev/null +++ b/shared/blocks/index.ts @@ -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 = new Set(TRIGGER_KINDS); +export const VALID_ACTION_KINDS: ReadonlySet = 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); +} diff --git a/shared/blocks/package.json b/shared/blocks/package.json new file mode 100644 index 0000000..cbb6fb1 --- /dev/null +++ b/shared/blocks/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nodrix/blocks-shared", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./index.ts" + }, + "dependencies": { + "@nodrix/integrations-shared": "*" + } +} diff --git a/shared/blocks/triggers.ts b/shared/blocks/triggers.ts new file mode 100644 index 0000000..b5a1a3a --- /dev/null +++ b/shared/blocks/triggers.ts @@ -0,0 +1,100 @@ +// Trigger block manifests. Per-kind evaluation lives in the worker engine. + +import type { BlockManifest } from './index'; + +export const TRIGGER_CATALOG = [ + { + kind: 'variable', + category: 'trigger', + label: 'Variable', + description: 'Run when the value of a variable meets a condition.', + icon: 'M21 7.5 12 3 3 7.5m18 0L12 12m9-4.5v9L12 21m0-9L3 7.5m9 4.5v9M3 7.5v9L12 21', + executable: true, + ports: { out: ['out'] }, + fields: [ + { key: 'variable', label: 'Variable', type: 'variable', required: true }, + { + key: 'operator', + label: 'Condition', + type: 'select', + options: ['>', '<', '>=', '<=', '==', '!=', 'changed'], + default: '>', + }, + { key: 'value', label: 'Value', type: 'text', placeholder: 'value' }, + { + key: 'mode', + label: 'Fire mode', + type: 'select', + options: ['edge', 'always'], + default: 'edge', + hint: 'edge = once on entry; always = every matching reading.', + }, + { + key: 'cooldown_seconds', + label: 'Cooldown (s)', + type: 'number', + default: 0, + hint: 'Minimum seconds between fires (0 = none).', + }, + ], + }, + { + kind: 'manual', + category: 'trigger', + label: 'Manual', + description: 'Run on demand with a Run button — no condition.', + icon: 'M15 11.25h-1.5m0 0V9.75m0 1.5h-1.5m1.5 0v-1.5M10.5 21h.75m-1.5-12.75V6.75A2.25 2.25 0 0 1 12 4.5a2.25 2.25 0 0 1 2.25 2.25V9m-4.5 0H7.5a1.5 1.5 0 0 0-1.5 1.5v8.25A2.25 2.25 0 0 0 8.25 21h7.5A2.25 2.25 0 0 0 18 18.75V10.5a1.5 1.5 0 0 0-1.5-1.5h-2.25M9.75 9h4.5', + executable: true, + ports: { out: ['out'] }, + fields: [], + }, + { + kind: 'schedule', + category: 'trigger', + label: 'Schedule', + description: 'Run at a specific time of day, on chosen weekdays.', + icon: 'M12 6v6l4 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z', + executable: true, + ports: { out: ['out'] }, + fields: [ + { key: 'time', label: 'Time', type: 'time', required: true, default: '08:00' }, + { key: 'days', label: 'Days', type: 'weekdays', hint: 'No days selected = every day.' }, + { key: 'tz', label: 'Timezone', type: 'text', mono: true, placeholder: 'IANA timezone' }, + ], + }, + { + kind: 'sunset_sunrise', + category: 'trigger', + label: 'Sunset / Sunrise', + description: 'Run relative to local sunrise or sunset.', + icon: 'M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z', + executable: true, + ports: { out: ['out'] }, + fields: [ + { key: 'event', label: 'Event', type: 'select', options: ['sunrise', 'sunset'], default: 'sunset' }, + { key: 'lat', label: 'Latitude', type: 'number', placeholder: 'lat' }, + { key: 'lng', label: 'Longitude', type: 'number', placeholder: 'lng' }, + { key: 'offset_minutes', label: 'Offset (min)', type: 'number', default: 0 }, + ], + }, + { + kind: 'event', + category: 'trigger', + label: 'Event', + description: 'Run when a named event is posted by hardware or an automation.', + icon: 'M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0', + executable: true, + ports: { out: ['out'] }, + fields: [ + { + key: 'event', + label: 'Event name', + type: 'text', + required: true, + mono: true, + placeholder: 'event name, e.g. button_pressed', + hint: 'Matched against POST /v1/events { "event": "…" }.', + }, + ], + }, +] as const satisfies readonly BlockManifest[]; diff --git a/shared/widgets/package.json b/shared/widgets/package.json index cc5175c..bffd0ae 100644 --- a/shared/widgets/package.json +++ b/shared/widgets/package.json @@ -6,5 +6,12 @@ "exports": { ".": "./index.ts", "./registry": "./registry.ts" + }, + "dependencies": { + "apexcharts": "^5.12.0", + "leaflet": "^1.9.4" + }, + "devDependencies": { + "@types/leaflet": "^1.9.21" } } diff --git a/web/package.json b/web/package.json index 6a5e4b1..88243f0 100644 --- a/web/package.json +++ b/web/package.json @@ -10,12 +10,12 @@ "typecheck": "vue-tsc --noEmit" }, "dependencies": { + "@nodrix/blocks-shared": "*", "@nodrix/integrations-shared": "*", "@nodrix/widgets-shared": "*", - "apexcharts": "^5.12.0", + "@vue-flow/core": "^1.48.2", "better-auth": "^1.6.11", "grid-layout-plus": "^1.1.1", - "leaflet": "^1.9.4", "pinia": "^2.3.0", "reka-ui": "^2.0.2", "vue": "^3.5.13", @@ -23,7 +23,6 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", - "@types/leaflet": "^1.9.21", "@vitejs/plugin-vue": "^5.2.1", "tailwindcss": "^4.0.0", "typescript": "^5.6.3", diff --git a/web/src/pages/project/automations/AutomationCard.vue b/web/src/pages/project/automations/AutomationCard.vue index d055882..fbbcfa7 100644 --- a/web/src/pages/project/automations/AutomationCard.vue +++ b/web/src/pages/project/automations/AutomationCard.vue @@ -62,13 +62,17 @@ async function run() { async function duplicate() { menuOpen.value = false; try { - await project.createAutomation({ - name: `${props.automation.name} (copy)`, - description: props.automation.description, - trigger_type: props.automation.trigger_type, - trigger_config: props.automation.trigger_config, - actions: props.automation.actions, - }); + await project.createAutomation( + props.automation.graph + ? { name: `${props.automation.name} (copy)`, description: props.automation.description, graph: props.automation.graph } + : { + name: `${props.automation.name} (copy)`, + description: props.automation.description, + trigger_type: props.automation.trigger_type, + trigger_config: props.automation.trigger_config, + actions: props.automation.actions, + } + ); } catch (e) { toast.error((e as Error).message); } diff --git a/web/src/pages/project/automations/AutomationEditor.vue b/web/src/pages/project/automations/AutomationEditor.vue index 15e61f0..f48a626 100644 --- a/web/src/pages/project/automations/AutomationEditor.vue +++ b/web/src/pages/project/automations/AutomationEditor.vue @@ -1,16 +1,21 @@