From e0f14f2ecf210d950615e7954a138f628cbf2c18 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 22 Sep 2026 08:14:03 -0500 Subject: [PATCH] feat: move automations into a first-party plugin --- plugins/ai/backend/tools/executor.ts | 2 +- .../backend}/actions/host-targets.ts | 10 +-- .../automations/backend}/actions/index.ts | 19 ++--- .../automations/backend}/actions/types.ts | 2 +- .../automations/backend}/conditions.ts | 2 +- .../automations/backend}/cron.ts | 0 .../automations/backend}/docker-watcher.ts | 16 ++-- .../automations/backend}/engine.ts | 12 +-- .../automations/backend}/headless-viewer.ts | 2 +- .../automations/backend}/http.ts | 4 +- plugins/automations/backend/index.mjs | 76 +++++++++++++++++++ .../automations/backend}/notify.ts | 2 +- .../automations/backend/routes.ts | 42 ++++++---- .../automations/backend}/scheduler.ts | 8 +- .../automations/backend}/template.ts | 0 .../automations/backend}/triggers.ts | 10 +-- .../frontend}/AutomationsPanel.tsx | 6 +- .../automations/frontend}/automations-api.ts | 0 .../automations/AutomationEditor.tsx | 2 +- .../automations/HostSelectorField.tsx | 0 .../frontend}/automations/StepBlock.tsx | 0 .../frontend}/automations/TriggerCard.tsx | 0 .../frontend}/automations/editor-types.ts | 0 plugins/automations/frontend/index.mjs | 36 +++++++++ plugins/automations/manifest.json | 20 +++++ .../host-metrics/backend/automation-bridge.ts | 2 +- plugins/host-metrics/backend/routes.ts | 2 +- src/backend/database/database.ts | 4 +- .../database/routes/automation-dispatch.ts | 49 ++++++++++++ src/backend/plugins/first-party.ts | 9 +++ src/backend/starter.ts | 20 +++-- .../automations/conditions.test.ts | 2 +- .../{ => plugins}/automations/cron.test.ts | 2 +- .../automations/docker-watcher.test.ts | 2 +- .../{ => plugins}/automations/engine.test.ts | 14 ++-- .../automations/headless-viewer.test.ts | 5 +- .../{ => plugins}/automations/notify.test.ts | 4 +- .../automations/routes.test.ts} | 26 ++----- .../automations/template.test.ts | 2 +- .../automations/triggers.test.ts | 9 ++- .../tests/plugins/bundled-plugin.test.ts | 41 ++++++++++ src/backend/tests/plugins/first-party.test.ts | 24 +++++- src/ui/AppShell.tsx | 2 +- src/ui/features/ai/useMentions.ts | 2 +- src/ui/shell/tabUtils.tsx | 8 +- .../automations}/automations-editor.test.ts | 6 +- tsconfig.plugins.json | 3 +- 47 files changed, 384 insertions(+), 125 deletions(-) rename {src/backend/automations => plugins/automations/backend}/actions/host-targets.ts (84%) rename {src/backend/automations => plugins/automations/backend}/actions/index.ts (94%) rename {src/backend/automations => plugins/automations/backend}/actions/types.ts (96%) rename {src/backend/automations => plugins/automations/backend}/conditions.ts (98%) rename {src/backend/automations => plugins/automations/backend}/cron.ts (100%) rename {src/backend/automations => plugins/automations/backend}/docker-watcher.ts (91%) rename {src/backend/automations => plugins/automations/backend}/engine.ts (96%) rename {src/backend/automations => plugins/automations/backend}/headless-viewer.ts (97%) rename {src/backend/automations => plugins/automations/backend}/http.ts (91%) create mode 100644 plugins/automations/backend/index.mjs rename {src/backend/automations => plugins/automations/backend}/notify.ts (98%) rename src/backend/database/routes/automations.ts => plugins/automations/backend/routes.ts (94%) rename {src/backend/automations => plugins/automations/backend}/scheduler.ts (95%) rename {src/backend/automations => plugins/automations/backend}/template.ts (100%) rename {src/backend/automations => plugins/automations/backend}/triggers.ts (96%) rename {src/ui/sidebar => plugins/automations/frontend}/AutomationsPanel.tsx (99%) rename {src/ui/api => plugins/automations/frontend}/automations-api.ts (100%) rename {src/ui/sidebar => plugins/automations/frontend}/automations/AutomationEditor.tsx (99%) rename {src/ui/sidebar => plugins/automations/frontend}/automations/HostSelectorField.tsx (100%) rename {src/ui/sidebar => plugins/automations/frontend}/automations/StepBlock.tsx (100%) rename {src/ui/sidebar => plugins/automations/frontend}/automations/TriggerCard.tsx (100%) rename {src/ui/sidebar => plugins/automations/frontend}/automations/editor-types.ts (100%) create mode 100644 plugins/automations/frontend/index.mjs create mode 100644 plugins/automations/manifest.json create mode 100644 src/backend/database/routes/automation-dispatch.ts rename src/backend/tests/{ => plugins}/automations/conditions.test.ts (98%) rename src/backend/tests/{ => plugins}/automations/cron.test.ts (98%) rename src/backend/tests/{ => plugins}/automations/docker-watcher.test.ts (98%) rename src/backend/tests/{ => plugins}/automations/engine.test.ts (97%) rename src/backend/tests/{ => plugins}/automations/headless-viewer.test.ts (96%) rename src/backend/tests/{ => plugins}/automations/notify.test.ts (89%) rename src/backend/tests/{database/routes/automations.test.ts => plugins/automations/routes.test.ts} (95%) rename src/backend/tests/{ => plugins}/automations/template.test.ts (97%) rename src/backend/tests/{ => plugins}/automations/triggers.test.ts (97%) rename src/ui/tests/{sidebar => plugins/automations}/automations-editor.test.ts (85%) diff --git a/plugins/ai/backend/tools/executor.ts b/plugins/ai/backend/tools/executor.ts index 735391394..0ec334754 100644 --- a/plugins/ai/backend/tools/executor.ts +++ b/plugins/ai/backend/tools/executor.ts @@ -5,7 +5,7 @@ import { createCurrentHostRepository, createCurrentSnippetRepository, } from "../../../../src/backend/database/repositories/factory.js"; -import { validateDefinition } from "../../../../src/backend/database/routes/automations.js"; +import { validateDefinition } from "../../../automations/backend/routes.js"; import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { diff --git a/src/backend/automations/actions/host-targets.ts b/plugins/automations/backend/actions/host-targets.ts similarity index 84% rename from src/backend/automations/actions/host-targets.ts rename to plugins/automations/backend/actions/host-targets.ts index adb936639..d855b09be 100644 --- a/src/backend/automations/actions/host-targets.ts +++ b/plugins/automations/backend/actions/host-targets.ts @@ -1,7 +1,7 @@ -import type { HostSelector } from "../../../types/automations.js"; -import { resolveHostById } from "../../hosts/host-resolver.js"; -import { createCurrentFleetRepository } from "../../database/repositories/factory.js"; -import { PermissionManager } from "../../utils/permission-manager.js"; +import type { HostSelector } from "../../../../src/types/automations.js"; +import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; +import { createCurrentFleetRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; import type { StepExecutionContext } from "./types.js"; /** A host the caller is allowed to act on. `host` is always resolved. */ @@ -75,7 +75,7 @@ async function fleetHostIds( async function allAccessibleHostIds(userId: string): Promise { try { const { createCurrentHostRepository } = - await import("../../database/repositories/factory.js"); + await import("../../../../src/backend/database/repositories/factory.js"); const hosts = await createCurrentHostRepository().listByUserId(userId); const ids = hosts.map((host) => host.id); const allowed = diff --git a/src/backend/automations/actions/index.ts b/plugins/automations/backend/actions/index.ts similarity index 94% rename from src/backend/automations/actions/index.ts rename to plugins/automations/backend/actions/index.ts index 54fd4279a..d56d8f9b5 100644 --- a/src/backend/automations/actions/index.ts +++ b/plugins/automations/backend/actions/index.ts @@ -1,22 +1,22 @@ import { DEFAULT_STEP_TIMEOUT_MS, type Step, -} from "../../../types/automations.js"; -import { execCommand } from "../../hosts/metrics-shared/common-utils.js"; +} from "../../../../src/types/automations.js"; +import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { execElevated, shellSingleQuote, -} from "../../hosts/metrics-shared/exec-elevated.js"; +} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { createFleetSshFactory, getFleetPoolKey, -} from "../../hosts/ssh-client-factory.js"; -import { withConnection } from "../../hosts/ssh-connection-pool.js"; -import { resolveSnippetCommand } from "../../database/routes/snippets-execution.js"; +} from "../../../../src/backend/hosts/ssh-client-factory.js"; +import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js"; +import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js"; import { createCurrentAlertRepository, createCurrentSnippetRepository, -} from "../../database/repositories/factory.js"; +} from "../../../../src/backend/database/repositories/factory.js"; import { sendAutomationNotification } from "../notify.js"; import { automationFetch } from "../http.js"; import { renderRecord, renderTemplate } from "../template.js"; @@ -247,7 +247,8 @@ async function runTunnel( if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`); try { - const manager = await import("../../hosts/tunnel/manager.js"); + const manager = + await import("../../../../src/backend/hosts/tunnel/manager.js"); const config = manager.tunnelConfigs?.get(name); if (!config) return fail(`Tunnel "${name}" is not configured`); @@ -283,7 +284,7 @@ async function runWol( try { const { sendWakeOnLan, isValidMac } = - await import("../../utils/wake-on-lan.js"); + await import("../../../../src/backend/utils/wake-on-lan.js"); if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`); await sendWakeOnLan(mac); return ok(`Sent magic packet to ${target.name}`); diff --git a/src/backend/automations/actions/types.ts b/plugins/automations/backend/actions/types.ts similarity index 96% rename from src/backend/automations/actions/types.ts rename to plugins/automations/backend/actions/types.ts index b6f325eb3..a0ad32669 100644 --- a/src/backend/automations/actions/types.ts +++ b/plugins/automations/backend/actions/types.ts @@ -1,4 +1,4 @@ -import type { Step } from "../../../types/automations.js"; +import type { Step } from "../../../../src/types/automations.js"; import type { TemplateContext } from "../template.js"; /** What every executor returns. `output` is what later steps can read. */ diff --git a/src/backend/automations/conditions.ts b/plugins/automations/backend/conditions.ts similarity index 98% rename from src/backend/automations/conditions.ts rename to plugins/automations/backend/conditions.ts index 8e1711efd..9e291ae76 100644 --- a/src/backend/automations/conditions.ts +++ b/plugins/automations/backend/conditions.ts @@ -1,4 +1,4 @@ -import type { MetricPath, Operator } from "../../types/automations.js"; +import type { MetricPath, Operator } from "../../../src/types/automations.js"; /** * Operator evaluation and metric extraction. diff --git a/src/backend/automations/cron.ts b/plugins/automations/backend/cron.ts similarity index 100% rename from src/backend/automations/cron.ts rename to plugins/automations/backend/cron.ts diff --git a/src/backend/automations/docker-watcher.ts b/plugins/automations/backend/docker-watcher.ts similarity index 91% rename from src/backend/automations/docker-watcher.ts rename to plugins/automations/backend/docker-watcher.ts index a5cd6816a..1e93d3e33 100644 --- a/src/backend/automations/docker-watcher.ts +++ b/plugins/automations/backend/docker-watcher.ts @@ -1,14 +1,14 @@ -import type { AutomationDefinition } from "../../types/automations.js"; -import { createCurrentAutomationRepository } from "../database/repositories/factory.js"; -import { resolveHostById } from "../hosts/host-resolver.js"; -import { DataCrypto } from "../utils/data-crypto.js"; -import { execCommand } from "../hosts/metrics-shared/common-utils.js"; +import type { AutomationDefinition } from "../../../src/types/automations.js"; +import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; +import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; +import { DataCrypto } from "../../../src/backend/utils/data-crypto.js"; +import { execCommand } from "../../../src/backend/hosts/metrics-shared/common-utils.js"; import { createFleetSshFactory, getFleetPoolKey, -} from "../hosts/ssh-client-factory.js"; -import { withConnection } from "../hosts/ssh-connection-pool.js"; -import { statsLogger } from "../utils/logger.js"; +} from "../../../src/backend/hosts/ssh-client-factory.js"; +import { withConnection } from "../../../src/backend/hosts/ssh-connection-pool.js"; +import { statsLogger } from "../../../src/backend/utils/logger.js"; import { onDockerEvent } from "./triggers.js"; /** diff --git a/src/backend/automations/engine.ts b/plugins/automations/backend/engine.ts similarity index 96% rename from src/backend/automations/engine.ts rename to plugins/automations/backend/engine.ts index 354f90647..925d5db24 100644 --- a/src/backend/automations/engine.ts +++ b/plugins/automations/backend/engine.ts @@ -2,16 +2,16 @@ import type { AutomationDefinition, RunStatus, Step, -} from "../../types/automations.js"; +} from "../../../src/types/automations.js"; import { DEFAULT_MAX_RUN_SECONDS, MAX_AUTOMATION_DEPTH, MAX_STEP_OUTPUT_BYTES, -} from "../../types/automations.js"; -import { createCurrentAutomationRepository } from "../database/repositories/factory.js"; -import { statsLogger } from "../utils/logger.js"; -import { resolveHostById } from "../hosts/host-resolver.js"; -import { notifyAutomationInternalEvent } from "../hosts/automation-events.js"; +} from "../../../src/types/automations.js"; +import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; +import { statsLogger } from "../../../src/backend/utils/logger.js"; +import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; +import { notifyAutomationInternalEvent } from "../../../src/backend/hosts/automation-events.js"; import { executeStep } from "./actions/index.js"; import type { StepExecutionContext, StepResult } from "./actions/types.js"; import { compare } from "./conditions.js"; diff --git a/src/backend/automations/headless-viewer.ts b/plugins/automations/backend/headless-viewer.ts similarity index 97% rename from src/backend/automations/headless-viewer.ts rename to plugins/automations/backend/headless-viewer.ts index 8123aff7a..502d7abc1 100644 --- a/src/backend/automations/headless-viewer.ts +++ b/plugins/automations/backend/headless-viewer.ts @@ -1,4 +1,4 @@ -import { statsLogger } from "../utils/logger.js"; +import { statsLogger } from "../../../src/backend/utils/logger.js"; import { listAutomationWatchedHosts } from "./triggers.js"; /** diff --git a/src/backend/automations/http.ts b/plugins/automations/backend/http.ts similarity index 91% rename from src/backend/automations/http.ts rename to plugins/automations/backend/http.ts index 569addf49..ed2a6df4b 100644 --- a/src/backend/automations/http.ts +++ b/plugins/automations/backend/http.ts @@ -1,5 +1,5 @@ -import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js"; -import { readNotificationPrivateAllowlist } from "../utils/notification-egress.js"; +import { safeOutboundFetch } from "../../../src/backend/utils/safe-outbound-fetch.js"; +import { readNotificationPrivateAllowlist } from "../../../src/backend/utils/notification-egress.js"; /** * Outbound HTTP for automation steps and notification channels. diff --git a/plugins/automations/backend/index.mjs b/plugins/automations/backend/index.mjs new file mode 100644 index 000000000..6435c2780 --- /dev/null +++ b/plugins/automations/backend/index.mjs @@ -0,0 +1,76 @@ +/** + * Automations - first-party, in-process plugin. + * + * This plugin runs on the main thread rather than in a worker for the same + * reason ai and fleets do (see src/backend/plugins/first-party.ts): its + * engine and scheduler write across hosts, snippets, fleets, and + * notification-channel repositories through the shared SSH pool via + * resolveHostById and withConnection, and its scheduler needs direct + * repository access plus event-bus subscription that the worker ctx's + * read-only ctx.hosts and single-postMessage ctx.http.route cannot support. + * + * Like fleets, this plugin does not own a live transport (no WebSocket + * server, no dedicated port), so it registers its Express router with the + * always-mounted /automations dispatcher in + * src/backend/database/routes/automation-dispatch.ts instead of getting its + * own port. + * + * routes.ts is what used to be src/backend/database/routes/automations.ts, + * moved here and rewritten from `export default router` into + * startAutomationsService()/stopAutomationsService(), called from + * activate()/deactivate() below. scheduler.ts owns the one setInterval the + * feature has always used for due schedules, dwell rechecks, docker-event + * polling and history pruning -- there is no ctx.schedule API for an + * in-process plugin, so it starts and stops the same way it always has. The + * automations/automationTriggerState/automationSchedules/automationRuns/ + * automationRunSteps/automationChannels tables and their repository stay in + * core (src/backend/database/db/schema.ts, + * src/backend/database/repositories/automation-repository.ts) -- this plugin + * system has no owned-table migration mechanism of its own, and these tables + * carry FKs into core's hosts/users tables. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Loads a module by its path relative to this file's own directory. + * + * The plugin ships beside dist/backend, so in a built server the compiled + * .js for routes.js/scheduler.js sits right next to this .mjs, and core + * modules resolve via ../../../src/backend/... from there. Under + * vitest/dev nothing imports this plugin directly (the loader loads + * in-process plugins with a plain dynamic import, no tsx loader involved), + * so the only layout that needs to resolve here is the built one. + */ +async function loadRelative(relativePath) { + const here = path.dirname(fileURLToPath(import.meta.url)); + const target = path.join(here, `${relativePath}.js`); + return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); +} + +let routesModule = null; +let schedulerModule = null; + +export async function activate(ctx) { + routesModule = await loadRelative("routes"); + routesModule.startAutomationsService(); + + schedulerModule = await loadRelative("scheduler"); + schedulerModule.startAutomationScheduler(); + + ctx.log.info( + "Automations router registered at /automations; scheduler started", + ); +} + +export async function deactivate() { + if (schedulerModule) { + schedulerModule.stopAutomationScheduler(); + schedulerModule = null; + } + if (routesModule) { + routesModule.stopAutomationsService(); + routesModule = null; + } +} diff --git a/src/backend/automations/notify.ts b/plugins/automations/backend/notify.ts similarity index 98% rename from src/backend/automations/notify.ts rename to plugins/automations/backend/notify.ts index 747685c79..2df127966 100644 --- a/src/backend/automations/notify.ts +++ b/plugins/automations/backend/notify.ts @@ -1,4 +1,4 @@ -import { statsLogger } from "../utils/logger.js"; +import { statsLogger } from "../../../src/backend/utils/logger.js"; import { automationFetch } from "./http.js"; import type { TemplateContext } from "./template.js"; diff --git a/src/backend/database/routes/automations.ts b/plugins/automations/backend/routes.ts similarity index 94% rename from src/backend/database/routes/automations.ts rename to plugins/automations/backend/routes.ts index 1aca7ca11..09515657a 100644 --- a/src/backend/database/routes/automations.ts +++ b/plugins/automations/backend/routes.ts @@ -1,30 +1,30 @@ import crypto from "node:crypto"; import express, { type Request, type Response } from "express"; -import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { AuthenticatedRequest } from "../../../src/types/index.js"; import type { AutomationDefinition, Step, Trigger, -} from "../../../types/automations.js"; -import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js"; -import { PermissionManager } from "../../utils/permission-manager.js"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { databaseLogger } from "../../utils/logger.js"; +} from "../../../src/types/automations.js"; +import { AUTOMATION_DEFINITION_VERSION } from "../../../src/types/automations.js"; +import { PermissionManager } from "../../../src/backend/utils/permission-manager.js"; +import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +import { databaseLogger } from "../../../src/backend/utils/logger.js"; import { getAuditUsername, getRequestMeta, logAudit, -} from "../../utils/audit-logger.js"; -import { createCurrentAutomationRepository } from "../repositories/factory.js"; -import type { AutomationRow } from "../repositories/automation-repository.js"; -import { AutomationEngine } from "../../automations/engine.js"; +} from "../../../src/backend/utils/audit-logger.js"; +import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; +import type { AutomationRow } from "../../../src/backend/database/repositories/automation-repository.js"; +import { AutomationEngine } from "./engine.js"; +import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js"; import { - computeNextDueAt, - isValidCron, - isValidTimezone, -} from "../../automations/cron.js"; + registerAutomationsRouter, + unregisterAutomationsRouter, +} from "../../../src/backend/database/routes/automation-dispatch.js"; -const router = express.Router(); +export const router = express.Router(); const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); @@ -819,4 +819,14 @@ function timingSafeEqual(a: string, b: string): boolean { return crypto.timingSafeEqual(left, right); } -export default router; +/** Called from activate(). Mounts this router at /automations via the shared + * dispatcher. */ +export function startAutomationsService(): void { + registerAutomationsRouter(router); +} + +/** Called from deactivate(). /automations/* falls back to 404 until + * reactivated. */ +export function stopAutomationsService(): void { + unregisterAutomationsRouter(); +} diff --git a/src/backend/automations/scheduler.ts b/plugins/automations/backend/scheduler.ts similarity index 95% rename from src/backend/automations/scheduler.ts rename to plugins/automations/backend/scheduler.ts index 6a35c770f..640e24f18 100644 --- a/src/backend/automations/scheduler.ts +++ b/plugins/automations/backend/scheduler.ts @@ -1,7 +1,7 @@ -import type { AutomationDefinition } from "../../types/automations.js"; -import { createCurrentAutomationRepository } from "../database/repositories/factory.js"; -import { DataCrypto } from "../utils/data-crypto.js"; -import { statsLogger } from "../utils/logger.js"; +import type { AutomationDefinition } from "../../../src/types/automations.js"; +import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; +import { DataCrypto } from "../../../src/backend/utils/data-crypto.js"; +import { statsLogger } from "../../../src/backend/utils/logger.js"; import { computeNextDueAt } from "./cron.js"; import { hasDwelled, isCoolingDown } from "./conditions.js"; import { pollDockerEvents } from "./docker-watcher.js"; diff --git a/src/backend/automations/template.ts b/plugins/automations/backend/template.ts similarity index 100% rename from src/backend/automations/template.ts rename to plugins/automations/backend/template.ts diff --git a/src/backend/automations/triggers.ts b/plugins/automations/backend/triggers.ts similarity index 96% rename from src/backend/automations/triggers.ts rename to plugins/automations/backend/triggers.ts index 6df0751a2..44638ad31 100644 --- a/src/backend/automations/triggers.ts +++ b/plugins/automations/backend/triggers.ts @@ -2,10 +2,10 @@ import type { AutomationDefinition, HostSelector, Trigger, -} from "../../types/automations.js"; -import { createCurrentAutomationRepository } from "../database/repositories/factory.js"; -import type { AutomationEngineRow } from "../database/repositories/automation-repository.js"; -import { statsLogger } from "../utils/logger.js"; +} from "../../../src/types/automations.js"; +import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; +import type { AutomationEngineRow } from "../../../src/backend/database/repositories/automation-repository.js"; +import { statsLogger } from "../../../src/backend/utils/logger.js"; import { compare, extractMetricValue, @@ -16,7 +16,7 @@ import { type MetricsSnapshot, } from "./conditions.js"; import { AutomationEngine } from "./engine.js"; -import { pluginEvents, TOPICS } from "../plugins/events.js"; +import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js"; /** * Matches events against automation triggers and decides what fires. diff --git a/src/ui/sidebar/AutomationsPanel.tsx b/plugins/automations/frontend/AutomationsPanel.tsx similarity index 99% rename from src/ui/sidebar/AutomationsPanel.tsx rename to plugins/automations/frontend/AutomationsPanel.tsx index 1374f0427..7ca0395ff 100644 --- a/src/ui/sidebar/AutomationsPanel.tsx +++ b/plugins/automations/frontend/AutomationsPanel.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -25,14 +25,14 @@ import { type AutomationRow, type AutomationRunRow, type AutomationRunStepRow, -} from "@/api/automations-api"; +} from "./automations-api"; import { deleteNotificationChannel, getNotificationChannels, testNotificationChannel, type NotificationChannel, } from "@/api/alerts-api"; -import { NotificationChannelDialog } from "./NotificationChannelDialog"; +import { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog"; import { getSnippets } from "@/api/snippets-api"; import { getSSHHosts } from "@/api/ssh-host-management-api"; import { listFleets } from "@/api/fleets-api"; diff --git a/src/ui/api/automations-api.ts b/plugins/automations/frontend/automations-api.ts similarity index 100% rename from src/ui/api/automations-api.ts rename to plugins/automations/frontend/automations-api.ts diff --git a/src/ui/sidebar/automations/AutomationEditor.tsx b/plugins/automations/frontend/automations/AutomationEditor.tsx similarity index 99% rename from src/ui/sidebar/automations/AutomationEditor.tsx rename to plugins/automations/frontend/automations/AutomationEditor.tsx index e99dcc024..d6715adde 100644 --- a/src/ui/sidebar/automations/AutomationEditor.tsx +++ b/plugins/automations/frontend/automations/AutomationEditor.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Plus } from "lucide-react"; diff --git a/src/ui/sidebar/automations/HostSelectorField.tsx b/plugins/automations/frontend/automations/HostSelectorField.tsx similarity index 100% rename from src/ui/sidebar/automations/HostSelectorField.tsx rename to plugins/automations/frontend/automations/HostSelectorField.tsx diff --git a/src/ui/sidebar/automations/StepBlock.tsx b/plugins/automations/frontend/automations/StepBlock.tsx similarity index 100% rename from src/ui/sidebar/automations/StepBlock.tsx rename to plugins/automations/frontend/automations/StepBlock.tsx diff --git a/src/ui/sidebar/automations/TriggerCard.tsx b/plugins/automations/frontend/automations/TriggerCard.tsx similarity index 100% rename from src/ui/sidebar/automations/TriggerCard.tsx rename to plugins/automations/frontend/automations/TriggerCard.tsx diff --git a/src/ui/sidebar/automations/editor-types.ts b/plugins/automations/frontend/automations/editor-types.ts similarity index 100% rename from src/ui/sidebar/automations/editor-types.ts rename to plugins/automations/frontend/automations/editor-types.ts diff --git a/plugins/automations/frontend/index.mjs b/plugins/automations/frontend/index.mjs new file mode 100644 index 000000000..a80d84ba7 --- /dev/null +++ b/plugins/automations/frontend/index.mjs @@ -0,0 +1,36 @@ +/** + * Frontend half of the automations plugin. + * + * AutomationsPanel is a left-sidebar railView panel (opened from the rail, + * not a Tab), the same shape as FleetsPanel/HostsPanel/AiPanel. None of those + * go through registerRailItem/registerTabComponent either -- per + * src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like this is + * rendered directly by AppShell.tsx rather than registered at runtime, the + * same way ai's AiPanel is. AutomationsPanel is reached today through a + * direct relative import in AppShell.tsx + * (../../plugins/automations/frontend/AutomationsPanel) and in + * src/ui/shell/tabUtils.tsx (for its promotable/dockable tab surface), not + * through register() below. + * + * register()/unregister() still follow the same shape every other plugin's + * frontend entry does, so this is ready the moment a real frontend plugin + * loader exists -- see plugins/ssh-terminal/frontend/index.mjs and + * src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright + * "there is no plugin loader yet". + */ + +export const id = "automations"; +export const railViewId = "automations"; + +export async function register({ registerRailItem, icons }) { + registerRailItem?.({ + id: railViewId, + icon: icons.Workflow, + labelKey: "nav.automations", + separatorAfter: true, + }); +} + +export async function unregister({ unregisterRailItem }) { + unregisterRailItem?.(railViewId); +} diff --git a/plugins/automations/manifest.json b/plugins/automations/manifest.json new file mode 100644 index 000000000..fc0e17ad9 --- /dev/null +++ b/plugins/automations/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "automations", + "name": "Automations", + "version": "1.0.0", + "description": "Trigger-and-action automation engine: schedules, metric/status/docker-event triggers, and multi-step actions across managed hosts.", + "author": { "name": "Termix" }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Infrastructure", + "icon": "Workflow", + "engine": { "termix": ">=2.9.0", "api": "1" }, + "capabilities": { + "backend": true, + "frontend": true, + "electron": true, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["process:transport-owner"], + "sidecars": [] +} diff --git a/plugins/host-metrics/backend/automation-bridge.ts b/plugins/host-metrics/backend/automation-bridge.ts index 53ba78ce5..9a49c05f3 100644 --- a/plugins/host-metrics/backend/automation-bridge.ts +++ b/plugins/host-metrics/backend/automation-bridge.ts @@ -12,7 +12,7 @@ */ import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js"; -import type { MetricsSnapshot } from "../../../src/backend/automations/conditions.js"; +import type { MetricsSnapshot } from "../../automations/backend/conditions.js"; export interface HostMetricsPayload { hostId: number; diff --git a/plugins/host-metrics/backend/routes.ts b/plugins/host-metrics/backend/routes.ts index 8c6294909..91798f32a 100644 --- a/plugins/host-metrics/backend/routes.ts +++ b/plugins/host-metrics/backend/routes.ts @@ -3046,7 +3046,7 @@ app.post("/metrics/connect-totp", async (req, res) => { // Lets automations keep metrics flowing for hosts they watch, through the same // viewer refcount the UI uses rather than around it. -import("../../../src/backend/automations/headless-viewer.js") +import("../../automations/backend/headless-viewer.js") .then(({ setViewerRegistry }) => { setViewerRegistry({ registerViewer: (hostId, sessionId, userId) => diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 88fde2f9c..6354d3f1b 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -33,7 +33,7 @@ import vaultRoutes from "./routes/vault.js"; import secretSourceRoutes from "./routes/secret-sources.js"; import alertRulesRoutes from "./routes/alert-rules-routes.js"; import aiDispatch from "./routes/ai-dispatch.js"; -import automationsRoutes from "./routes/automations.js"; +import automationsDispatch from "./routes/automation-dispatch.js"; import syncRoutes from "./routes/sync.js"; import pluginApiRoutes from "./routes/plugin-api-routes.js"; import pluginRoutes from "./routes/plugins.js"; @@ -1776,7 +1776,7 @@ app.use("/vault", vaultRoutes); app.use("/secret-sources", secretSourceRoutes); // Before the alert routes, which are mounted at the root and would otherwise // have first claim on the path. -app.use("/automations", automationsRoutes); +app.use("/automations", automationsDispatch); app.use("/ai", aiDispatch); app.use("/", alertRulesRoutes); app.use("/sync", syncRoutes); diff --git a/src/backend/database/routes/automation-dispatch.ts b/src/backend/database/routes/automation-dispatch.ts new file mode 100644 index 000000000..ba6d6249a --- /dev/null +++ b/src/backend/database/routes/automation-dispatch.ts @@ -0,0 +1,49 @@ +// Mounted at /automations. Automations ships as a first-party in-process +// plugin (plugins/automations) rather than a route file imported directly +// here, so this module exists to keep /automations/* working at its original +// path without handing the plugin a reference to the shared app instance. The +// plugin runtime registers its router via registerAutomationsRouter when it +// activates, and clears it on deactivate, so a disabled plugin falls back to +// the 404 below. +// +// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards +// to exactly one first-party router (the Automations plugin's own), and every +// route in that router already applies authenticateJWT/requireDataAccess +// itself (including the intentionally unauthenticated webhook route, which +// gates on its own token instead), the same as every other router mounted +// alongside it here (/users, /host, /fleets, ...). /plugin-api gates at the +// mount point because it forwards to arbitrary third-party plugin code that +// cannot be assumed to gate itself; that reasoning does not apply to a single +// first-party router that already does. + +import type { Request, Response, NextFunction, Router } from "express"; +import { databaseLogger } from "../../utils/logger.js"; + +let activeRouter: Router | null = null; + +export function registerAutomationsRouter(router: Router): void { + activeRouter = router; + databaseLogger.info("Registered Automations plugin router", { + operation: "automations_dispatch_register", + }); +} + +export function unregisterAutomationsRouter(): void { + if (!activeRouter) return; + activeRouter = null; + databaseLogger.info("Unregistered Automations plugin router", { + operation: "automations_dispatch_register", + }); +} + +export default function automationsDispatch( + req: Request, + res: Response, + next: NextFunction, +): void { + if (!activeRouter) { + res.status(404).json({ error: "Automations is not available" }); + return; + } + activeRouter(req, res, next); +} diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts index 32d4ff7c8..1d89c9b47 100644 --- a/src/backend/plugins/first-party.ts +++ b/src/backend/plugins/first-party.ts @@ -75,6 +75,14 @@ * reads/writes and a zip archive for its push/pull routes, and buffer * multipart file uploads. None of that fits the worker's single-exec * ctx.ssh or single-postMessage-reply ctx.http.route. + * + * automations is here for the same reason as ai, proxmox and fleets: it does + * not own a transport, but its engine and scheduler write across hosts, + * snippets, fleets and notification-channel repositories through the shared + * SSH pool via resolveHostById and withConnection, and its scheduler needs + * direct repository access plus subscription to the cross-plugin event bus + * (host metrics, status and health-check events) that the worker's read-only + * ctx.hosts and single-postMessage-reply ctx.http.route cannot support. */ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "ssh-terminal", @@ -84,6 +92,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "proxmox", "remote-desktop", "fleets", + "automations", ]); export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; diff --git a/src/backend/starter.ts b/src/backend/starter.ts index bec7570e2..4f877b598 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -299,12 +299,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // ssh-terminal, docker and host-metrics plugins start their own servers, // so disabling any of them stops its WS/HTTP server. See // plugins/ssh-terminal, plugins/docker and plugins/host-metrics. - // AI, Proxmox, Remote Desktop and Fleets are also absent for the same - // reason: each plugin registers its router with its own dispatcher in - // database.ts on activate (/ai, /proxmox, /guacamole, /fleets), so - // disabling any of them makes its routes 404 instead of leaving a dead - // import here. See plugins/ai, plugins/proxmox, plugins/remote-desktop - // and plugins/fleets. + // AI, Proxmox, Remote Desktop, Fleets and Automations are also absent for + // the same reason: each plugin registers its router with its own + // dispatcher in database.ts on activate (/ai, /proxmox, /guacamole, + // /fleets, /automations), so disabling any of them makes its routes 404 + // instead of leaving a dead import here. Automations' scheduler (due + // schedules, dwell rechecks, docker-event polling, history pruning) also + // starts from its own activate() rather than here. See plugins/ai, + // plugins/proxmox, plugins/remote-desktop, plugins/fleets and + // plugins/automations. await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/tmux/index.js"); @@ -323,11 +326,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { }); } - // After metrics, which the automation triggers and headless polling hook into. - const { startAutomationScheduler } = - await import("./automations/scheduler.js"); - startAutomationScheduler(); - // Last, so a plugin's activate() sees a fully wired server. A plugin that // fails to load must not stop the backend, so this never rejects. try { diff --git a/src/backend/tests/automations/conditions.test.ts b/src/backend/tests/plugins/automations/conditions.test.ts similarity index 98% rename from src/backend/tests/automations/conditions.test.ts rename to src/backend/tests/plugins/automations/conditions.test.ts index a054f35bd..9a467b794 100644 --- a/src/backend/tests/automations/conditions.test.ts +++ b/src/backend/tests/plugins/automations/conditions.test.ts @@ -7,7 +7,7 @@ import { metricStateKey, severityForValue, type MetricsSnapshot, -} from "../../automations/conditions.js"; +} from "../../../../../plugins/automations/backend/conditions.js"; const metrics: MetricsSnapshot = { cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] }, diff --git a/src/backend/tests/automations/cron.test.ts b/src/backend/tests/plugins/automations/cron.test.ts similarity index 98% rename from src/backend/tests/automations/cron.test.ts rename to src/backend/tests/plugins/automations/cron.test.ts index 314c6bf5e..89c5c934c 100644 --- a/src/backend/tests/automations/cron.test.ts +++ b/src/backend/tests/plugins/automations/cron.test.ts @@ -5,7 +5,7 @@ import { isValidTimezone, nextCronRun, parseCron, -} from "../../automations/cron.js"; +} from "../../../../../plugins/automations/backend/cron.js"; describe("parseCron", () => { it("rejects anything that is not five fields", () => { diff --git a/src/backend/tests/automations/docker-watcher.test.ts b/src/backend/tests/plugins/automations/docker-watcher.test.ts similarity index 98% rename from src/backend/tests/automations/docker-watcher.test.ts rename to src/backend/tests/plugins/automations/docker-watcher.test.ts index 6cfb1582f..3bcd4208b 100644 --- a/src/backend/tests/automations/docker-watcher.test.ts +++ b/src/backend/tests/plugins/automations/docker-watcher.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { diffContainerStates, parseContainerStates, -} from "../../automations/docker-watcher.js"; +} from "../../../../../plugins/automations/backend/docker-watcher.js"; /** * The polling side needs SSH, so what is tested here is the pure part: turning diff --git a/src/backend/tests/automations/engine.test.ts b/src/backend/tests/plugins/automations/engine.test.ts similarity index 97% rename from src/backend/tests/automations/engine.test.ts rename to src/backend/tests/plugins/automations/engine.test.ts index 85782b774..ff097c346 100644 --- a/src/backend/tests/automations/engine.test.ts +++ b/src/backend/tests/plugins/automations/engine.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AutomationDefinition, Step } from "../../../types/automations.js"; +import type { + AutomationDefinition, + Step, +} from "../../../../types/automations.js"; /** * The engine reaches the database through the repository factory and the @@ -49,20 +52,21 @@ const repository = { const resolveHostById = vi.fn(); -vi.mock("../../hosts/host-resolver.js", () => ({ +vi.mock("../../../hosts/host-resolver.js", () => ({ resolveHostById: (...args: unknown[]) => resolveHostById(...args), })); -vi.mock("../../database/repositories/factory.js", () => ({ +vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentAutomationRepository: () => repository, })); const executeStep = vi.fn(); -vi.mock("../../automations/actions/index.js", () => ({ +vi.mock("../../../../../plugins/automations/backend/actions/index.js", () => ({ executeStep: (...args: unknown[]) => executeStep(...args), })); -const { AutomationEngine } = await import("../../automations/engine.js"); +const { AutomationEngine } = + await import("../../../../../plugins/automations/backend/engine.js"); function defineAutomation( steps: Step[], diff --git a/src/backend/tests/automations/headless-viewer.test.ts b/src/backend/tests/plugins/automations/headless-viewer.test.ts similarity index 96% rename from src/backend/tests/automations/headless-viewer.test.ts rename to src/backend/tests/plugins/automations/headless-viewer.test.ts index fbbcf9bb8..90a34fedd 100644 --- a/src/backend/tests/automations/headless-viewer.test.ts +++ b/src/backend/tests/plugins/automations/headless-viewer.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const listAutomationWatchedHosts = vi.fn(); -vi.mock("../../automations/triggers.js", () => ({ +vi.mock("../../../../../plugins/automations/backend/triggers.js", () => ({ listAutomationWatchedHosts: () => listAutomationWatchedHosts(), })); @@ -10,7 +10,8 @@ const { reconcileHeadlessViewers, releaseHeadlessViewers, setViewerRegistry, -} = await import("../../automations/headless-viewer.js"); +} = + await import("../../../../../plugins/automations/backend/headless-viewer.js"); const registry = { registerViewer: vi.fn(), diff --git a/src/backend/tests/automations/notify.test.ts b/src/backend/tests/plugins/automations/notify.test.ts similarity index 89% rename from src/backend/tests/automations/notify.test.ts rename to src/backend/tests/plugins/automations/notify.test.ts index d481dfd03..c38cdb0e4 100644 --- a/src/backend/tests/automations/notify.test.ts +++ b/src/backend/tests/plugins/automations/notify.test.ts @@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const automationFetch = vi.fn(); -vi.mock("../../automations/http.js", () => ({ +vi.mock("../../../../../plugins/automations/backend/http.js", () => ({ automationFetch: (...args: unknown[]) => automationFetch(...args), })); const { sendAutomationNotification } = - await import("../../automations/notify.js"); + await import("../../../../../plugins/automations/backend/notify.js"); beforeEach(() => { automationFetch.mockReset(); diff --git a/src/backend/tests/database/routes/automations.test.ts b/src/backend/tests/plugins/automations/routes.test.ts similarity index 95% rename from src/backend/tests/database/routes/automations.test.ts rename to src/backend/tests/plugins/automations/routes.test.ts index 635e7cc68..01f09cbe5 100644 --- a/src/backend/tests/database/routes/automations.test.ts +++ b/src/backend/tests/plugins/automations/routes.test.ts @@ -44,10 +44,15 @@ vi.mock("../../../database/repositories/factory.js", () => ({ })); const run = vi.hoisted(() => vi.fn()); -vi.mock("../../../automations/engine.js", () => ({ +vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({ AutomationEngine: { getInstance: () => ({ run }) }, })); +vi.mock("../../../database/routes/automation-dispatch.js", () => ({ + registerAutomationsRouter: vi.fn(), + unregisterAutomationsRouter: vi.fn(), +})); + vi.mock("../../../utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ @@ -80,23 +85,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({ getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), })); -const { default: router } = - await import("../../../database/routes/automations.js"); - -function findHandler(method: string, path: string) { - const stack = (router as unknown as Router).stack as Array<{ - route?: { - path: string; - methods: Record; - stack: Array<{ handle: (req: Request, res: Response) => unknown }>; - }; - }>; - const layer = stack.find( - (l) => l.route?.path === path && l.route?.methods[method], - ); - if (!layer?.route) throw new Error(`No route for ${method} ${path}`); - return layer.route.stack[layer.route.stack.length - 1].handle; -} +const { router } = + await import("../../../../../plugins/automations/backend/routes.js"); /** Runs the whole middleware chain for the route, not just its handler. */ async function invoke( diff --git a/src/backend/tests/automations/template.test.ts b/src/backend/tests/plugins/automations/template.test.ts similarity index 97% rename from src/backend/tests/automations/template.test.ts rename to src/backend/tests/plugins/automations/template.test.ts index 969bfb8ef..08605ec5a 100644 --- a/src/backend/tests/automations/template.test.ts +++ b/src/backend/tests/plugins/automations/template.test.ts @@ -4,7 +4,7 @@ import { redactSecrets, renderRecord, renderTemplate, -} from "../../automations/template.js"; +} from "../../../../../plugins/automations/backend/template.js"; const context = { host: { id: 7, name: "Zeus", ip: "10.0.0.5", username: "root", port: 22 }, diff --git a/src/backend/tests/automations/triggers.test.ts b/src/backend/tests/plugins/automations/triggers.test.ts similarity index 97% rename from src/backend/tests/automations/triggers.test.ts rename to src/backend/tests/plugins/automations/triggers.test.ts index e5beec0da..25470d460 100644 --- a/src/backend/tests/automations/triggers.test.ts +++ b/src/backend/tests/plugins/automations/triggers.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AutomationDefinition, Trigger, -} from "../../../types/automations.js"; +} from "../../../../types/automations.js"; /** * Trigger matching, dwell windows and cooldowns. The repository and the engine @@ -42,16 +42,17 @@ const repository = { }), }; -vi.mock("../../database/repositories/factory.js", () => ({ +vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentAutomationRepository: () => repository, })); const run = vi.fn(async () => ({ runId: 1, status: "success" as const })); -vi.mock("../../automations/engine.js", () => ({ +vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({ AutomationEngine: { getInstance: () => ({ run }) }, })); -const triggers = await import("../../automations/triggers.js"); +const triggers = + await import("../../../../../plugins/automations/backend/triggers.js"); function addAutomation(trigger: Trigger, overrides: Partial = {}) { const definition: AutomationDefinition = { version: 1, trigger, steps: [] }; diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts index 6dcc92777..bb4687520 100644 --- a/src/backend/tests/plugins/bundled-plugin.test.ts +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -283,3 +283,44 @@ describe("bundled fleets plugin", () => { expect(source).toMatch(/export async function deactivate/); }); }); + +describe("bundled automations plugin", () => { + it("ships a directory with a manifest and both entry points", () => { + const dir = path.join(getBundledPluginsDir(), "automations"); + + expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); + expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); + }); + + it("has a manifest that passes the real validator", () => { + const { manifest, errors } = parseManifest( + readBundledManifest("automations"), + ); + + expect(errors).toEqual([]); + expect(manifest?.id).toBe("automations"); + expect(manifest?.category).toBe("Infrastructure"); + }); + + it("declares the transport-owner capability and qualifies for the tier", () => { + const { manifest } = parseManifest(readBundledManifest("automations")); + + expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); + expect(isFirstParty("automations")).toBe(true); + expect(runsInProcess("automations", manifest!.permissions)).toBe(true); + }); + + it("exports activate and deactivate from its backend entry", () => { + const entry = path.join( + getBundledPluginsDir(), + "automations", + "backend", + "index.mjs", + ); + const source = fs.readFileSync(entry, "utf8"); + + expect(source).toMatch(/export async function activate/); + expect(source).toMatch(/export async function deactivate/); + }); +}); diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts index ba13d1314..f6d0e22b3 100644 --- a/src/backend/tests/plugins/first-party.test.ts +++ b/src/backend/tests/plugins/first-party.test.ts @@ -39,9 +39,10 @@ function manifest(overrides: Record = {}) { } describe("first-party allowlist", () => { - it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets", () => { + it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets and automations", () => { expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([ "ai", + "automations", "docker", "fleets", "host-metrics", @@ -51,7 +52,7 @@ describe("first-party allowlist", () => { ]); }); - it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets and nothing else", () => { + it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets and automations and nothing else", () => { expect(isFirstParty("ssh-terminal")).toBe(true); expect(isFirstParty("docker")).toBe(true); expect(isFirstParty("host-metrics")).toBe(true); @@ -59,6 +60,7 @@ describe("first-party allowlist", () => { expect(isFirstParty("proxmox")).toBe(true); expect(isFirstParty("remote-desktop")).toBe(true); expect(isFirstParty("fleets")).toBe(true); + expect(isFirstParty("automations")).toBe(true); expect(isFirstParty("ssh-terminal-pro")).toBe(false); expect(isFirstParty("community-plugin")).toBe(false); expect(isFirstParty("")).toBe(false); @@ -79,6 +81,10 @@ describe("runsInProcess", () => { expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe( true, ); + expect(runsInProcess("fleets", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); + expect(runsInProcess("automations", [TRANSPORT_OWNER_CAPABILITY])).toBe( + true, + ); // On the list but not asking for it: stays in a worker, so the manifest // remains an honest description of what the plugin does. @@ -88,6 +94,8 @@ describe("runsInProcess", () => { expect(runsInProcess("ai", ["hosts.read"])).toBe(false); expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false); expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false); + expect(runsInProcess("fleets", ["hosts.read"])).toBe(false); + expect(runsInProcess("automations", ["hosts.read"])).toBe(false); // Asking for it but not on the list. expect( @@ -179,6 +187,18 @@ describe("manifest gate on the reserved capability", () => { expect(parsed?.id).toBe("proxmox"); }); + it("allows it for automations", () => { + const { manifest: parsed, errors } = parseManifest( + manifest({ + id: "automations", + permissions: [TRANSPORT_OWNER_CAPABILITY], + }), + ); + + expect(errors).toEqual([]); + expect(parsed?.id).toBe("automations"); + }); + it("still accepts an ordinary manifest that never mentions it", () => { const { manifest: parsed, errors } = parseManifest( manifest({ permissions: ["hosts.read"] }), diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index c20b89169..7c3c11a9a 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -102,7 +102,7 @@ const WorkspacesPanel = lazy(() => })), ); const AutomationsPanel = lazy(() => - import("@/sidebar/AutomationsPanel").then((m) => ({ + import("../../plugins/automations/frontend/AutomationsPanel").then((m) => ({ default: m.AutomationsPanel, })), ); diff --git a/src/ui/features/ai/useMentions.ts b/src/ui/features/ai/useMentions.ts index e549af527..1596b3883 100644 --- a/src/ui/features/ai/useMentions.ts +++ b/src/ui/features/ai/useMentions.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { getSSHHosts } from "@/api/ssh-host-management-api"; import { getSnippets } from "@/api/snippets-api"; -import { listAutomations } from "@/api/automations-api"; +import { listAutomations } from "../../../../plugins/automations/frontend/automations-api"; /** * Backs the @-mention picker in the composer. diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 0a7744e94..e158256b5 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -175,9 +175,11 @@ const SshToolsPanel = lazy(() => import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })), ); const AutomationsPanel = lazy(() => - import("@/sidebar/AutomationsPanel").then((m) => ({ - default: m.AutomationsPanel, - })), + import("../../../plugins/automations/frontend/AutomationsPanel").then( + (m) => ({ + default: m.AutomationsPanel, + }), + ), ); const AiPanel = lazy(() => diff --git a/src/ui/tests/sidebar/automations-editor.test.ts b/src/ui/tests/plugins/automations/automations-editor.test.ts similarity index 85% rename from src/ui/tests/sidebar/automations-editor.test.ts rename to src/ui/tests/plugins/automations/automations-editor.test.ts index cefe06090..7e97b6ea5 100644 --- a/src/ui/tests/sidebar/automations-editor.test.ts +++ b/src/ui/tests/plugins/automations/automations-editor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { defaultTrigger } from "@/sidebar/automations/TriggerCard"; -import { emptyDraft } from "@/sidebar/automations/AutomationEditor"; -import { newStepId } from "@/sidebar/automations/editor-types"; +import { defaultTrigger } from "../../../../../plugins/automations/frontend/automations/TriggerCard"; +import { emptyDraft } from "../../../../../plugins/automations/frontend/automations/AutomationEditor"; +import { newStepId } from "../../../../../plugins/automations/frontend/automations/editor-types"; import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations"; describe("emptyDraft", () => { diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json index 873c38c89..bbb2c4a9a 100644 --- a/tsconfig.plugins.json +++ b/tsconfig.plugins.json @@ -32,7 +32,8 @@ "plugins/ai/backend/**/*.ts", "plugins/proxmox/backend/**/*.ts", "plugins/remote-desktop/backend/**/*.ts", - "plugins/fleets/backend/**/*.ts" + "plugins/fleets/backend/**/*.ts", + "plugins/automations/backend/**/*.ts" ], "exclude": ["src/**/*.test.ts"] }