From 70e7756c07a417253c54201377fc755139352220 Mon Sep 17 00:00:00 2001 From: Arjun Krishna Date: Mon, 1 Jun 2026 20:18:59 +0530 Subject: [PATCH 1/5] feat: add shared block catalog for automation triggers and actions --- .claude/settings.json | 17 ++++ internal | 2 +- shared/blocks/actions.ts | 43 +++++++++ shared/blocks/index.ts | 91 ++++++++++++++++++ shared/blocks/package.json | 12 +++ shared/blocks/triggers.ts | 93 +++++++++++++++++++ shared/widgets/package.json | 7 ++ web/package.json | 4 +- .../project/automations/automation-catalog.ts | 65 ++++++------- worker/package.json | 1 + worker/src/domains/automations/service.ts | 6 +- 11 files changed, 297 insertions(+), 44 deletions(-) create mode 100644 .claude/settings.json create mode 100644 shared/blocks/actions.ts create mode 100644 shared/blocks/index.ts create mode 100644 shared/blocks/package.json create mode 100644 shared/blocks/triggers.ts 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/index.ts b/shared/blocks/index.ts new file mode 100644 index 0000000..1f47f86 --- /dev/null +++ b/shared/blocks/index.ts @@ -0,0 +1,91 @@ +// 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'; + +// ─── 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); +} 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..c8fc6f7 --- /dev/null +++ b/shared/blocks/triggers.ts @@ -0,0 +1,93 @@ +// 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.', + }, + ], + }, + { + kind: 'scene', + category: 'trigger', + label: 'Scene', + 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..7f8ac79 100644 --- a/web/package.json +++ b/web/package.json @@ -10,12 +10,11 @@ "typecheck": "vue-tsc --noEmit" }, "dependencies": { + "@nodrix/blocks-shared": "*", "@nodrix/integrations-shared": "*", "@nodrix/widgets-shared": "*", - "apexcharts": "^5.12.0", "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 +22,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/automation-catalog.ts b/web/src/pages/project/automations/automation-catalog.ts index e49cd32..c48355a 100644 --- a/web/src/pages/project/automations/automation-catalog.ts +++ b/web/src/pages/project/automations/automation-catalog.ts @@ -1,7 +1,14 @@ -// Catalog + formatting helpers for automations. Trigger metadata drives the -// picker, the editor header, and the card summaries. Keeping the human-readable -// summary logic here means cards and the editor stay in sync. - +// Formatting helpers for automations: the human-readable summary/chip logic that +// keeps cards and the editor in sync. Block metadata (labels, icons, kinds) is +// sourced from the shared block catalog — the single source of truth — so adding +// a trigger/action is a manifest folder, not an edit here. + +import { + TRIGGER_CATALOG, + ACTION_CATALOG, + triggerSpec as triggerManifest, + actionSpec as actionManifest, +} from '@nodrix/blocks-shared'; import type { AutomationTriggerType, VariableOperator, Action } from '../../../types'; export type TriggerSpec = { @@ -11,25 +18,17 @@ export type TriggerSpec = { icon: string; // 24x24 outline path }; -// Heroicons-style outline paths. -const ICON = { - variable: 'M21 7.5 12 3 3 7.5m18 0L12 12m9-4.5v9L12 21m0-9L3 7.5m9 4.5v9M3 7.5v9L12 21', - scene: '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', - clock: 'M12 6v6l4 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z', - sun: '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', - bell: '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', -} as const; - -export const TRIGGERS: readonly TriggerSpec[] = [ - { key: 'variable', title: 'Variable', desc: 'Run when the value of a variable meets a condition.', icon: ICON.variable }, - { key: 'scene', title: 'Scene', desc: 'Run on demand with a Run button — no condition.', icon: ICON.scene }, - { key: 'schedule', title: 'Schedule', desc: 'Run at a specific time of day, on chosen weekdays.', icon: ICON.clock }, - { key: 'sunset_sunrise', title: 'Sunset / Sunrise', desc: 'Run relative to local sunrise or sunset.', icon: ICON.sun }, - { key: 'event', title: 'Event', desc: 'Run when a named event is posted by hardware or an automation.', icon: ICON.bell }, -]; +// Picker / editor-header metadata, derived from the shared catalog. +export const TRIGGERS: readonly TriggerSpec[] = TRIGGER_CATALOG.map((m) => ({ + key: m.kind as AutomationTriggerType, + title: m.label, + desc: m.description, + icon: m.icon, +})); export function triggerSpec(key: AutomationTriggerType): TriggerSpec { - return TRIGGERS.find((t) => t.key === key) ?? TRIGGERS[0]!; + const m = triggerManifest(key); + return { key: m.kind as AutomationTriggerType, title: m.label, desc: m.description, icon: m.icon }; } export const OPERATORS: readonly { value: VariableOperator; label: string; short: string }[] = [ @@ -42,11 +41,10 @@ export const OPERATORS: readonly { value: VariableOperator; label: string; short { value: 'changed', label: 'changes', short: 'changes' }, ]; -export const ACTION_TYPES = [ - { value: 'set_variable', label: 'Set variable' }, - { value: 'call_integration', label: 'Call integration' }, - { value: 'emit_event', label: 'Emit event' }, -] as const; +export const ACTION_TYPES: readonly { value: string; label: string }[] = ACTION_CATALOG.map((m) => ({ + value: m.kind, + label: m.label, +})); const DAY_LABEL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; @@ -93,12 +91,7 @@ export function triggerSummary( // ─── Rule-flow chips ────────────────────────────────────────────────────────── // Compact trigger + action nodes for the rule-flow card and editor preview. - -const ACTION_ICON = { - set_variable: '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', - call_integration: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z', - emit_event: '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', -} as const; +// Action icons come from the shared catalog (actionManifest(kind).icon). export type FlowChip = { icon: string; label: string }; @@ -150,16 +143,16 @@ export function actionChips(actions: unknown[] | null | undefined, r: ChipResolv return actions.map((raw) => { const a = raw as Partial & Record; if (a.type === 'set_variable') { - return { icon: ACTION_ICON.set_variable, label: `${varLabel(String(a.variable ?? '?'))} = ${fmtVal(a.value)}` }; + return { icon: actionManifest('set_variable').icon, label: `${varLabel(String(a.variable ?? '?'))} = ${fmtVal(a.value)}` }; } if (a.type === 'call_integration') { const meta = r.integration?.(String(a.integration_id ?? '')); - return { icon: meta?.icon ?? ACTION_ICON.call_integration, label: meta?.name ?? 'an integration' }; + return { icon: meta?.icon ?? actionManifest('call_integration').icon, label: meta?.name ?? 'an integration' }; } if (a.type === 'emit_event') { - return { icon: ACTION_ICON.emit_event, label: `Emit "${a.event ?? '?'}"` }; + return { icon: actionManifest('emit_event').icon, label: `Emit "${a.event ?? '?'}"` }; } - return { icon: ACTION_ICON.call_integration, label: 'Unknown action' }; + return { icon: actionManifest('call_integration').icon, label: 'Unknown action' }; }); } diff --git a/worker/package.json b/worker/package.json index ff9fffc..e7c8c8d 100644 --- a/worker/package.json +++ b/worker/package.json @@ -13,6 +13,7 @@ "dependencies": { "@cloudflare/workers-oauth-provider": "^0.7.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@nodrix/blocks-shared": "*", "@nodrix/integrations-shared": "*", "@nodrix/widgets-shared": "*", "agents": "^0.13.2", diff --git a/worker/src/domains/automations/service.ts b/worker/src/domains/automations/service.ts index 6e48964..641cf12 100644 --- a/worker/src/domains/automations/service.ts +++ b/worker/src/domains/automations/service.ts @@ -8,9 +8,7 @@ import { projectStub } from '../../platform/durable-objects/stubs'; import { safeParse, buildUpdate } from '../../platform/lib/sql'; import { type Actor, ServiceError } from '../../platform/lib/service'; import { assertProjectAccess } from '../projects/service'; - -const TRIGGER_TYPES = ['variable', 'scene', 'schedule', 'sunset_sunrise', 'event'] as const; -type TriggerType = (typeof TRIGGER_TYPES)[number]; +import { VALID_TRIGGER_KINDS } from '@nodrix/blocks-shared'; export function isScheduled(t: string | undefined): boolean { return t === 'schedule' || t === 'sunset_sunrise'; @@ -93,7 +91,7 @@ export async function createAutomation( await assertProjectAccess(env, actor, projectId); const name = (input.name ?? '').trim(); if (!name) throw new ServiceError('bad_request', 'name is required', 'missing_name'); - if (!TRIGGER_TYPES.includes(input.trigger_type as TriggerType)) { + if (!VALID_TRIGGER_KINDS.has(input.trigger_type)) { throw new ServiceError('bad_request', 'invalid trigger_type', 'invalid_trigger_type'); } From 54e274c11bda4eac0e92e4038c3c72727bc57797 Mon Sep 17 00:00:00 2001 From: Arjun Krishna Date: Mon, 1 Jun 2026 20:33:12 +0530 Subject: [PATCH 2/5] feat: implement action node handlers and automation graph construction --- worker/src/platform/engine/actions.ts | 66 ++++++++++++ worker/src/platform/engine/graph.ts | 65 ++++++++++++ worker/src/platform/engine/run.ts | 143 +++++++++++--------------- worker/src/platform/engine/types.ts | 20 ++++ worker/test/graph.test.ts | 64 ++++++++++++ 5 files changed, 274 insertions(+), 84 deletions(-) create mode 100644 worker/src/platform/engine/actions.ts create mode 100644 worker/src/platform/engine/graph.ts create mode 100644 worker/test/graph.test.ts diff --git a/worker/src/platform/engine/actions.ts b/worker/src/platform/engine/actions.ts new file mode 100644 index 0000000..ec8f508 --- /dev/null +++ b/worker/src/platform/engine/actions.ts @@ -0,0 +1,66 @@ +// Action node handlers, keyed by kind. Adding an action = register a handler here +// and add its manifest to @nodrix/blocks-shared. + +import type { Env } from '../../env'; +import { executeIntegration, recordIntegrationRun } from './integrations'; +import type { AutomationContext, AutomationRow, IntegrationRow } from './types'; + +export const MAX_DEPTH = 5; + +export type ActionDeps = { + setVariable: (variable: string, value: unknown) => Promise; + emitEvent: ( + event: string, + payload: Record | undefined, + ctx: AutomationContext + ) => Promise; +}; + +type HandlerArgs = { + env: Env; + automation: AutomationRow; + ctx: AutomationContext; + config: Record; + deps: ActionDeps; +}; + +type ActionHandler = (a: HandlerArgs) => Promise; + +const HANDLERS: Record = { + set_variable: async ({ config, deps }) => { + await deps.setVariable(String(config.variable), config.value); + }, + + call_integration: async ({ env, automation, ctx, config }) => { + const integration = await loadIntegration(env, automation.project_id, String(config.integration_id)); + if (!integration) throw new Error(`integration ${config.integration_id} not found`); + const res = await executeIntegration(integration, ctx, config.payload as Record | undefined); + await recordIntegrationRun(env, integration.id, res).catch(() => {}); + if (res.status === 'error') throw new Error(`integration "${integration.name}": ${res.detail}`); + }, + + emit_event: async ({ ctx, config, deps }) => { + if (ctx.depth >= MAX_DEPTH) throw new Error('max event depth exceeded'); + await deps.emitEvent(String(config.event), config.payload as Record | undefined, { + ...ctx, + depth: ctx.depth + 1, + }); + }, +}; + +export async function runActionNode(kind: string, args: HandlerArgs): Promise { + const handler = HANDLERS[kind]; + if (!handler) throw new Error(`no handler for action '${kind}'`); + await handler(args); +} + +async function loadIntegration(env: Env, projectId: string, id: string): Promise { + return env.DB + .prepare( + `SELECT id, project_id, name, kind, config, enabled + FROM integrations + WHERE id = ? AND project_id = ? AND archived_at IS NULL` + ) + .bind(id, projectId) + .first(); +} diff --git a/worker/src/platform/engine/graph.ts b/worker/src/platform/engine/graph.ts new file mode 100644 index 0000000..e701399 --- /dev/null +++ b/worker/src/platform/engine/graph.ts @@ -0,0 +1,65 @@ +// Builds the executable graph for an automation. Until the editor persists graphs +// directly, this rebuilds the linear trigger → action chain from the legacy +// trigger_type/trigger_config/actions columns (convert-on-read). + +import { VALID_TRIGGER_KINDS, VALID_ACTION_KINDS } from '@nodrix/blocks-shared'; +import type { AutomationGraph, GraphEdge, GraphNode, AutomationRow } from './types'; + +const TRIGGER_NODE_ID = 'trigger'; + +export function toGraph(row: AutomationRow): AutomationGraph { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + if (VALID_TRIGGER_KINDS.has(row.trigger_type)) { + nodes.push({ id: TRIGGER_NODE_ID, kind: row.trigger_type, config: safeObject(row.trigger_config) }); + } + + let prev: string | null = nodes.length ? TRIGGER_NODE_ID : null; + parseActions(row.actions).forEach((a, i) => { + const { type, ...config } = a; + const id = `a${i}`; + nodes.push({ id, kind: String(type), config }); + if (prev) edges.push({ from: prev, to: id, port: 'out' }); + prev = id; + }); + + return { nodes, edges }; +} + +// Entrypoint: the trigger node, or the head of the action chain if the row has no +// valid trigger (keeps a manual/orphaned automation runnable, matching prior behavior). +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); +} + +export function countActionNodes(graph: AutomationGraph): number { + return graph.nodes.filter((n) => VALID_ACTION_KINDS.has(n.kind)).length; +} + +function parseActions(raw: string): Array & { type: string }> { + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { return []; } + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (a): a is Record & { type: string } => + !!a && typeof a === 'object' && VALID_ACTION_KINDS.has((a as { type?: unknown }).type as string) + ); +} + +function safeObject(raw: string): Record { + try { + const v = JSON.parse(raw); + return v && typeof v === 'object' ? (v as Record) : {}; + } catch { + return {}; + } +} diff --git a/worker/src/platform/engine/run.ts b/worker/src/platform/engine/run.ts index ccef9cf..799e1e7 100644 --- a/worker/src/platform/engine/run.ts +++ b/worker/src/platform/engine/run.ts @@ -1,25 +1,28 @@ -// Automation orchestrator. Runs an automation's ordered action list, records the -// outcome on the row + audit log, and supports event chaining with a depth guard. +// Automation orchestrator. Builds the automation's flow graph, walks it from the +// trigger node running each action via the kind→handler registry, and records the +// outcome on the row + audit log. DAG traversal: each node runs once, condition +// ports gate which edges are followed, and a step cap bounds runaway graphs. // // Callable from anywhere: the ProjectDO hot path, the cron scheduled handler, the -// manual-run endpoint, and the /v1/events endpoint. `set_variable` differs by -// caller (DO binds addControl; the worker routes to the PROJECT_DO stub), so it's -// injectable via deps. +// manual-run endpoint, and /v1/events. `set_variable` differs by caller (DO binds +// addControl; the worker routes to the PROJECT_DO stub), so it's injectable via deps. +import { VALID_ACTION_KINDS } from '@nodrix/blocks-shared'; import type { Env } from '../../env'; import { newId } from '../lib/ids'; import { recordAudit } from '../lib/audit'; import { projectStub } from '../durable-objects/stubs'; -import { executeIntegration, recordIntegrationRun } from './integrations'; -import type { Action, AutomationContext, AutomationRow, IntegrationRow, RunResult } from './types'; +import { runActionNode, type ActionDeps } from './actions'; +import { toGraph, entryNode, nodesById, outgoingEdges, countActionNodes } from './graph'; +import type { AutomationContext, AutomationRow, RunResult, RunStatus } from './types'; -const MAX_DEPTH = 5; +const MAX_STEPS = 100; export type RunDeps = { // Enqueue a control write. Defaults to the PROJECT_DO stub path. - setVariable?: (variable: string, value: unknown) => Promise; + setVariable?: ActionDeps['setVariable']; // Re-entry for emit_event. Defaults to dispatchEvent over D1. - emitEvent?: (event: string, payload: Record | undefined, ctx: AutomationContext) => Promise; + emitEvent?: ActionDeps['emitEvent']; }; export async function runAutomation( @@ -28,19 +31,53 @@ export async function runAutomation( ctx: AutomationContext, deps: RunDeps = {} ): Promise { - const actions = parseActions(automation.actions); - const setVariable = deps.setVariable ?? defaultSetVariable(env, automation.project_id); - const emitEvent = deps.emitEvent ?? defaultEmitEvent(env); + const graph = toGraph(automation); + const entry = entryNode(graph); + + // Trigger cooldown: suppress re-fires within the window, measured from the last + // real run. Skipped silently (no last_run_at write) so the window isn't reset, + // and never applied to manual runs. + const cooldown = Number((entry?.config as { cooldown_seconds?: unknown })?.cooldown_seconds ?? 0); + if (ctx.source !== 'manual' && cooldown > 0 && automation.last_run_at != null + && ctx.ts - automation.last_run_at < cooldown) { + return { status: 'skipped', actionsRun: 0 }; + } + + const actionDeps: ActionDeps = { + setVariable: deps.setVariable ?? defaultSetVariable(env, automation.project_id), + emitEvent: deps.emitEvent ?? defaultEmitEvent(env), + }; + const byId = nodesById(graph); let actionsRun = 0; - let status: RunResult['status'] = actions.length === 0 ? 'skipped' : 'ok'; + let status: RunStatus = countActionNodes(graph) === 0 ? 'skipped' : 'ok'; let error: string | undefined; - try { - for (const action of actions) { - await runAction(env, automation, ctx, action, setVariable, emitEvent); + const visited = new Set(); + let steps = 0; + + const visit = async (id: string): Promise => { + if (visited.has(id)) return; + visited.add(id); + if (++steps > MAX_STEPS) throw new Error('graph step limit exceeded'); + + const node = byId.get(id); + if (!node) return; + + // Action nodes execute; trigger nodes are pass-through entrypoints. Condition + // nodes will additionally gate which output ports are followed. + if (VALID_ACTION_KINDS.has(node.kind)) { + await runActionNode(node.kind, { env, automation, ctx, config: node.config, deps: actionDeps }); actionsRun++; } + + for (const e of outgoingEdges(graph, id)) { + await visit(e.to); + } + }; + + try { + if (entry) await visit(entry.id); } catch (e) { status = 'error'; error = (e as Error).message; @@ -59,36 +96,6 @@ export async function runAutomation( return { status, error, actionsRun }; } -async function runAction( - env: Env, - automation: AutomationRow, - ctx: AutomationContext, - action: Action, - setVariable: NonNullable, - emitEvent: NonNullable -): Promise { - switch (action.type) { - case 'set_variable': - await setVariable(action.variable, action.value); - return; - - case 'call_integration': { - const integration = await loadIntegration(env, automation.project_id, action.integration_id); - if (!integration) throw new Error(`integration ${action.integration_id} not found`); - const res = await executeIntegration(integration, ctx, action.payload); - await recordIntegrationRun(env, integration.id, res).catch(() => {}); - if (res.status === 'error') throw new Error(`integration "${integration.name}": ${res.detail}`); - return; - } - - case 'emit_event': { - if (ctx.depth >= MAX_DEPTH) throw new Error('max event depth exceeded'); - await emitEvent(action.event, action.payload, { ...ctx, depth: ctx.depth + 1 }); - return; - } - } -} - // Loads enabled `event` automations matching `eventName` and runs each. Returns // how many ran. Used by POST /v1/events and by emit_event chaining. export async function dispatchEvent( @@ -127,41 +134,22 @@ export async function dispatchEvent( return ran; } -function defaultSetVariable(env: Env, projectId: string) { - return async (variable: string, value: unknown): Promise => { +function defaultSetVariable(env: Env, projectId: string): ActionDeps['setVariable'] { + return async (variable, value) => { await projectStub(env, projectId).addControl(newId('control'), variable, value); }; } -function defaultEmitEvent(env: Env) { - return async ( - event: string, - payload: Record | undefined, - ctx: AutomationContext - ): Promise => { +function defaultEmitEvent(env: Env): ActionDeps['emitEvent'] { + return async (event, payload, ctx) => { await dispatchEvent(env, ctx.projectId, event, payload, ctx.depth); }; } -async function loadIntegration( - env: Env, - projectId: string, - id: string -): Promise { - return env.DB - .prepare( - `SELECT id, project_id, name, kind, config, enabled - FROM integrations - WHERE id = ? AND project_id = ? AND archived_at IS NULL` - ) - .bind(id, projectId) - .first(); -} - async function recordRun( env: Env, id: string, - status: RunResult['status'], + status: RunStatus, error: string | undefined ): Promise { const now = Math.floor(Date.now() / 1000); @@ -170,16 +158,3 @@ async function recordRun( .bind(now, status, error ?? null, id) .run(); } - -function parseActions(raw: string): Action[] { - let parsed: unknown; - try { parsed = JSON.parse(raw); } catch { return []; } - if (!Array.isArray(parsed)) return []; - return parsed.filter(isAction); -} - -function isAction(a: unknown): a is Action { - if (!a || typeof a !== 'object') return false; - const t = (a as { type?: unknown }).type; - return t === 'set_variable' || t === 'call_integration' || t === 'emit_event'; -} diff --git a/worker/src/platform/engine/types.ts b/worker/src/platform/engine/types.ts index eddc772..0d9ec5a 100644 --- a/worker/src/platform/engine/types.ts +++ b/worker/src/platform/engine/types.ts @@ -10,6 +10,7 @@ export type VariableTriggerConfig = { operator: VariableOperator; value?: number | string | boolean; // omitted for 'changed' mode?: 'edge' | 'always'; // edge (default): fire once on entry + cooldown_seconds?: number; // min seconds between runs; suppresses re-fires }; export type ScheduleTriggerConfig = { @@ -34,6 +35,25 @@ export type Action = | { type: 'call_integration'; integration_id: string; payload?: Record } | { type: 'emit_event'; event: string; payload?: Record }; +// ─── Flow graph (executable shape) ─────────────────────────────────────────── +// An automation runs as a directed acyclic graph: trigger node(s) as entrypoints, +// action nodes executed in edge order, condition nodes (later) gating named ports. +// Phase 2 builds this on-read from the legacy trigger_config/actions columns. + +export type GraphNode = { + id: string; + kind: string; // trigger/action kind from the block catalog + config: Record; +}; + +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[] }; + export type TriggerSource = 'variable' | 'manual' | 'event' | 'schedule' | 'sunset_sunrise'; // Threaded through a run: feeds integration templating/payloads, the audit diff --git a/worker/test/graph.test.ts b/worker/test/graph.test.ts new file mode 100644 index 0000000..2842e94 --- /dev/null +++ b/worker/test/graph.test.ts @@ -0,0 +1,64 @@ +// Unit tests for the convert-on-read shim: the legacy trigger_config/actions row +// must rebuild into the same linear trigger → action chain the executor walks. + +import { expect, test } from 'bun:test'; +import { toGraph, entryNode, countActionNodes, outgoingEdges } from '../src/platform/engine/graph'; +import type { AutomationRow } from '../src/platform/engine/types'; + +function row(over: Partial): AutomationRow { + return { + id: 'au1', project_id: 'p1', name: 'a', enabled: 1, + trigger_type: 'variable', trigger_config: '{}', actions: '[]', last_run_at: null, + ...over, + }; +} + +test('variable trigger + actions → trigger node and ordered chain', () => { + const g = toGraph(row({ + trigger_type: 'variable', + trigger_config: JSON.stringify({ variable: 'temp', operator: '>', value: 30 }), + actions: JSON.stringify([ + { type: 'set_variable', variable: 'fan', value: 1 }, + { type: 'call_integration', integration_id: 'int1' }, + ]), + })); + + expect(g.nodes.map((n) => n.kind)).toEqual(['variable', 'set_variable', 'call_integration']); + expect(entryNode(g)?.kind).toBe('variable'); + expect(countActionNodes(g)).toBe(2); + // chain: trigger → a0 → a1 + expect(g.edges).toEqual([ + { from: 'trigger', to: 'a0', port: 'out' }, + { from: 'a0', to: 'a1', port: 'out' }, + ]); + // trigger config preserved, action `type` stripped into kind + expect(entryNode(g)?.config).toEqual({ variable: 'temp', operator: '>', value: 30 }); + expect(g.nodes[1]?.config).toEqual({ variable: 'fan', value: 1 }); +}); + +test('invalid action kinds are dropped', () => { + const g = toGraph(row({ + actions: JSON.stringify([ + { type: 'set_variable', variable: 'x', value: 1 }, + { type: 'bogus_action', foo: 1 }, + ]), + })); + expect(countActionNodes(g)).toBe(1); + expect(g.nodes.map((n) => n.kind)).toEqual(['variable', 'set_variable']); +}); + +test('scene trigger with no actions → single trigger node, no edges', () => { + const g = toGraph(row({ trigger_type: 'scene', trigger_config: '{}', actions: '[]' })); + expect(g.nodes.map((n) => n.kind)).toEqual(['scene']); + expect(g.edges).toEqual([]); + expect(countActionNodes(g)).toBe(0); +}); + +test('unknown trigger_type → actions form the entry chain', () => { + const g = toGraph(row({ + trigger_type: 'not_a_trigger', + actions: JSON.stringify([{ type: 'emit_event', event: 'boom' }]), + })); + expect(entryNode(g)?.kind).toBe('emit_event'); + expect(outgoingEdges(g, 'a0')).toEqual([]); +}); From 83470311fd5df68f31b9c36a9fe658acd921078a Mon Sep 17 00:00:00 2001 From: Arjun Krishna Date: Mon, 1 Jun 2026 20:41:57 +0530 Subject: [PATCH 3/5] feat: enhance automation triggers with multi-trigger support and refactor scheduling logic --- worker/src/domains/automations/service.ts | 5 +- worker/src/platform/db/migrations.gen.ts | 2 +- .../src/platform/db/migrations/0001_init.sql | 1 + .../platform/durable-objects/project-do.ts | 38 +++++++------ .../platform/durable-objects/scheduler-do.ts | 4 +- worker/src/platform/engine/graph.ts | 6 ++ worker/src/platform/engine/run.ts | 42 +++++++------- worker/src/platform/engine/schedule.ts | 57 ++++++++++--------- worker/src/platform/engine/types.ts | 1 + 9 files changed, 88 insertions(+), 68 deletions(-) diff --git a/worker/src/domains/automations/service.ts b/worker/src/domains/automations/service.ts index 641cf12..920a9bf 100644 --- a/worker/src/domains/automations/service.ts +++ b/worker/src/domains/automations/service.ts @@ -101,14 +101,15 @@ export async function createAutomation( .prepare( `INSERT INTO automations (id, project_id, name, description, enabled, trigger_type, - trigger_config, actions, created_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + trigger_config, actions, trigger_kinds, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( id, projectId, name, input.description ?? null, input.enabled === false ? 0 : 1, input.trigger_type, JSON.stringify(input.trigger_config ?? {}), JSON.stringify(Array.isArray(input.actions) ? input.actions : []), + `,${input.trigger_type},`, actor.userId, now, now ) .run(); diff --git a/worker/src/platform/db/migrations.gen.ts b/worker/src/platform/db/migrations.gen.ts index d647846..7e2f50c 100644 --- a/worker/src/platform/db/migrations.gen.ts +++ b/worker/src/platform/db/migrations.gen.ts @@ -28,7 +28,7 @@ export const MIGRATIONS: Migration[] = [ "CREATE TABLE IF NOT EXISTS dashboards (\n id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n name TEXT NOT NULL,\n description TEXT,\n layout TEXT NOT NULL, \n visibility TEXT NOT NULL DEFAULT 'private'\n CHECK (visibility IN ('private','public')),\n share_token TEXT, \n created_by TEXT REFERENCES users(id),\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n archived_at INTEGER\n)", "CREATE INDEX IF NOT EXISTS idx_dashboards_project ON dashboards(project_id)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_dashboards_share_token\n ON dashboards(share_token) WHERE share_token IS NOT NULL", - "CREATE TABLE IF NOT EXISTS automations (\n id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n name TEXT NOT NULL,\n description TEXT,\n enabled INTEGER NOT NULL DEFAULT 1, \n trigger_type TEXT NOT NULL CHECK (trigger_type IN\n ('variable','scene','schedule','sunset_sunrise','event')),\n trigger_config TEXT NOT NULL, \n actions TEXT NOT NULL, \n created_by TEXT REFERENCES users(id),\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n last_run_at INTEGER,\n last_run_status TEXT CHECK (last_run_status IN ('ok','error','skipped')),\n last_error TEXT\n)", + "CREATE TABLE IF NOT EXISTS automations (\n id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n name TEXT NOT NULL,\n description TEXT,\n enabled INTEGER NOT NULL DEFAULT 1, \n trigger_type TEXT NOT NULL CHECK (trigger_type IN\n ('variable','scene','schedule','sunset_sunrise','event')),\n trigger_config TEXT NOT NULL, \n actions TEXT NOT NULL, \n trigger_kinds TEXT NOT NULL DEFAULT '', \n created_by TEXT REFERENCES users(id),\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n last_run_at INTEGER,\n last_run_status TEXT CHECK (last_run_status IN ('ok','error','skipped')),\n last_error TEXT\n)", "CREATE INDEX IF NOT EXISTS idx_automations_project ON automations(project_id)", "CREATE INDEX IF NOT EXISTS idx_automations_enabled ON automations(enabled) WHERE enabled = 1", "CREATE INDEX IF NOT EXISTS idx_automations_project_enabled_type\n ON automations(project_id, enabled, trigger_type)", diff --git a/worker/src/platform/db/migrations/0001_init.sql b/worker/src/platform/db/migrations/0001_init.sql index bedd7ff..843e8ac 100644 --- a/worker/src/platform/db/migrations/0001_init.sql +++ b/worker/src/platform/db/migrations/0001_init.sql @@ -174,6 +174,7 @@ CREATE TABLE IF NOT EXISTS automations ( ('variable','scene','schedule','sunset_sunrise','event')), trigger_config TEXT NOT NULL, -- JSON actions TEXT NOT NULL, -- JSON ordered list + trigger_kinds TEXT NOT NULL DEFAULT '', -- ",kind,kind," for multi-trigger lookups created_by TEXT REFERENCES users(id), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, diff --git a/worker/src/platform/durable-objects/project-do.ts b/worker/src/platform/durable-objects/project-do.ts index c2d2395..7ccc0be 100644 --- a/worker/src/platform/durable-objects/project-do.ts +++ b/worker/src/platform/durable-objects/project-do.ts @@ -4,6 +4,7 @@ import { newId } from '../lib/ids'; import { dashboardStub } from './stubs'; import { runAutomation } from '../engine/run'; import { matchVariableCondition } from '../engine/triggers'; +import { toGraph, triggerNodes } from '../engine/graph'; import type { AutomationContext, AutomationRow, VariableTriggerConfig } from '../engine/types'; import { toCompactSeries, type CompactSeries } from '../lib/series'; @@ -147,22 +148,25 @@ export class ProjectDO extends DurableObject { this.addControl(newId('control'), variable, value); for (const a of autos) { - let cfg: VariableTriggerConfig; - try { cfg = JSON.parse(a.trigger_config); } catch { continue; } - - const point = points.find((p) => p.variable === cfg.variable); - if (!point) continue; - if (!matchVariableCondition(cfg, point.value, prev.get(cfg.variable))) continue; - - const ctx: AutomationContext = { - source: 'variable', - projectId, - ts, - variable: cfg.variable, - value: point.value, - depth: 0, - }; - await runAutomation(this.env, a, ctx, { setVariable }); + for (const node of triggerNodes(toGraph(a))) { + if (node.kind !== 'variable') continue; + const cfg = node.config as VariableTriggerConfig; + + const point = points.find((p) => p.variable === cfg.variable); + if (!point) continue; + if (!matchVariableCondition(cfg, point.value, prev.get(cfg.variable))) continue; + + const ctx: AutomationContext = { + source: 'variable', + projectId, + ts, + variable: cfg.variable, + value: point.value, + depth: 0, + entryNodeId: node.id, + }; + await runAutomation(this.env, a, ctx, { setVariable }); + } } } catch (e) { console.error('variable-trigger eval failed', e); @@ -193,7 +197,7 @@ export class ProjectDO extends DurableObject { .prepare( `SELECT id, project_id, name, enabled, trigger_type, trigger_config, actions, last_run_at FROM automations - WHERE project_id = ? AND enabled = 1 AND trigger_type = 'variable'` + WHERE project_id = ? AND enabled = 1 AND trigger_kinds LIKE '%,variable,%'` ) .bind(projectId) .all(); diff --git a/worker/src/platform/durable-objects/scheduler-do.ts b/worker/src/platform/durable-objects/scheduler-do.ts index 08c1a3f..b5afaba 100644 --- a/worker/src/platform/durable-objects/scheduler-do.ts +++ b/worker/src/platform/durable-objects/scheduler-do.ts @@ -1,6 +1,6 @@ import { DurableObject } from 'cloudflare:workers'; import type { Env } from '../../env'; -import { computeNextScheduled, runScheduledByIds, type SchedulePlan } from '../engine/schedule'; +import { computeNextScheduled, runScheduledDue, type SchedulePlan } from '../engine/schedule'; // Singleton scheduler. Holds ONE alarm set to the next schedule/sunset fire time; // on wake it runs the planned automations and re-arms. Replaces the every-minute @@ -35,7 +35,7 @@ export class SchedulerDO extends DurableObject { const plan = await this.ctx.storage.get('plan'); if (plan?.fireAt != null) { try { - await runScheduledByIds(this.env, plan.dueIds, Math.floor(plan.fireAt / 1000)); + await runScheduledDue(this.env, plan.due, Math.floor(plan.fireAt / 1000)); } catch (e) { console.error('[scheduler] run failed', e); } diff --git a/worker/src/platform/engine/graph.ts b/worker/src/platform/engine/graph.ts index e701399..01d6673 100644 --- a/worker/src/platform/engine/graph.ts +++ b/worker/src/platform/engine/graph.ts @@ -5,6 +5,12 @@ import { VALID_TRIGGER_KINDS, VALID_ACTION_KINDS } from '@nodrix/blocks-shared'; import type { AutomationGraph, GraphEdge, GraphNode, AutomationRow } from './types'; +// Trigger entrypoint nodes (≥1 ⇒ multi-trigger). The hot path / scheduler / event +// dispatch iterate these and enter the executor at the matched node. +export function triggerNodes(graph: AutomationGraph): GraphNode[] { + return graph.nodes.filter((n) => VALID_TRIGGER_KINDS.has(n.kind)); +} + const TRIGGER_NODE_ID = 'trigger'; export function toGraph(row: AutomationRow): AutomationGraph { diff --git a/worker/src/platform/engine/run.ts b/worker/src/platform/engine/run.ts index 799e1e7..a0e404d 100644 --- a/worker/src/platform/engine/run.ts +++ b/worker/src/platform/engine/run.ts @@ -13,7 +13,7 @@ import { newId } from '../lib/ids'; import { recordAudit } from '../lib/audit'; import { projectStub } from '../durable-objects/stubs'; import { runActionNode, type ActionDeps } from './actions'; -import { toGraph, entryNode, nodesById, outgoingEdges, countActionNodes } from './graph'; +import { toGraph, entryNode, nodesById, outgoingEdges, countActionNodes, triggerNodes } from './graph'; import type { AutomationContext, AutomationRow, RunResult, RunStatus } from './types'; const MAX_STEPS = 100; @@ -32,12 +32,14 @@ export async function runAutomation( deps: RunDeps = {} ): Promise { const graph = toGraph(automation); - const entry = entryNode(graph); + const byId = nodesById(graph); + // Enter at the trigger that fired (multi-trigger); fall back to the graph entry. + const start = (ctx.entryNodeId ? byId.get(ctx.entryNodeId) : undefined) ?? entryNode(graph); // Trigger cooldown: suppress re-fires within the window, measured from the last // real run. Skipped silently (no last_run_at write) so the window isn't reset, // and never applied to manual runs. - const cooldown = Number((entry?.config as { cooldown_seconds?: unknown })?.cooldown_seconds ?? 0); + const cooldown = Number((start?.config as { cooldown_seconds?: unknown })?.cooldown_seconds ?? 0); if (ctx.source !== 'manual' && cooldown > 0 && automation.last_run_at != null && ctx.ts - automation.last_run_at < cooldown) { return { status: 'skipped', actionsRun: 0 }; @@ -47,7 +49,6 @@ export async function runAutomation( setVariable: deps.setVariable ?? defaultSetVariable(env, automation.project_id), emitEvent: deps.emitEvent ?? defaultEmitEvent(env), }; - const byId = nodesById(graph); let actionsRun = 0; let status: RunStatus = countActionNodes(graph) === 0 ? 'skipped' : 'ok'; @@ -77,7 +78,7 @@ export async function runAutomation( }; try { - if (entry) await visit(entry.id); + if (start) await visit(start.id); } catch (e) { status = 'error'; error = (e as Error).message; @@ -109,27 +110,28 @@ export async function dispatchEvent( .prepare( `SELECT id, project_id, name, enabled, trigger_type, trigger_config, actions, last_run_at FROM automations - WHERE project_id = ? AND enabled = 1 AND trigger_type = 'event'` + WHERE project_id = ? AND enabled = 1 AND trigger_kinds LIKE '%,event,%'` ) .bind(projectId) .all(); let ran = 0; for (const a of rows.results) { - let cfg: { event?: string }; - try { cfg = JSON.parse(a.trigger_config); } catch { cfg = {}; } - if (cfg.event !== eventName) continue; - - const ctx: AutomationContext = { - source: 'event', - projectId, - ts: Math.floor(Date.now() / 1000), - event: eventName, - payload, - depth, - }; - await runAutomation(env, a, ctx, { emitEvent: defaultEmitEvent(env) }); - ran++; + for (const node of triggerNodes(toGraph(a))) { + if (node.kind !== 'event' || (node.config as { event?: string }).event !== eventName) continue; + + const ctx: AutomationContext = { + source: 'event', + projectId, + ts: Math.floor(Date.now() / 1000), + event: eventName, + payload, + depth, + entryNodeId: node.id, + }; + await runAutomation(env, a, ctx, { emitEvent: defaultEmitEvent(env) }); + ran++; + } } return ran; } diff --git a/worker/src/platform/engine/schedule.ts b/worker/src/platform/engine/schedule.ts index 523c8a5..9b0d640 100644 --- a/worker/src/platform/engine/schedule.ts +++ b/worker/src/platform/engine/schedule.ts @@ -2,8 +2,8 @@ // sunset/sunrise automations, and run a planned set when due. // // The DO alarm fires at `computeNextScheduled().fireAt`, then runs exactly the -// `dueIds` planned for that instant — so it's robust to wake-up drift (we fire -// the ids that were due at the planned time, not whatever the clock says). +// `due` (automation, node) pairs planned for that instant — so it's robust to +// wake-up drift (we fire what was due at the planned time, not the clock). import type { Env } from '../../env'; import type { @@ -14,49 +14,52 @@ import type { } from './types'; import { sunTimes } from './solar'; import { runAutomation } from './run'; +import { toGraph, triggerNodes, nodesById } from './graph'; -export type SchedulePlan = { fireAt: number | null; dueIds: string[] }; +// A due fire is a specific schedule/solar trigger node within an automation. +export type ScheduleDue = { id: string; nodeId: string }; +export type SchedulePlan = { fireAt: number | null; due: ScheduleDue[] }; -// Soonest upcoming fire across all enabled schedule/solar automations, plus the -// ids that share that instant. +// Soonest upcoming fire across every schedule/solar trigger node of all enabled +// automations, plus the (automation, node) pairs that share that instant. export async function computeNextScheduled(env: Env): Promise { const rows = await env.DB .prepare( `SELECT id, project_id, name, enabled, trigger_type, trigger_config, actions, last_run_at FROM automations - WHERE enabled = 1 AND trigger_type IN ('schedule', 'sunset_sunrise')` + WHERE enabled = 1 AND (trigger_kinds LIKE '%,schedule,%' OR trigger_kinds LIKE '%,sunset_sunrise,%')` ) .all(); const now = Date.now(); let best: number | null = null; - const fireAtById = new Map(); + const entries: { id: string; nodeId: string; fireAt: number }[] = []; for (const a of rows.results) { - let next: number | null = null; - try { - next = a.trigger_type === 'schedule' - ? nextScheduleFireAt(JSON.parse(a.trigger_config) as ScheduleTriggerConfig, now) - : nextSolarFireAt(JSON.parse(a.trigger_config) as SolarTriggerConfig, now); - } catch { - next = null; + for (const node of triggerNodes(toGraph(a))) { + let next: number | null = null; + try { + if (node.kind === 'schedule') next = nextScheduleFireAt(node.config as ScheduleTriggerConfig, now); + else if (node.kind === 'sunset_sunrise') next = nextSolarFireAt(node.config as SolarTriggerConfig, now); + } catch { + next = null; + } + if (next == null) continue; + entries.push({ id: a.id, nodeId: node.id, fireAt: next }); + if (best == null || next < best) best = next; } - if (next == null) continue; - fireAtById.set(a.id, next); - if (best == null || next < best) best = next; } - if (best == null) return { fireAt: null, dueIds: [] }; - const dueIds: string[] = []; - for (const [id, t] of fireAtById) if (t - best < 1000) dueIds.push(id); - return { fireAt: best, dueIds }; + if (best == null) return { fireAt: null, due: [] }; + const due = entries.filter((e) => e.fireAt - best! < 1000).map(({ id, nodeId }) => ({ id, nodeId })); + return { fireAt: best, due }; } -// Run the planned automations. `fireAtSec` dedups against last_run_at so a -// duplicate scheduler instance (or replay) can't double-fire the same instant. -export async function runScheduledByIds(env: Env, ids: string[], fireAtSec: number): Promise { +// Run the planned fires. `fireAtSec` dedups against last_run_at so a duplicate +// scheduler instance (or replay) can't double-fire the same instant. +export async function runScheduledDue(env: Env, due: ScheduleDue[], fireAtSec: number): Promise { let ran = 0; - for (const id of ids) { + for (const { id, nodeId } of due) { const a = await env.DB .prepare( `SELECT id, project_id, name, enabled, trigger_type, trigger_config, actions, last_run_at @@ -67,11 +70,13 @@ export async function runScheduledByIds(env: Env, ids: string[], fireAtSec: numb if (!a) continue; if (a.last_run_at != null && a.last_run_at >= fireAtSec) continue; // already fired this instant + const node = nodesById(toGraph(a)).get(nodeId); const ctx: AutomationContext = { - source: a.trigger_type === 'schedule' ? 'schedule' : 'sunset_sunrise', + source: node?.kind === 'sunset_sunrise' ? 'sunset_sunrise' : 'schedule', projectId: a.project_id, ts: Math.floor(Date.now() / 1000), depth: 0, + entryNodeId: nodeId, }; try { await runAutomation(env, a, ctx); diff --git a/worker/src/platform/engine/types.ts b/worker/src/platform/engine/types.ts index 0d9ec5a..f2625d3 100644 --- a/worker/src/platform/engine/types.ts +++ b/worker/src/platform/engine/types.ts @@ -67,6 +67,7 @@ export type AutomationContext = { event?: string; payload?: Record; depth: number; // emit_event recursion depth + entryNodeId?: string; // trigger node that fired; defaults to the graph entry }; export type RunStatus = 'ok' | 'error' | 'skipped'; From cd782587c8a97a5fcaefb96305b0fda9c1631b55 Mon Sep 17 00:00:00 2001 From: Arjun Krishna Date: Mon, 1 Jun 2026 21:07:21 +0530 Subject: [PATCH 4/5] feat(automations): introduce BlockNode and NodeInspector components for automation graph editing - Added BlockNode.vue to represent individual nodes in the automation graph with visual handles for connections. - Implemented NodeInspector.vue for detailed configuration of selected nodes, supporting various input types including dropdowns and text areas. - Updated automation-catalog.ts to reflect changes in trigger types, renaming 'scene' to 'manual'. - Created graph-edit.ts for managing conversions between automation graphs and Vue Flow structures. - Enhanced project store to support graph data in automation creation and updates. - Modified types.ts to include AutomationGraph type for better type safety. - Developed graph-build.ts for server-side graph handling, ensuring backward compatibility with legacy trigger configurations. - Updated routes and service layers to accommodate new graph structure in automation creation and updates. - Adjusted database migrations to include graph column in automations table. - Refactored engine logic to prioritize graph column over legacy trigger/action columns for automation execution. - Updated tests to validate new manual trigger behavior and ensure proper graph handling. --- shared/blocks/graph.ts | 125 +++++ shared/blocks/index.ts | 6 + shared/blocks/triggers.ts | 11 +- web/package.json | 1 + .../project/automations/AutomationCard.vue | 18 +- .../project/automations/AutomationEditor.vue | 448 +++++------------- .../pages/project/automations/BlockNode.vue | 56 +++ .../project/automations/NodeInspector.vue | 122 +++++ .../project/automations/automation-catalog.ts | 4 +- .../pages/project/automations/graph-edit.ts | 83 ++++ web/src/stores/project.ts | 6 +- web/src/types.ts | 13 +- worker/src/domains/automations/graph-build.ts | 31 ++ worker/src/domains/automations/routes.ts | 12 +- worker/src/domains/automations/service.ts | 88 +++- worker/src/mcp/tools-write.ts | 6 +- worker/src/platform/db/migrations.gen.ts | 2 +- .../src/platform/db/migrations/0001_init.sql | 7 +- worker/src/platform/engine/graph.ts | 83 ++-- worker/src/platform/engine/types.ts | 22 +- worker/test/graph.test.ts | 6 +- 21 files changed, 704 insertions(+), 446 deletions(-) create mode 100644 shared/blocks/graph.ts create mode 100644 web/src/pages/project/automations/BlockNode.vue create mode 100644 web/src/pages/project/automations/NodeInspector.vue create mode 100644 web/src/pages/project/automations/graph-edit.ts create mode 100644 worker/src/domains/automations/graph-build.ts 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 index 1f47f86..986ab3f 100644 --- a/shared/blocks/index.ts +++ b/shared/blocks/index.ts @@ -8,6 +8,7 @@ import { ACTION_CATALOG } from './actions'; export { TRIGGER_CATALOG } from './triggers'; export { ACTION_CATALOG } from './actions'; +export * from './graph'; // ─── Manifest types ───────────────────────────────────────────────────────── @@ -89,3 +90,8 @@ export function blockSpec(category: BlockCategory, kind: string): BlockManifest 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/triggers.ts b/shared/blocks/triggers.ts index c8fc6f7..b5a1a3a 100644 --- a/shared/blocks/triggers.ts +++ b/shared/blocks/triggers.ts @@ -29,12 +29,19 @@ export const TRIGGER_CATALOG = [ 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: 'scene', + kind: 'manual', category: 'trigger', - label: 'Scene', + 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, diff --git a/web/package.json b/web/package.json index 7f8ac79..88243f0 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "@nodrix/blocks-shared": "*", "@nodrix/integrations-shared": "*", "@nodrix/widgets-shared": "*", + "@vue-flow/core": "^1.48.2", "better-auth": "^1.6.11", "grid-layout-plus": "^1.1.1", "pinia": "^2.3.0", 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 @@