feat(automations): Automation Engine — Phase 1a (#3653)#3668
Conversation
…ine (#3653) Foundation for the user-creatable, global Automation Engine ("Advanced Mode"). This slice is the data layer only — engine, routes, and UI follow. - Migration 098: create `automations` (global, no sourceId) + `automation_runs` (execution log now, stateful run store in Phase 1b) across SQLite/PG/MySQL. - Drizzle schema + activeSchema/index wiring for both tables. - AutomationsRepository: CRUD, enabled-only load, run-log create/update/list, list-by-status + cancel-active-runs (for the Phase 1b stateful engine). - Wire repository into DatabaseService; export from the repo barrel. - Document the global-by-design exception in CLAUDE.md and add the full design/plan doc (AUTOMATION_ENGINE_PLAN.md). Tests: 11 repository tests (real in-memory SQLite) + migration registry count/sequence updated to 98. All green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
Adds the variable registry behind the Automation Engine: define reusable
values once, reference them as {{ var.<name> }} in conditions/actions.
- Migration 099: create `automation_variables` (global definitions:
name/type/scope/readonly/config) + `automation_variable_values` (per-scope
values keyed by scopeKey, with `expiresAt` for flag auto-clear) across
SQLite/PG/MySQL. Count test 98 -> 99.
- Drizzle schema + activeSchema/index wiring.
- AutomationVariablesRepository: definitions CRUD; buildScopeKey() for the four
scopes (global/source/node/sourceNode); scoped value upsert/clear;
getEffectiveValue() applies flag TTL (expired reads as absent); pruneExpired()
sweep. Type-agnostic at the repo layer — encoding/duration live in the engine.
- Wire into DatabaseService + repo barrel.
Types: string|integer|float|boolean|flag. `readonly` marks user-set constants
(thresholds) the engine may read but not write; flags auto-clear after a
duration (anti-spam primitive).
Tests: 10 repository tests (real in-memory SQLite). All green (30 total across
the automation data layer).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
Adds the contract shared by the backend engine/routes and the frontend builder.
- src/types/automation.ts: canonical block-type catalog (trigger/condition/
action/flow unions + category helpers), the { version, nodes[], edges[] } graph
shape, and validateAutomationGraph() — a dependency-free validator that returns
structured per-problem errors. Enforces: well-formed shape, unique/known nodes,
exactly one trigger, valid edges (no dangling refs/self-loops), true/false
ports only on condition edges, no trigger incoming edges, acyclic (DAG), no
orphans, plus light per-block param checks.
- Canonical VariableType/VariableScope live here now; the variables repository
re-exports them to avoid divergence.
- Plan doc: record the hand-written-validator decision (no Zod dependency).
Tests: 14 validator/category tests (happy path incl. If/ElseIf/Else routing +
every rejection case). All green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
Pure, DB-free engine internals (new src/server/services/automation/):
- variableCodec.ts: parseVarConfig (tolerant JSON), encodeValue/decodeValue
(typed <-> stored string for string/integer/float/boolean/flag, rejecting
non-representable values), and flagExpiry (now + flagDurationSeconds, null when
no positive duration). Keeps the repository type-agnostic.
- interpolate.ts: interpolate(template, lookup) replaces {{ path }} tokens via a
caller-supplied lookup (trigger.*/var.*/system vars resolved by the engine);
unknown paths render empty and a throwing lookup never breaks output.
extractPaths() lists referenced tokens.
Tests: 15 (codec round-trips + flag expiry; interpolation incl. unknown/throwing
lookups). All green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
The heart of the engine — a topological-activation walk over a validated DAG, decoupled from mesh IO via injected hooks (evaluateCondition/executeAction/ applySetVar) so all routing logic is unit-tested without a node connection. - Uniform activation model handles If/ElseIf/Else routing (true/false ports, unported = gate), fanout (multi-out), and collapse (ANY/ALL/NONE join) in one topological pass. - Action + setVar errors are caught and recorded — a failure never aborts the run. maxActions guard caps executions per run (loop/spam backstop). - Produces a structured result: activated nodes, condition results, action outcomes, and an ordered step log (for the run-log). Tests: 12 — linear, If/Else both branches, gate, ElseIf cascade, fanout, collapse ANY/ALL/NONE, action-error isolation, maxActions guard, setVar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…3653, §5.1) Pure helpers turning mesh event payloads into the trigger.* field map and the subject node for variable scope binding: - buildMessageContext (DbMessage → §5.1 fields incl. derived hops=hopStart-hopLimit, isDM/isBroadcast vs 0xFFFFFFFF, snr/rssi), buildNodeContext (changed keys), buildTelemetryContext (per reading), buildSystemContext (bootup/connect). - messageMatchesFilter: the tight portnum/from/to/channel/textContains/regex fast-fail the engine applies before graph evaluation (invalid regex = no match). - resolveTriggerPath: resolves {{ trigger.* }} + system vars (NOW, sourceId, timestamp) for interpolation. Tests: 12 — hop derivation, field mapping, broadcast flags, each context builder, filter matching, path resolution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…3653, §5.2) The engine's typed read/write API over user-defined variables: - getValue: resolves by name, keys from context (subject node/source), applies flag TTL, falls back to the configured default when unstored. Unknown var or un-resolvable scope → null. - setValue: rejects readonly constants and non-representable values; for flags a truthy value arms with TTL, falsy clears. - setFlag/clearFlag conveniences; increment for integer/float counters (seeds 0, rejects non-numeric types). Tests: 11 against real in-memory SQLite — default fallback, readonly rejection, type-representability, per-node + sourceNode scope isolation, missing-context error, flag arm/expire/clear, counter increment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
… §5)
- engineContext.ts: EngineEvalContext (the Ctx threaded through the graph
evaluator hooks) bundling trigger fields + variable resolver + scope context +
clock. Async {{ }} interpolation (interpolateAsync/resolveOperand) that
pre-resolves every referenced path since var.* needs a DB read; resolveField
for condition field refs.
- conditionEvaluator.ts: evaluates every condition.* type → boolean, never
throws. sourceFilter, numeric (with {{ var }} thresholds), string
(contains/regex/...), variable (is-set + comparison), distance (haversine vs
coords), timeRange (incl. overnight wrap), logical (AND/OR/NOT recursion).
Tests: 8 condition suites (incl. var-backed thresholds against real SQLite).
Full automation core now 101 tests, all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
Turns action.* nodes into concrete IO calls, with all mesh IO behind an
ActionDeps interface so routing/interpolation is tested without a live node:
- action.sendMessage: interpolated text, broadcast (trigger channel) or DM (to
an explicit node), optional reply-to-trigger.
- action.tapback: emoji reaction, replyId defaults to the triggering packet,
routed the way the trigger arrived (DM→DM, channel→channel) — mirrors auto-ack.
- action.nodeManage: favorite/unfavorite/ignore/unignore/delete on the subject
(or explicit) node, with op validation.
- action.notify: interpolated title/body + type for Apprise/webhook.
- Target source = explicit param override else the trigger's source.
Also exposes the triggering packetId in the message context (parsed from the
load-bearing `${sourceId}_${from}_${packetId}` row id) for tapback replyIds.
Tests: 8 executor (fake deps) + 12 trigger-context. All green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
Composes the whole pipeline: load enabled automations + validate their graphs, index by trigger type, and on each event build the trigger context, fast-fail on the trigger pre-filter, enforce a per-automation cooldown, evaluate the graph, and write a run-log row (completed/failed). Mesh IO injected via ActionDeps; the clock is injectable for cooldown/flag tests. flow.setVar handling (set/clear/ flag/increment) lives here. Phase 1a is synchronous (no waiting status). Event entry points: onMessage / onNode / onTelemetry / onSystem (telemetry pre-filters by telemetryType). Tests: 5 end-to-end against real SQLite + fake deps — ping→tapback with run-log, pre-filter miss, cooldown window, the welcome-once per-node flag anti-spam pattern, and invalid-config skip-on-load. Full core: 114 tests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…rver wiring (#3653) Makes the engine live and controllable via API: - Global `automations` permission resource (distinct from the legacy per-source `automation`; NOT in SOURCEY_RESOURCES; admin default on). - meshActionDeps: real ActionDeps wired to MeshtasticManager (sendTextMessage for messages/tapbacks, sendFavoriteNode/sendRemoveFavoriteNode/sendIgnoredNode/ sendRemoveIgnoredNode, deleteNodeAsync). notify throws for now (no clean per-source Apprise entry point) → records a failed step, not a crash. - automationEngineSingleton: process-wide engine, reloadAutomations() for routes, and dataEventEmitter subscription (message:new / node:updated / telemetry:batch / connection:status → onMessage/onNode/onTelemetry/onSystem), fully guarded. - /api/automations routes: catalog, variables CRUD, automations CRUD + enable/ disable + import (lands disabled) + export + runs. Graph validated via validateAutomationGraph; mutations reload the engine. - server.ts: mount routes + startAutomationEngine() on boot. Backend TS-clean; automation suite still 114 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
Adds the global "Automation Engine" tab and management page: - Register `automations` tab (TabType + VALID_TABS + App.tsx render + Sidebar nav + tab permission gate on the global `automations` resource). - AutomationsPage: list/toggle/delete/export automations; editor with a prefilled ping→tapback template and server-side graph validation surfacing per-problem errors; run-log viewer; and a Variables management area (create/delete with type/scope/readonly/default/flag-duration). The visual node-graph builder is Phase 2; this JSON-config surface is enough to create, enable, and verify real automations end-to-end. Frontend TS-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…rce tab (#3653) Verification in the deployed container revealed the management UI never appeared: it was wired as a per-source tab (App.tsx activeTab + Sidebar Configuration NavItem), but the Automation Engine is a GLOBAL feature (like Map Analysis) and the per-source tab nav doesn't render in the unified/global view. - Add a global route `/automations` (sharedProviders(<AutomationsPage/>)) in main.tsx, mirroring the `/analysis` Map Analysis route. - Add a permission-gated "🤖 Automation Engine" link to the global DashboardSidebar (next to Map Analysis / Analysis & Reports). - Add a "← Dashboard" back control to the page (it now renders full-screen). - Revert the incorrect per-source wiring (App.tsx import/tabPermissions/render block, Sidebar NavItem, TabType + VALID_TABS). Frontend TS-clean (changed files) and Vite-bundles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
Foundation for the IFTTT/Maintainerr-style workflow builder (the plan's linear UI over the graph model): - catalog.ts: UI metadata for every trigger/condition/action — the param fields the builder renders (kind/label/options/help/advanced), plus per-trigger field options for conditions. - compile.ts: compile(form)→linear graph and decompile(graph)→form. Builder model is WHEN(trigger) → IF(AND-chain of conditions) → THEN(sequence of actions); decompile returns null for branched/fanout/non-linear graphs so the page can fall back to the raw-JSON editor for advanced/imported workflows. Tests: 6 (compile validity + blank-param stripping, round-trip, and the null fall-back cases). Green, TS-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
#3653) Addresses UI feedback: crowded/jumbled layout, no help for variable types/scopes, and the missing actual workflow builder. - AutomationBuilder: structured WHEN → IF (AND-chain) → THEN editor driven by the catalog — trigger/condition/action dropdowns with typed param fields, per-trigger field options, variable pickers, add/remove rows. Compiles to the graph model; a raw-JSON "Advanced" toggle remains for branched/imported workflows (auto-used when a graph can't be decompiled to the linear form). - AutomationsPage.css: full Catppuccin-themed stylesheet (cards, tabs, buttons, form fields, WHEN/IF/THEN sections, help drawer) — fixes the crowded layout. - Variables: a "?" help drawer explaining every type (incl. flag auto-clear) and scope (Global / Per Source / Per Node / Per Source+Node) + constant, with a link to meshmonitor.org for full docs. Frontend TS-clean and Vite-bundles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
Caught in UI testing: a condition saved without field/op because a <select>
shows its first option but fires no onChange until changed, so the params stayed
empty and server validation rejected it ("requires params.field/op"). Seed every
select field's default value when a block is created or its type changes (incl.
the dynamic, trigger-derived condition "field" options).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…er (#3653) Implements the proposed rules + combine model so the form-based builder can express fanout (map) and collapse (reduce) without a free-form canvas: - compile/decompile reworked to WorkflowForm { trigger, rules[], combine }: * multiple RULES → trigger fans out to one branch per rule (each IF→THEN) * optional COMBINE → flow.collapse(ANY/ALL/NONE) joining each rule's tail, then its actions (reduce: run if ANY/ALL/NONE of the rules matched) * a single rule with no combine still compiles to a plain linear chain (no fanout), and decompile recovers both shapes; anything more exotic returns null → JSON/canvas fallback. Backward-compatible with existing linear graphs. - AutomationBuilder: renders the WHEN → RULES (add/remove rule) → FINALLY (ANY/ALL/NONE + actions) form; reusable BlockListEditor for conditions/actions. - AutomationsPage: rules-aware DEFAULT_FORM + validateForm (each rule needs an action unless it only feeds the combine). Tests: 11 compiler tests — linear/fanout/collapse round-trips (incl. ANY/ALL/NONE and condition-only rules) + the null fall-back cases. Green, TS-clean, Vite-builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…nce, source + system triggers (#3653) Conditions can now compare far more than the trigger's own event fields: - Engine: NodeDataProvider hydrates the subject node + its latest telemetry during condition evaluation (injected for testability; real impl reads nodesRepo.getNode + getLatestTelemetryForTypeAsync). Field resolution is async and supports namespaces: * node.* long/short name, role, hopsAway, battery, voltage, position, + calculated node.ageMinutes and node.roleName * telemetry.* latest reading of ANY metric for the subject node * event fields (hops, text, value, …) as before condition.distance now uses the hydrated node position. - Builder: the Number/Text field pickers are grouped <optgroup> selectors (This event / Node / Latest telemetry). New conditions surfaced: "Source is one of…" (condition.sourceFilter, multi-select of sources) and "Distance from a point". New trigger "A system event" (System start / Source online / Source offline); the engine now fires `bootup` on startup. ("Upgrade available" flagged as coming later.) - Source list fetched from /api/sources for the source multi-select. Tests: +6 condition cases (node battery, any-telemetry, node age, longName + roleName, distance-from-node-position). Full automation suite 121 green, TS-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
Adds trigger.geofence: fires when a node crosses a circular region.
- geo.ts: shared haversineKm + pure geofenceFires(prevInside, nowInside, mode)
transition logic (baseline never fires; enter=out→in, exit=in→out, dwell=in→in).
conditionEvaluator now reuses haversineKm.
- Engine: checkGeofences(nodeNum, sourceId) hydrates the node position, computes
inside/outside per geofence automation, tracks per-(automation,node) state, and
fires on the configured transition (honoring cooldown). Refactored the
per-automation evaluation into fireAutomation() shared with runTrigger.
- Wired into the node:updated handler — only runs when latitude/longitude changed.
- triggerContext.buildGeofenceContext (subject node = the moving node, so node.*
conditions work). New trigger type registered in the shared catalog + builder
("A node enters/leaves a region": event + center lat/lon + radius km).
Note: geofence state is in-memory (re-baselines after a restart — first position
update establishes state without firing).
Tests: geo helpers (haversine + all transition cases) + engine enter/exit
scenarios (baseline no-fire, enter once, exit). Automation suite 107 green in
this set; TS-clean; Vite-builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…nt (#3653) action.notify now dispatches through appriseNotificationService.notifyDirect (automation-specific, non-user-filtered): resolves per-source→global→env→bundled Apprise URL, supports optional inline Apprise URL(s) + severity in the builder, and throws on failure so the run logs a failed step. New notifyDirect unit tests. Add the 'upgrade-available' trigger.system event, raised (deduped per version) from /version/check, exposing latestVersion/currentVersion to conditions and interpolation. Fix a latent bug: trigger.system now prefilters on its `event` param so a bootup rule no longer fires on source-connected etc. Register the four global Automation Engine tables in the migrate-db TABLE_ORDER (automations, automation_runs, automation_variables, automation_variable_values) so SQLite→PG/MySQL migration covers them. Add docs/internal/dev-notes/AUTOMATION_ENGINE_SYSTEM_TESTS.md: proposal for a gated simulate endpoint + a no-hardware system-test matrix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…est scripts (#3653) Add POST /api/automations/test (and /:id/test): dry-run a graph against a synthetic trigger event with NO mesh IO, NO Apprise dispatch and NO persistence. Recording ActionDeps return fully-resolved params; a stub NodeDataProvider overlays caller-supplied node facts + latest telemetry on the live DB; a recording VariableResolver reads real values (with overrides) and records writes. Returns the full trace { matched, status, steps, conditionResults, actions (resolvedParams), variableWrites }. Builder gains an in-app "▶ Test" panel (AutomationTester) — trigger-specific input form + optional subject-node facts / variable overrides → renders the execution trace, resolved actions, and simulated variable changes, so authors preview a rule before saving. Add deterministic no-hardware system tests built on the endpoint: tests/automation/{lib.sh,test-triggers.sh,test-conditions.sh}. Unit tests: automationSimulator.test.ts (7). Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
|
Claude finished @Yeraze's task —— View job PR Review: Automation Engine — Phase 1aTasks:
This is a substantial, well-architected feature. Overall code quality is high — the dependency-injected engine, the strong type model, the three-DB migration support, and the comprehensive test coverage all reflect mature engineering. The items below are observations and suggestions, not blockers. Code Quality & ArchitectureStrengths:
Observations:
Potential Bugs
Security Concerns
Performance
Test CoverageStrengths:
Gaps: Minor / Nits
Summary: This is a production-quality Phase 1a implementation. The architecture is sound, the test coverage is thorough, and the design decisions (global automations, source-filter block for scoping, dry-run simulator) are well-reasoned. The most actionable items before a 1.0 stamp would be the double-step bug (#2), the Apprise URL injection via interpolation (#12), the |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
… findings (#3653) CodeQL: - js/regex-injection: compile user-supplied regex with RE2 (compileUserRegex, linear-time, ReDoS-immune) instead of `new RegExp` in the message-trigger pre-filter and the condition.string regex op. - js/remote-property-injection: the graph evaluator keyed conditionResults by config-derived node ids on a plain object; use a Map internally and materialise the return via Object.fromEntries (no prototype-pollution sink). Review: - Graph evaluator pushed two steps when a condition threw — now a single step carrying the error. - action.notify `urls` interpolation restricted to {{ var.* }} (varsOnly) so a mesh-controlled {{ trigger.* }} can't inject a notification target. - Variable-create UNIQUE-violation detection now covers PostgreSQL/MySQL ("duplicate") not just SQLite ("UNIQUE"). - trigger.nodeDiscovered description notes new-vs-updated detection is deferred. Tests: evaluator single-step-on-throw + adversarial-node-id prototype-safety; notify urls security (trigger.* stripped). Automation suite green (107). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
|
Claude finished @Yeraze's task —— View job PR Review: Automation Engine — Phase 1aTasks:
This is a substantial, well-architected feature. The architecture is sound, the CodeQL concerns from the initial review have been resolved in the code, and the test coverage is thorough. Below is my independent review. What's Been Addressed Well (compared to initial concerns)ReDoS (CodeQL alert) — Property injection (CodeQL alert) — Double-step push bug — The previous review flagged a double push on condition error. Reading the actual code at Apprise URL injection — UNIQUE error detection across DBs — Remaining Issues1. case 'node:updated': {
await e.onNode('trigger.nodeUpdated', nodeNum, changed, sourceId);
// trigger.nodeDiscovered is never emitted
}Both
2. const cooldownSeconds = Number((triggerNode.params as any)?.cooldownSeconds ?? 0) || 0;
const want = (a.triggerNode.params as any)?.telemetryType;
3. Unbounded in-memory geofence state ( private geofenceState = new Map<string, boolean>();Keys are 4. case 'condition.logical': {
const subs = Array.isArray(p.conditions) ? (p.conditions as AutomationNode[]) : [];
// ... recursive evaluateCondition(subs[0], ctx)Nested 5. const channel = p.channel != null ? Number(p.channel) : triggerChannel;No validation that 6. No foreign key between Neither the SQLite, PostgreSQL, nor MySQL migration creates an FK constraint between 7. Every create/update/enable/disable/delete rebuilds the entire automation index from a full table scan. For Phase 1a scale this is fine. Worth noting for future work with larger automation sets. 8. const remove = async (a: Automation) => { if (!confirm(...)) return; ...
alert('Exported JSON copied to clipboard.');Browser-native 9.
Test Coverage AssessmentStrong areas:
Gaps:
SummaryThe implementation is production-quality for Phase 1a. The critical security concerns from the initial review (ReDoS, property injection, Apprise URL injection) have all been properly addressed. The most actionable remaining items before a 1.0 stamp:
|
Automation Engine — Phase 1a (#3653)
A generic, global "when this happens, do that" automation system for MeshMonitor — Home Assistant / Node-RED / IFTTT-inspired, replacing hardcoded automations. Applies across all sources (like Map Analysis). Built on a graph data model with an IFTTT/Maintainerr-style linear builder (drag-and-drop canvas deferred to Phase 2).
Design doc:
docs/internal/dev-notes/AUTOMATION_ENGINE_PLAN.md.Screenshots
The builder + in-app Test panel (dry-run: "Trigger matched · completed", no IO, nothing saved)
IFTTT/Maintainerr-style builder — WHEN → RULE (IF/THEN) → optional FINALLY combine
Automations list · Variables help drawer (types & scopes)
Highlights
Engine (server) — event-driven off
dataEventEmitter; per-automation trigger index; topological graph evaluation with condition routing (If/ElseIf/Else), fanout/collapse (map/reduce: ANY/ALL/NONE), cooldown throttle, per-run action cap, and a run-log. All mesh IO is injected (ActionDeps) so the pipeline is fully unit-tested.Triggers — message · node discovered · node updated · telemetry · schedule · system (bootup / source online / source offline / upgrade available) · geofence (enter / exit / dwell).
Conditions — numeric (event field / hydrated node field / latest telemetry, all ops, variable operands) · string (message text, node longName/shortName/roleName, all ops) · source filter · distance from a point · variable / flag · time-of-day.
Actions — tapback · send message (channel/DM, reply-to-trigger, interpolation) · node manage (favorite/ignore/delete) · notify via Apprise.
User-defined variables — types
string/integer/float/boolean/flag(flag = auto-clearing boolean for anti-spam) · scopesglobal/source/node/sourceNode· readonly constants vs dynamic · with an in-UI help drawer.In-app Test / dry-run —
POST /api/automations/test: run a workflow against a synthetic event with no mesh IO, no Apprise dispatch, no persistence. Returns the full trace (trigger match, per-condition verdicts, resolved action params, simulated variable writes). Surfaced as a ▶ Test panel in the builder so authors preview a rule before saving — and reused as the deterministic substrate for no-hardware system tests.Apprise —
action.notifydispatches throughappriseNotificationService.notifyDirect(per-source → global →APPRISE_URL→ bundled:8000), with optional inline Apprise URL(s) + severity.Data model — 4 global tables (
automations,automation_runs,automation_variables,automation_variable_values) across SQLite/PostgreSQL/MySQL (migrations 098/099), registered in the migrate-db CLI table order.Testing
notifyDirect, the engine service (incl. system-event prefilter + geofence + notify-failure), and the simulator.tests/automation/{lib.sh,test-triggers.sh,test-conditions.sh}— 24 live assertions passing against a deployed container.Scope / follow-ups (Phase 1b+)
flow.delay/ stateful waits; visual drag-and-drop canvas; remaining system-test batches (flow/map-reduce, actions w/ Apprise stub, lifecycle, MQTT smoke).🤖 Generated with Claude Code