feat: finish the automations plugin (B17)

This commit is contained in:
LukeGus committed 2026-09-24 13:05:44 -05:00
1 parent 993a91c899
commit 6b4d1432ab
96 files changed
+25106 -5077

No files matched your search

+7 -1
View File
@@ -161,7 +161,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gjgrKvv0zkJ6QUgUJBcK4CPIIa2JbLMmHecileCoA9A='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-u6nDWD7Mm1U/e9z2vyubUQB8Wk00VJsojS2q1tGdZwg='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -323,6 +323,12 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# Automation webhooks lived at /automations/webhook/<token> before
# 2.9.0 and external systems still call that URL.
location ^~ /automations/webhook/ {
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
}
# Every plugin's HTTP routes. One block, no per-plugin entries: a
# plugin serves at /plugin-api/<id>/ on the main backend and never
# gets a port or a block of its own. Buffering is off so SSE streams
+7 -1
View File
@@ -142,7 +142,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gjgrKvv0zkJ6QUgUJBcK4CPIIa2JbLMmHecileCoA9A='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-u6nDWD7Mm1U/e9z2vyubUQB8Wk00VJsojS2q1tGdZwg='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -303,6 +303,12 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# Automation webhooks lived at /automations/webhook/<token> before
# 2.9.0 and external systems still call that URL.
location ^~ /automations/webhook/ {
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
}
# Every plugin's HTTP routes. One block, no per-plugin entries: a
# plugin serves at /plugin-api/<id>/ on the main backend and never
# gets a port or a block of its own. Buffering is off so SSE streams
+4
View File
@@ -0,0 +1,4 @@
-- The automation tables moved to the automations plugin, which renames them
-- into its own namespace at activation. That runs after this migration, so a
-- real DROP TABLE here would delete every automation first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -323,6 +323,13 @@
"when": 1790268466430,
"tag": "0045_flowery_the_hood",
"breakpoints": true
},
{
"idx": 46,
"version": "5",
"when": 1790272734438,
"tag": "0046_rich_logan",
"breakpoints": true
}
]
}
@@ -0,0 +1,4 @@
-- The automation tables moved to the automations plugin, which renames them
-- into its own namespace at activation. That runs after this migration, so a
-- real DROP TABLE here would delete every automation first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -330,6 +330,13 @@
"when": 1790268465907,
"tag": "0046_small_white_tiger",
"breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1790272733784,
"tag": "0047_mighty_butterfly",
"breakpoints": true
}
]
}
+4
View File
@@ -0,0 +1,4 @@
-- The automation tables moved to the automations plugin, which renames them
-- into its own namespace at activation. That runs after this migration, so a
-- real DROP TABLE here would delete every automation first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -302,6 +302,13 @@
"when": 1790268465325,
"tag": "0042_many_old_lace",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1790272733108,
"tag": "0043_curvy_red_shift",
"breakpoints": true
}
]
}
+66 -13
View File
@@ -598,7 +598,10 @@ The `docker` and `hostMetrics` areas are still declared in core's
status for a host, from the shell's own polling, or null outside the shell.
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
`ConnectionStage` type, for remote desktop's display socket and its remote
host lookup. `useConnectionDefaults` now carries terminal defaults only; the
host lookup. **B17** added `NotificationChannelDialog` and the channel API
(`getNotificationChannels`, `deleteNotificationChannel`,
`testNotificationChannel`, the `NotificationChannel` type): channels are core,
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the
RDP half became remote desktop's user settings. Publishing its
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
@@ -732,6 +735,38 @@ the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
`TabOptions`/`TabHandle` are gone. Core's active connections list gets the
sessions shared with a user from the `sessions.sharedWithMe` action.
**Automations (B17)** imports nothing from core. It reaches snippets
(`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
(`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1,
`wake(hostId)`, which B20 provides) as services, notification channels through
`ctx.notify`, the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and
`ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run
executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's.
The scheduler tick is `ctx.schedule.every`, and triggers listen through
`ctx.events` to `host.status`, `host.login`, `internal.event`,
`plugin.host-metrics.snapshot`, `plugin.host-metrics.health-check`,
`plugin.tunnels.tunnel_disconnected` and its own
`plugin.automations.automation_failed`, which replaced the core
`automation_failed` internal event.
A definition's trigger and steps say which of those optional plugins it needs
(`requirements.ts`). An automation whose provider is not running is skipped:
the run is recorded as `skipped` with "Needs the <plugin> plugin", the reason
is logged, and the engine carries on with the next one. The editor asks
`GET /editor-options` for the running providers and only offers the triggers
and steps they allow, and the list marks such an automation "Needs <plugin>".
It provides `automations.access` v1 (`list`, `get`, `create`, `run`, gated
on `automations.view`, with `create` and `run` also checking their own
permissions) for the AI assistant, which lists automations as an optional
dependency. It adopts `automations` and the five `automation_*` tables; the
migrations keep the legacy links between them and to `notification_channels`
by hand. Core's `hosts/automation-events.ts` became `hosts/internal-events.ts`
(`emitInternalEvent`), since it never was specific to automations. Webhooks
live at `/plugin-api/automations/webhook/<token>`; the 2.8 path
`/automations/webhook/<token>` is rewritten to it in both nginx configs,
because external systems hold that URL.
#### Host status is core
The status dot in the host list, the dashboard and the homepage widget is
@@ -1358,14 +1393,14 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | B |
| `ctx.fetch` | `network:outbound` | **B17** |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other
@@ -1555,6 +1590,25 @@ per-host timers spreads out. Needs no capability.
the debounced save write it, for frequent low-value writes such as metrics
samples; the default still saves before returning.
**B17** added `ctx.notify` and `ctx.fetch`:
- `ctx.notify.channels()` lists the acting user's notification channels
(id, name, type, enabled, never the config), and `send(channelIds,
notification)` delivers to the ones among them that are theirs and
enabled, returning `{ delivered, failures }`. Both need `notify:send` and
an actor, and are audited (`plugin_notify_channels`, `plugin_notify_send`).
Channels stay core (`notification_channels`, its routes and
`NotificationChannelDialog`); delivery is `deliverNotification` in
`utils/notification-sender.ts`, which keeps the channel's private-network
opt-in plus the admin allowlist.
- `ctx.fetch(url, { method, headers, body, timeoutMs, allowPrivateHosts })`
goes through `safeOutboundFetch`: http and https only, no embedded
credentials, DNS pinned, no redirects, and private or loopback addresses
refused unless the exact host is in `allowPrivateHosts`. Needs
`network:outbound`, audited as `plugin_fetch`. Automations' HTTP step
passes the admin allowlist it reads through `ctx.settings.readCore`
(`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`).
`ctx.hosts.create` (**B14**) hands the created row to every plugin's
`hostImportNormalizer`, the same as a bulk import row, so a plugin creating a
host can set another plugin's host settings (proxmox marks an imported
@@ -2085,7 +2139,8 @@ Then, with the app running:
## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15) and host-metrics (B16), which import
(B2), remote-desktop (B14), docker (B15), host-metrics (B16) and automations
(B17), which import
nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
@@ -2113,9 +2168,9 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 70 files | D1 |
| A plugin importing core by relative path | Warning | 24 files | D1 |
| A plugin importing another plugin's source | Warning | 1 file | B18 |
| A plugin frontend importing core through `@/` | Warning | 64 files | D1 |
| A plugin importing core by relative path | Warning | 12 files | D1 |
| A plugin importing another plugin's source | Warning | 0 files | - |
A warning does not fail a build, so the counts are held by
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
@@ -2131,12 +2186,10 @@ rule cannot carry both severities on the same files.
Known specifics:
- **TODO(B18):** `plugins/ai/src/backend/tools/executor.ts` imports
`automations`' `routes.js` directly. It is declared as a hard `dependencies`
entry so the loader starts automations first and marks `ai` blocked if
automations is disabled. In B18 `ai` calls automations through an
`automations` service as an `optionalDependency` and the direct import goes
away.
- **B17** removed the last plugin-to-plugin import: ai's proposal executor
and automation read tools call automations through the `automations.access`
service, and automations is an optional dependency of ai rather than a hard
one.
- **B16** moved host-metrics onto the SDK: it imports nothing from core or
automations. Automations reaches it through the `host-metrics.viewers`
service and its `plugin.host-metrics.*` events, and core reaches it through
+44 -20
View File
@@ -334,16 +334,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
plugins through `tsconfig.plugins*.json` and passes. Owner: D0.
- **B16 (host-metrics):** automations still sets up its headless viewers
through `setViewerRegistry` in `plugins/automations/src/backend/headless-viewer.ts`,
which nothing calls any more, so hosts an automation watches are only
sampled while somebody has them open. Switch it to the
`host-metrics.viewers` service (`register(hostId)` as the automation's
owner, `heartbeat`, `unregister`) and make host-metrics an optional
dependency again. Its triggers already listen to the final topics
(`plugin.host-metrics.snapshot`, `plugin.host-metrics.health-check`, core
`host.status`, `host.login`), but through core's bus by relative import
instead of `ctx.events`. Owner: B17.
- **B16 (host-metrics):** Termix-Mobile read `statsConfig` on each host
(`statusCheckEnabled`, `enabledWidgets`) and the host status from the
metrics API. The host payload now carries `statusCheckEnabled` and
@@ -367,12 +357,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`widgets/network-collector.ts`) to compute rates. The poller clears them on
deactivate and per host, so nothing leaks, but they are still module state
rather than per-activation. Owner: D1.
- **Pre-existing, found in B16:** automations
`tests/backend/tunnels.test.ts` fails "fails the step cleanly while the
tunnels plugin is off": it expects "tunnels plugin is not available" and
gets "tunnels.start is not a function". Same cause as the tunnels line
above: since B12 `ctx.services.get` hands back an empty handle instead of
throwing, so the step has to check `typeof start`. Owner: B17 or D0.
- **B15 (docker), for D2:** the console socket `/plugin-ws/docker/console`
is a public route with `optionalAuth`. The handler refuses a socket with no
signed-in user or without `docker.use` and checks the data key per connect
@@ -394,10 +378,39 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
tools still report `enableDocker` from the host row, which no longer has
the column, so it always reads null. Read `pluginSettings.docker` or ask the
`docker.containers` service instead. Owner: B18.
- **B15 (docker), for B17:** automations' `docker-watcher.ts` still reads its
own rules through core's automation repository and types by relative
import; only the Docker side is on the SDK (`docker.containers` for the
step, `docker.events` for the trigger). Owner: B17.
- **B17 (automations), for B20:** the wake-on-lan step calls the optional
`wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
automation's owner; the provider resolves the host's MAC and checks access
itself. Until B20 provides it, the step is offered nowhere and an existing
one is skipped with "Needs the wake-on-lan plugin". Owner: B20.
- **B17 (automations), for D2:** the ctx table says `ctx.events.on` needs
`events:core` for core topics, but the runtime only checks `emit`.
docker, file-manager, host-metrics, session-recording and snippets
subscribe to core topics (`host.*`, `user.*`) without declaring it.
Either gate `on` and add `events:core` to those five, or change the doc to
say subscribing is ungated. Owner: D2.
- **B17 (automations):** the 2.8 webhook URL `/automations/webhook/<token>`
is kept only by a rewrite in `docker/nginx.conf` and
`docker/nginx-https.conf`. An install that serves the backend without
nginx (the desktop app's embedded backend, a hand-rolled proxy) answers
404 there. Decide whether that matters enough for a generic legacy-path
alias in the plugin HTTP layer. Owner: D0.
- **B17 (automations):** there are two notification senders now:
`deliverNotification` (ctx.notify, per-channel private opt-in, throws) and
the older `sendWebhook`/`sendNtfy`/`sendDiscord` that the channel "test"
route in `notification-channels-routes.ts` still uses (always applies the
allowlist, retries once). Point the test route at `deliverNotification`
and delete the old ones. Owner: D1.
- **B17 (automations):** the scheduler no longer skips a user whose data key
cannot be resolved (`DataCrypto.canUserAccessData` has no SDK equivalent).
With system-wrapped DEKs that check was effectively always true; if it
ever is not, the run now fails on its first SSH step instead of being
skipped silently. Owner: none unless a background "is this user unlocked"
check is added to the SDK.
- **B17 (automations):** AI's `list_notification_channels` read tool still reads
notification channels through core's repository by relative import; it
can use `ctx.notify.channels()` now. Owner: B18.
## Manual checks after 2.9.0
@@ -444,3 +457,14 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
with no stored password asks for credentials; the homepage Docker widget;
an automation Docker step and a `docker_event` trigger; disable Docker with
a console open, then enable it and open another.
- Automations: on a 2.8 database the automations, their run history and
their channel links are there; a schedule fires; a webhook fires on both
`/plugin-api/automations/webhook/<token>` and the old
`/automations/webhook/<token>`; a metric threshold on a host nobody has
open still fires (headless viewers); a `docker_event` trigger and a Docker
step; a notify step to webhook, ntfy and Discord channels, and to a LAN
ntfy with the private opt-in plus the admin allowlist; disable Docker and
the automation shows "Needs docker", its scheduled runs are recorded as
skipped and the others keep running, then enable it again; the AI
assistant lists and creates automations, and says they are unavailable
with automations off.
+78
View File
@@ -1255,6 +1255,80 @@ export interface PluginAudit {
}) => Promise<void>;
}
/** A notification channel the acting user set up. Its config never leaves core. */
export interface PluginNotificationChannel {
id: number;
name: string;
/** "webhook", "ntfy" or "discord". */
type: string;
enabled: boolean;
}
export interface PluginNotification {
title: string;
body: string;
severity?: "info" | "warning" | "critical";
/**
* Extra fields a webhook receiver gets alongside the message. `sourceId`
* and `sourceName` name what sent it (an automation, a rule).
*/
context?: {
hostId?: number;
hostName?: string;
sourceId?: number | string;
sourceName?: string;
triggerType?: string;
value?: unknown;
threshold?: unknown;
};
}
export interface PluginNotifyResult {
delivered: number;
failures: Array<{ channelId: number; name: string; error: string }>;
}
/**
* Core's notification channels, as the acting user. Needs notify:send, and
* refuses a call with no actor: channels belong to a user.
*/
export interface PluginNotify {
/** The acting user's channels, without their config. */
channels: () => Promise<PluginNotificationChannel[]>;
/**
* Sends to the listed channels the acting user owns. Disabled channels and
* ids the user does not own are skipped. One channel failing does not stop
* the others.
*/
send: (
channelIds: number[],
notification: PluginNotification,
) => Promise<PluginNotifyResult>;
}
export interface PluginFetchInit {
method?: string;
headers?: Record<string, string>;
body?: string;
/** Aborts the request after this long. Defaults to 30 seconds. */
timeoutMs?: number;
/**
* Exact private or loopback hosts this request may reach. Everything else
* private is refused, and redirects are never followed.
*/
allowPrivateHosts?: readonly string[];
}
/**
* Outbound HTTP through core's SSRF guard: http and https only, no embedded
* credentials, DNS pinned, private addresses refused unless listed. Needs
* network:outbound.
*/
export type PluginFetch = (
url: string,
init?: PluginFetchInit,
) => Promise<Response>;
/**
* A generic capability check, for a privileged action that has no dedicated
* ctx method to wrap it: a plugin bundling its own native dependency to touch
@@ -1325,6 +1399,10 @@ export interface PluginContext {
readonly audit: PluginAudit;
/** Timers that stop on deactivate. */
readonly schedule: PluginSchedule;
/** Core's notification channels. Needs notify:send. */
readonly notify: PluginNotify;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch;
/**
* Runs `fn` with `userId` as the acting user, for background work that has
+66 -1
View File
@@ -56,6 +56,9 @@ import type {
PluginNativeRdpRequest,
PluginProtocolTarget,
PluginHostStatusEntry,
PluginNotification,
PluginNotificationChannel,
PluginFetchInit,
} from "./backend.js";
/** A timer registered through ctx.schedule, run by hand with runScheduled. */
@@ -108,6 +111,13 @@ export interface FakeContextOptions {
desktopAvailable?: boolean;
/** What ctx.hosts.status.get and check answer, by host id. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
* Answers ctx.fetch. Without it every fetch rejects, so a test never
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
}
export interface FakeAuthRegistrations {
@@ -169,6 +179,14 @@ export interface FakePluginContext {
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.notify.send call, with the actor it ran as. */
notifications: Array<{
actor: string | undefined;
channelIds: number[];
notification: PluginNotification;
}>;
/** Every ctx.fetch call, in order. */
fetches: Array<{ url: string; init?: PluginFetchInit }>;
/** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */
@@ -270,6 +288,8 @@ export function createFakeContext(
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const credentialReads: FakePluginContext["credentialReads"] = [];
const notifications: FakePluginContext["notifications"] = [];
const fetches: FakePluginContext["fetches"] = [];
const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
@@ -766,6 +786,23 @@ export function createFakeContext(
},
},
notify: {
channels: async () => options.notificationChannels ?? [],
send: async (channelIds, notification) => {
notifications.push({ actor, channelIds, notification });
const selected = (options.notificationChannels ?? []).filter(
(channel) => channelIds.includes(channel.id) && channel.enabled,
);
return { delivered: selected.length, failures: [] };
},
},
fetch: async (url, init) => {
fetches.push({ url, init });
if (!options.fetch) throw new Error("ctx.fetch is not stubbed");
return options.fetch(url, init);
},
asUser: async (userId, fn) => {
const previous = actor;
actor = userId;
@@ -797,6 +834,8 @@ export function createFakeContext(
desktopWindows,
nativeRdpLaunches,
credentialReads,
notifications,
fetches,
audits,
activities,
trackedSessions,
@@ -852,6 +891,13 @@ export interface MockContextOptions {
desktopAvailable?: boolean;
/** What ctx.hosts.status answers. See FakeContextOptions. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
* Answers ctx.fetch. Without it every fetch rejects, so a test never
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
}
export interface MockPluginContext extends FakePluginContext {
@@ -866,7 +912,8 @@ export interface MockPluginContext extends FakePluginContext {
* The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, and
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify, network:outbound on ctx.fetch, and
* auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
@@ -891,6 +938,8 @@ export function createMockCtx(
protocolTargets: options.protocolTargets,
desktopAvailable: options.desktopAvailable,
hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels,
fetch: options.fetch,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1155,6 +1204,22 @@ export function createMockCtx(
},
},
notify: {
channels: async () => {
require("notify:send");
return ctx.notify.channels();
},
send: async (channelIds, notification) => {
require("notify:send");
return ctx.notify.send(channelIds, notification);
},
},
fetch: async (url, init) => {
require("network:outbound");
return ctx.fetch(url, init);
},
capabilities: {
has: async (capability) => granted.has(capability),
require: async (capability) => {
+7 -4
View File
@@ -26,15 +26,13 @@
"network:serve",
"ui:surface"
],
"dependencies": {
"automations": "^1.0.0"
},
"optionalDependencies": {
"workspaces": "^1.0.0",
"network-topology": "^1.0.0",
"snippets": "^1.0.0",
"fleets": "^1.0.0",
"ssh-terminal": "^1.0.0"
"ssh-terminal": "^1.0.0",
"automations": "^1.0.0"
},
"requires": [
{
@@ -61,6 +59,11 @@
"service": "terminal.history",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "automations.access",
"versionRange": "^1.0.0",
"optional": true
}
],
"providesSecret": [
+81
View File
@@ -248,3 +248,84 @@ export async function listCommandHistory(
return null;
}
}
/** The automations plugin, as ctx.services.get("automations.access") returns it. */
interface AutomationsAccess {
list: () => Promise<
Array<{
id: number;
name: string;
description: string | null;
enabled: boolean;
triggerKind: string | null;
lastRunAt: string | null;
lastRunStatus: string | null;
missingPlugins: string[];
}>
>;
get: (id: number) => Promise<{
id: number;
name: string;
enabled: boolean;
definition: unknown;
} | null>;
create: (input: {
name: string;
description?: string | null;
definition: unknown;
enabled?: boolean;
}) => Promise<{ id: number; name: string }>;
}
function automations(userId: string): Partial<AutomationsAccess> {
if (!current) return {};
try {
return current.get<AutomationsAccess>("automations.access", { userId });
} catch {
return {};
}
}
/**
* The user's automations, or null when the automations plugin is off or the
* user may not use it.
*/
export async function listAutomations(
userId: string,
): Promise<Awaited<ReturnType<AutomationsAccess["list"]>> | null> {
const list = automations(userId).list;
if (typeof list !== "function") return null;
try {
return await list();
} catch {
return null;
}
}
export async function getAutomation(
userId: string,
id: number,
): Promise<Awaited<ReturnType<AutomationsAccess["get"]>> | undefined> {
const get = automations(userId).get;
if (typeof get !== "function") return undefined;
try {
return await get(id);
} catch {
return undefined;
}
}
/**
* Creates an automation through the plugin, which validates it exactly as
* its own route does. Throws when the plugin is off.
*/
export async function createAutomation(
userId: string,
input: Parameters<AutomationsAccess["create"]>[0],
): Promise<{ id: number; name: string }> {
const create = automations(userId).create;
if (typeof create !== "function") {
throw new Error("The automations plugin is not available");
}
return create(input);
}
+7 -18
View File
@@ -1,8 +1,4 @@
import {
createCurrentAutomationRepository,
createCurrentHostRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
import { createCurrentHostRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "../ssh.js";
@@ -14,6 +10,7 @@ import {
getSnippet,
createFleet,
addFleetMember,
createAutomation,
} from "../services.js";
/** Approved commands get a bounded window rather than hanging the request. */
@@ -204,20 +201,12 @@ export async function applyProposal(
}
case "propose_create_automation": {
// Reuses the same validator the automations route runs, so an
// LLM-authored definition is held to exactly the human standard.
const validation = validateDefinition(payload.definition);
if (!validation.ok || !validation.definition) {
throw new Error(
validation.error ?? "The automation definition is invalid",
);
}
const created = await createCurrentAutomationRepository().create({
userId,
// The automations plugin runs the same validator its own route does,
// so an LLM-authored definition is held to exactly the human standard.
const created = await createAutomation(userId, {
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
definition: JSON.stringify(validation.definition),
definition: payload.definition,
// Starts disabled: an automation the user has not watched run once
// should not begin firing against their servers on approval.
enabled: false,
@@ -225,7 +214,7 @@ export async function applyProposal(
return {
ok: true,
summary: `Created automation ${(created as any).name} (disabled until you enable it)`,
summary: `Created automation ${created.name} (disabled until you enable it)`,
};
}
+17 -14
View File
@@ -1,6 +1,5 @@
import {
createCurrentNotificationChannelRepository,
createCurrentAutomationRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
@@ -11,6 +10,8 @@ import {
listSnippets,
listFleets,
listCommandHistory,
listAutomations,
getAutomation,
} from "../services.js";
/**
@@ -128,17 +129,18 @@ export const readTools: AiTool[] = [
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const automations = await createCurrentAutomationRepository().list(
context.userId,
);
const automations = await listAutomations(context.userId);
if (!automations) return { automations: [], unavailable: true };
return {
automations: automations.map((automation: any) => ({
automations: automations.map((automation) => ({
id: automation.id,
name: automation.name,
description: automation.description ?? null,
description: automation.description,
enabled: automation.enabled,
lastRunAt: automation.lastRunAt ?? null,
lastRunStatus: automation.lastRunStatus ?? null,
triggerKind: automation.triggerKind,
lastRunAt: automation.lastRunAt,
lastRunStatus: automation.lastRunStatus,
needsPlugins: automation.missingPlugins,
})),
};
},
@@ -155,17 +157,18 @@ export const readTools: AiTool[] = [
["automationId"],
),
handler: async (args, context) => {
const automation = await createCurrentAutomationRepository().findForUser(
Number(args.automationId),
const automation = await getAutomation(
context.userId,
Number(args.automationId),
);
if (automation === undefined) return { unavailable: true };
if (!automation) return { error: "Automation not found" };
return {
automation: {
id: (automation as any).id,
name: (automation as any).name,
enabled: (automation as any).enabled,
definition: (automation as any).definition,
id: automation.id,
name: automation.name,
enabled: automation.enabled,
definition: automation.definition,
},
};
},
@@ -12,13 +12,12 @@ const snippetRepository = {
updateSnippet: vi.fn(),
deleteSnippet: vi.fn(),
};
const automationRepository = { create: vi.fn() };
const automationsAccess = { create: vi.fn() };
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentHostRepository: () => hostRepository,
createCurrentSnippetRepository: () => snippetRepository,
createCurrentAutomationRepository: () => automationRepository,
}));
const resolveHostById = vi.fn();
@@ -57,9 +56,24 @@ describe("applyProposal", () => {
).rejects.toThrow("Unknown proposal kind");
});
it("validates an automation definition before creating it", async () => {
// Reuses the automations route's own validator, so a definition the model
function withAutomations() {
setPluginServices({
get: (service: string) => {
if (service !== "automations.access")
throw new Error(`unexpected ${service}`);
return automationsAccess as never;
},
provide: vi.fn(),
} as never);
}
it("hands an automation to the automations plugin, which validates it", async () => {
// The plugin runs its own route's validator, so a definition the model
// invented is held to the same standard as a hand-written one.
withAutomations();
automationsAccess.create.mockRejectedValue(
new Error("Unknown or missing trigger kind"),
);
await expect(
applyProposal(
"propose_create_automation",
@@ -69,12 +83,22 @@ describe("applyProposal", () => {
},
"user-1",
),
).rejects.toThrow();
expect(automationRepository.create).not.toHaveBeenCalled();
).rejects.toThrow("Unknown or missing trigger kind");
});
it("refuses an automation while the automations plugin is off", async () => {
await expect(
applyProposal(
"propose_create_automation",
{ name: "x", definition: {} },
"user-1",
),
).rejects.toThrow("The automations plugin is not available");
});
it("creates a valid automation disabled so it cannot fire unwatched", async () => {
automationRepository.create.mockResolvedValue({ id: 5, name: "nightly" });
withAutomations();
automationsAccess.create.mockResolvedValue({ id: 5, name: "nightly" });
const result = await applyProposal(
"propose_create_automation",
@@ -89,8 +113,8 @@ describe("applyProposal", () => {
);
expect(result.ok).toBe(true);
expect(automationRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", enabled: false }),
expect(automationsAccess.create).toHaveBeenCalledWith(
expect.objectContaining({ name: "nightly", enabled: false }),
);
});
+10
View File
@@ -157,6 +157,16 @@
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the plugin it needs is turned on again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
+26 -6
View File
@@ -16,7 +16,6 @@
},
"capabilities": [
"hosts:read",
"hosts:write",
"credentials:use",
"ssh:connect",
"db:own",
@@ -24,19 +23,20 @@
"notify:send",
"network:outbound",
"network:serve",
"settings:read-core",
"ui:surface"
],
"optionalDependencies": {
"snippets": "^1.0.0",
"fleets": "^1.0.0",
"tunnels": "^1.0.0",
"docker": "^1.0.0"
"docker": "^1.0.0",
"host-metrics": "^1.0.0",
"wake-on-lan": "^1.0.0"
},
"requires": [
{
"service": "snippets.access",
"versionRange": "^1.0.0",
"optional": true
"versionRange": "^1.0.0"
},
{
"service": "fleets.access",
@@ -57,6 +57,23 @@
"service": "docker.events",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "host-metrics.viewers",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "wake-on-lan.send",
"versionRange": "^1.0.0",
"optional": true
}
],
"provides": [
{
"service": "automations.access",
"version": "1.0.0",
"permission": "automations.view"
}
],
"contributes": {
@@ -102,5 +119,8 @@
]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"dependencies": {
"snippets": "^1.0.0"
}
}
@@ -0,0 +1,86 @@
-- automations 0001: adopt_automations
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
-- (links between these tables, and a channel link to notification_channels).
CREATE TABLE IF NOT EXISTS `automations` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`description` text,
`enabled` boolean NOT NULL DEFAULT true,
`definition` text NOT NULL,
`definition_version` int NOT NULL DEFAULT 1,
`concurrency_policy` text NOT NULL DEFAULT ('skip'),
`max_run_seconds` int NOT NULL DEFAULT 300,
`dry_run` boolean NOT NULL DEFAULT false,
`last_run_at` text,
`last_run_status` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automations` TO `p_automations_automations`;
CREATE TABLE IF NOT EXISTS `automation_trigger_state` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`state_key` varchar(255) NOT NULL,
`breach_started_at` text,
`last_fired_at` text,
`last_value` double,
`last_observed_state` text,
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_trigger_state` TO `p_automations_trigger_state`;
CREATE TABLE IF NOT EXISTS `automation_schedules` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`cron` text,
`interval_seconds` int,
`timezone` text,
`next_due_at` varchar(255),
`last_tick_at` text,
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_schedules` TO `p_automations_schedules`;
CREATE TABLE IF NOT EXISTS `automation_runs` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`user_id` varchar(255) NOT NULL,
`trigger_type` text NOT NULL,
`trigger_context` text,
`status` text NOT NULL,
`started_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`finished_at` text,
`duration_ms` int,
`error` text,
`dry_run` boolean NOT NULL DEFAULT false,
`parent_run_id` int,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_runs` TO `p_automations_runs`;
CREATE TABLE IF NOT EXISTS `automation_run_steps` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`run_id` int NOT NULL,
`step_index` int NOT NULL,
`step_id` text NOT NULL,
`step_type` text NOT NULL,
`status` text NOT NULL,
`started_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`finished_at` text,
`output` text,
`error` text,
`truncated` boolean NOT NULL DEFAULT false,
FOREIGN KEY (`run_id`) REFERENCES `p_automations_runs` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_run_steps` TO `p_automations_run_steps`;
CREATE TABLE IF NOT EXISTS `automation_channels` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`channel_id` int NOT NULL,
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`channel_id`) REFERENCES `notification_channels` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_channels` TO `p_automations_channels`;
@@ -0,0 +1,94 @@
-- automations 0001: adopt_automations
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
-- (links between these tables, and a channel link to notification_channels).
CREATE TABLE IF NOT EXISTS "automations" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"description" text,
"enabled" boolean NOT NULL DEFAULT true,
"definition" text NOT NULL,
"definition_version" integer NOT NULL DEFAULT 1,
"concurrency_policy" text NOT NULL DEFAULT 'skip',
"max_run_seconds" integer NOT NULL DEFAULT 300,
"dry_run" boolean NOT NULL DEFAULT false,
"last_run_at" text,
"last_run_status" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automations_user" ON "automations" ("user_id", "enabled");
ALTER TABLE "automations" RENAME TO "p_automations_automations";
CREATE TABLE IF NOT EXISTS "automation_trigger_state" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"state_key" varchar(255) NOT NULL,
"breach_started_at" text,
"last_fired_at" text,
"last_value" double precision,
"last_observed_state" text,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_trigger_state_key" ON "automation_trigger_state" ("automation_id", "state_key");
ALTER TABLE "automation_trigger_state" RENAME TO "p_automations_trigger_state";
CREATE TABLE IF NOT EXISTS "automation_schedules" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"cron" text,
"interval_seconds" integer,
"timezone" text,
"next_due_at" varchar(255),
"last_tick_at" text,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_schedules_automation" ON "automation_schedules" ("automation_id");
CREATE INDEX IF NOT EXISTS "idx_automation_schedules_due" ON "automation_schedules" ("next_due_at");
ALTER TABLE "automation_schedules" RENAME TO "p_automations_schedules";
CREATE TABLE IF NOT EXISTS "automation_runs" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"trigger_type" text NOT NULL,
"trigger_context" text,
"status" text NOT NULL,
"started_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" text,
"duration_ms" integer,
"error" text,
"dry_run" boolean NOT NULL DEFAULT false,
"parent_run_id" integer,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automation_runs_automation" ON "automation_runs" ("automation_id", "started_at");
CREATE INDEX IF NOT EXISTS "idx_automation_runs_user" ON "automation_runs" ("user_id", "started_at");
ALTER TABLE "automation_runs" RENAME TO "p_automations_runs";
CREATE TABLE IF NOT EXISTS "automation_run_steps" (
"id" serial PRIMARY KEY,
"run_id" integer NOT NULL,
"step_index" integer NOT NULL,
"step_id" text NOT NULL,
"step_type" text NOT NULL,
"status" text NOT NULL,
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" text,
"output" text,
"error" text,
"truncated" boolean NOT NULL DEFAULT false,
FOREIGN KEY ("run_id") REFERENCES "p_automations_runs" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automation_run_steps_run" ON "automation_run_steps" ("run_id", "step_index");
ALTER TABLE "automation_run_steps" RENAME TO "p_automations_run_steps";
CREATE TABLE IF NOT EXISTS "automation_channels" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"channel_id" integer NOT NULL,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE,
FOREIGN KEY ("channel_id") REFERENCES "notification_channels" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
@@ -0,0 +1,303 @@
{
"version": 1,
"tables": {
"automations": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"description": {
"type": "text"
},
"enabled": {
"type": "boolean",
"notNull": true,
"defaultValue": true
},
"definition": {
"type": "text",
"notNull": true
},
"definitionVersion": {
"type": "integer",
"notNull": true,
"defaultValue": 1
},
"concurrencyPolicy": {
"type": "text",
"notNull": true,
"defaultValue": "skip"
},
"maxRunSeconds": {
"type": "integer",
"notNull": true,
"defaultValue": 300
},
"dryRun": {
"type": "boolean",
"notNull": true,
"defaultValue": false
},
"lastRunAt": {
"type": "text"
},
"lastRunStatus": {
"type": "text"
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_automations_user",
"columns": ["userId", "enabled"]
}
],
"adopts": "automations"
},
"trigger_state": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"automationId": {
"type": "integer",
"notNull": true
},
"stateKey": {
"type": "varchar",
"length": 255,
"notNull": true
},
"breachStartedAt": {
"type": "text"
},
"lastFiredAt": {
"type": "text"
},
"lastValue": {
"type": "real"
},
"lastObservedState": {
"type": "text"
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_automation_trigger_state_key",
"columns": ["automationId", "stateKey"],
"unique": true
}
],
"adopts": "automation_trigger_state"
},
"schedules": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"automationId": {
"type": "integer",
"notNull": true
},
"cron": {
"type": "text"
},
"intervalSeconds": {
"type": "integer"
},
"timezone": {
"type": "text"
},
"nextDueAt": {
"type": "timestamp"
},
"lastTickAt": {
"type": "text"
}
},
"indexes": [
{
"name": "idx_automation_schedules_automation",
"columns": ["automationId"],
"unique": true
},
{
"name": "idx_automation_schedules_due",
"columns": ["nextDueAt"]
}
],
"adopts": "automation_schedules"
},
"runs": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"automationId": {
"type": "integer",
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"triggerType": {
"type": "text",
"notNull": true
},
"triggerContext": {
"type": "text"
},
"status": {
"type": "text",
"notNull": true
},
"startedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"finishedAt": {
"type": "text"
},
"durationMs": {
"type": "integer"
},
"error": {
"type": "text"
},
"dryRun": {
"type": "boolean",
"notNull": true,
"defaultValue": false
},
"parentRunId": {
"type": "integer"
}
},
"indexes": [
{
"name": "idx_automation_runs_automation",
"columns": ["automationId", "startedAt"]
},
{
"name": "idx_automation_runs_user",
"columns": ["userId", "startedAt"]
}
],
"adopts": "automation_runs"
},
"run_steps": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"runId": {
"type": "integer",
"notNull": true
},
"stepIndex": {
"type": "integer",
"notNull": true
},
"stepId": {
"type": "text",
"notNull": true
},
"stepType": {
"type": "text",
"notNull": true
},
"status": {
"type": "text",
"notNull": true
},
"startedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"finishedAt": {
"type": "text"
},
"output": {
"type": "text"
},
"error": {
"type": "text"
},
"truncated": {
"type": "boolean",
"notNull": true,
"defaultValue": false
}
},
"indexes": [
{
"name": "idx_automation_run_steps_run",
"columns": ["runId", "stepIndex"]
}
],
"adopts": "automation_run_steps"
},
"channels": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"automationId": {
"type": "integer",
"notNull": true
},
"channelId": {
"type": "integer",
"notNull": true
}
},
"indexes": [
{
"name": "idx_automation_channels_pair",
"columns": ["automationId", "channelId"],
"unique": true
}
],
"adopts": "automation_channels"
}
}
}
@@ -0,0 +1,94 @@
-- automations 0001: adopt_automations
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
-- (links between these tables, and a channel link to notification_channels).
CREATE TABLE IF NOT EXISTS "automations" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"enabled" integer NOT NULL DEFAULT 1,
"definition" text NOT NULL,
"definition_version" integer NOT NULL DEFAULT 1,
"concurrency_policy" text NOT NULL DEFAULT 'skip',
"max_run_seconds" integer NOT NULL DEFAULT 300,
"dry_run" integer NOT NULL DEFAULT 0,
"last_run_at" text,
"last_run_status" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automations_user" ON "automations" ("user_id", "enabled");
ALTER TABLE "automations" RENAME TO "p_automations_automations";
CREATE TABLE IF NOT EXISTS "automation_trigger_state" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"state_key" text NOT NULL,
"breach_started_at" text,
"last_fired_at" text,
"last_value" real,
"last_observed_state" text,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_trigger_state_key" ON "automation_trigger_state" ("automation_id", "state_key");
ALTER TABLE "automation_trigger_state" RENAME TO "p_automations_trigger_state";
CREATE TABLE IF NOT EXISTS "automation_schedules" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"cron" text,
"interval_seconds" integer,
"timezone" text,
"next_due_at" text,
"last_tick_at" text,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_schedules_automation" ON "automation_schedules" ("automation_id");
CREATE INDEX IF NOT EXISTS "idx_automation_schedules_due" ON "automation_schedules" ("next_due_at");
ALTER TABLE "automation_schedules" RENAME TO "p_automations_schedules";
CREATE TABLE IF NOT EXISTS "automation_runs" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"user_id" text NOT NULL,
"trigger_type" text NOT NULL,
"trigger_context" text,
"status" text NOT NULL,
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" text,
"duration_ms" integer,
"error" text,
"dry_run" integer NOT NULL DEFAULT 0,
"parent_run_id" integer,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automation_runs_automation" ON "automation_runs" ("automation_id", "started_at");
CREATE INDEX IF NOT EXISTS "idx_automation_runs_user" ON "automation_runs" ("user_id", "started_at");
ALTER TABLE "automation_runs" RENAME TO "p_automations_runs";
CREATE TABLE IF NOT EXISTS "automation_run_steps" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"run_id" integer NOT NULL,
"step_index" integer NOT NULL,
"step_id" text NOT NULL,
"step_type" text NOT NULL,
"status" text NOT NULL,
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" text,
"output" text,
"error" text,
"truncated" integer NOT NULL DEFAULT 0,
FOREIGN KEY ("run_id") REFERENCES "p_automations_runs" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automation_run_steps_run" ON "automation_run_steps" ("run_id", "step_index");
ALTER TABLE "automation_run_steps" RENAME TO "p_automations_run_steps";
CREATE TABLE IF NOT EXISTS "automation_channels" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"channel_id" integer NOT NULL,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE,
FOREIGN KEY ("channel_id") REFERENCES "notification_channels" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
@@ -1,39 +1,45 @@
import type { HostSelector } from "../../../../../src/types/automations.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js";
import { fleetMemberIds } from "../snippets.js";
import type { StepExecutionContext } from "./types.js";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import type { HostSelector } from "../../types.js";
import type { StepExecutionContext, StepRuntime } from "./types.js";
/** A host the caller is allowed to act on. `host` is always resolved. */
/** A host the owner may act on, resolved through core's connect pipeline. */
export interface ResolvedTarget {
id: number;
name: string;
host: NonNullable<Awaited<ReturnType<typeof resolveHostById>>>;
host: PluginSshHost;
}
/**
* Turns a selector into the hosts a step may actually act on.
*
* Access is checked here, at execution time rather than when the automation
* was saved, so a permission revoked after the fact takes effect on the next
* run. resolveHostById performs its own connect-level check and returns null
* when the owner can no longer reach the host.
* Access is checked at execution time rather than when the automation was
* saved, so a permission revoked after the fact takes effect on the next run.
* resolveHost runs as the automation's owner and answers null when they can
* no longer reach the host.
*/
export async function resolveTargets(
selector: HostSelector,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<{ targets: ResolvedTarget[]; skipped: number[] }> {
const ids = await selectorHostIds(selector, context);
const ids = await selectorHostIds(selector, context, runtime);
const targets: ResolvedTarget[] = [];
const skipped: number[] = [];
for (const id of ids) {
const host = await resolveHostById(id, context.userId);
let host: PluginSshHost | null = null;
try {
host = await runtime.ctx.ssh.resolveHost(id);
} catch {
host = null;
}
if (!host) {
skipped.push(id);
continue;
}
targets.push({ id, name: host.name || host.ip, host });
const name =
typeof host.name === "string" && host.name ? host.name : host.ip;
targets.push({ id, name, host });
}
return { targets, skipped };
@@ -42,6 +48,7 @@ export async function resolveTargets(
async function selectorHostIds(
selector: HostSelector,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<number[]> {
switch (selector.kind) {
case "host":
@@ -50,35 +57,29 @@ async function selectorHostIds(
return selector.hostIds;
case "trigger":
return context.triggerHostId ? [context.triggerHostId] : [];
case "fleet":
return fleetHostIds(selector.fleetId, context.userId);
case "all":
return allAccessibleHostIds(context.userId);
case "fleet": {
const fleets = runtime.deps.fleets();
if (typeof fleets.members !== "function") return [];
try {
const members = await fleets.members(selector.fleetId);
return members.map((member) => member.id);
} catch {
return [];
}
}
case "all": {
try {
const hosts = await runtime.ctx.hosts.list();
// The owner's own hosts, as before: "all" never fans out onto hosts
// someone else shared with them.
return hosts
.filter((host) => host.userId === context.userId)
.map((host) => host.id);
} catch {
return [];
}
}
default:
return [];
}
}
async function fleetHostIds(
fleetId: number,
userId: string,
): Promise<number[]> {
return fleetMemberIds(userId, fleetId);
}
async function allAccessibleHostIds(userId: string): Promise<number[]> {
try {
const { createCurrentHostRepository } =
await import("../../../../../src/backend/database/repositories/factory.js");
const hosts = await createCurrentHostRepository().listByUserId(userId);
const ids = hosts.map((host) => host.id);
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
userId,
ids,
);
return ids.filter((id) => allowed.has(id));
} catch {
return [];
}
}
+145 -104
View File
@@ -1,15 +1,6 @@
import {
DEFAULT_STEP_TIMEOUT_MS,
type Step,
} from "../../../../../src/types/automations.js";
import type { Client } from "ssh2";
import { DEFAULT_STEP_TIMEOUT_MS, type Step } from "../../types.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "../ssh.js";
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
import { runDockerAction } from "../docker.js";
import { runTunnelAction } from "../tunnels.js";
import { sendAutomationNotification } from "../notify.js";
import { automationFetch } from "../http.js";
import { renderRecord, renderTemplate } from "../template.js";
import { resolveTargets, type ResolvedTarget } from "./host-targets.js";
import {
@@ -18,8 +9,14 @@ import {
stepTimeout,
type StepExecutionContext,
type StepResult,
type StepRuntime,
} from "./types.js";
/** The admin allowlist of private hosts an HTTP step may reach. */
const PRIVATE_ALLOWLIST_KEY = "notification_private_endpoint_allowlist";
const errorText = (error: unknown) =>
error instanceof Error ? error.message : String(error);
/**
* One executor per step type.
*
@@ -30,22 +27,23 @@ import {
export async function executeStep(
step: Step,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
switch (step.type) {
case "notify":
return runNotify(step, context);
return runNotify(step, context, runtime);
case "http":
return runHttp(step, context);
return runHttp(step, context, runtime);
case "run_command":
return runCommand(step, context);
return runCommand(step, context, runtime);
case "run_snippet":
return runSnippet(step, context);
return runSnippet(step, context, runtime);
case "docker":
return runDocker(step, context);
return runDocker(step, context, runtime);
case "tunnel":
return runTunnel(step, context);
return runTunnel(step, context, runtime);
case "wol":
return runWol(step, context);
return runWol(step, context, runtime);
case "wait":
return runWait(step, context);
case "set_var":
@@ -64,6 +62,7 @@ export async function executeStep(
async function runNotify(
step: Extract<Step, { type: "notify" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const title = renderTemplate(step.title ?? "", context.template);
const body = renderTemplate(step.body ?? "", context.template);
@@ -77,49 +76,45 @@ async function runNotify(
);
}
const repository = createCurrentNotificationChannelRepository();
const channels = await repository.listNotificationChannels(context.userId);
const selected = channels.filter((channel) =>
step.channelIds.includes(channel.id),
);
if (selected.length === 0) {
return fail("Selected notification channels no longer exist");
const trigger = context.template.trigger ?? {};
let result;
try {
result = await runtime.ctx.notify.send(step.channelIds, {
title: title || "Termix automation",
body,
severity: step.severity ?? "warning",
context: {
hostId:
context.template.host?.id ??
(typeof trigger.hostId === "number" ? trigger.hostId : undefined),
hostName:
context.template.host?.name ??
(typeof trigger.hostName === "string" ? trigger.hostName : undefined),
sourceId: context.automationId,
sourceName: title || undefined,
triggerType:
typeof trigger.type === "string" ? trigger.type : undefined,
value: trigger.value,
threshold: trigger.threshold,
},
});
} catch (error) {
return fail(errorText(error));
}
let delivered = 0;
const errors: string[] = [];
for (const channel of selected) {
if (!channel.enabled) continue;
try {
await sendAutomationNotification(
{ id: channel.id, type: channel.type, config: channel.config },
{
title,
body,
severity: step.severity ?? "warning",
context: context.template,
},
);
delivered++;
} catch (error) {
errors.push(
`${channel.name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (delivered === 0) {
const errors = result.failures.map((f) => `${f.name}: ${f.error}`);
if (result.delivered === 0) {
return fail(errors.join("; ") || "No enabled channels to notify");
}
return ok(
`Notified ${delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
`Notified ${result.delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
);
}
async function runHttp(
step: Extract<Step, { type: "http" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const url = renderTemplate(step.url, context.template);
const headers = renderRecord(step.headers, context.template);
@@ -132,11 +127,20 @@ async function runHttp(
}
try {
const response = await automationFetch(url, {
// Private delivery needs both the step's opt-in and an exact host in
// the admin allowlist.
const allowPrivateHosts = step.allowPrivateNetwork
? parseAllowlist(
await runtime.ctx.settings.readCore(PRIVATE_ALLOWLIST_KEY),
)
: [];
const response = await runtime.ctx.fetch(url, {
method: step.method,
headers,
headers: body
? { "Content-Type": "application/json", ...(headers ?? {}) }
: headers,
body,
allowPrivateNetwork: step.allowPrivateNetwork,
allowPrivateHosts,
timeoutMs: stepTimeout(context, step.timeoutMs, DEFAULT_STEP_TIMEOUT_MS),
});
@@ -144,16 +148,31 @@ async function runHttp(
const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
return response.ok ? ok(summary) : fail(`HTTP ${response.status}`, summary);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
return fail(errorText(error));
}
}
function parseAllowlist(raw: unknown): string[] {
if (typeof raw !== "string" || !raw) return [];
try {
const value = JSON.parse(raw);
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
} catch {
return [];
}
}
async function runCommand(
step: Extract<Step, { type: "run_command" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const command = renderTemplate(step.command, context.template);
return runOnTargets(step.hostSelector, context, async (target) => {
return runOnTargets(step.hostSelector, context, runtime, async (target) => {
if (context.dryRun) {
return { output: `Would run on ${target.name}: ${command}` };
}
@@ -162,6 +181,7 @@ async function runCommand(
command,
step.elevated,
context,
runtime,
step.timeoutMs,
);
});
@@ -170,10 +190,20 @@ async function runCommand(
async function runSnippet(
step: Extract<Step, { type: "run_snippet" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const snippet = await getSnippet(context.userId, step.snippetId);
const snippets = runtime.deps.snippets();
const getSnippet = snippets.get;
const resolveCommand = snippets.resolveCommand;
if (
typeof getSnippet !== "function" ||
typeof resolveCommand !== "function"
) {
return fail("The snippets plugin is not available");
}
const snippet = await getSnippet(step.snippetId).catch(() => null);
if (!snippet) return fail("Snippet not found, or the snippets plugin is off");
if (!snippet) return fail("Snippet not found");
if (snippet.isNote) return fail("Notes cannot be executed on a host");
// Template values only ever reach the snippet through inputValues, never by
@@ -181,18 +211,17 @@ async function runSnippet(
// syntax of their own.
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
return runOnTargets(step.hostSelector, context, async (target) => {
const command = await resolveSnippetCommandFor(
context.userId,
return runOnTargets(step.hostSelector, context, runtime, async (target) => {
const command = await resolveCommand(
step.snippetId,
{
ip: target.host.ip,
username: target.host.username,
port: target.host.port,
name: target.host.name,
name: target.name,
},
inputValues,
);
).catch(() => null);
if (!command) {
return { output: "", error: "Snippet could not be resolved" };
}
@@ -205,6 +234,7 @@ async function runSnippet(
command,
step.elevated,
context,
runtime,
step.timeoutMs,
);
});
@@ -213,40 +243,51 @@ async function runSnippet(
async function runDocker(
step: Extract<Step, { type: "docker" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const container = renderTemplate(step.container, context.template);
if (!container) return fail("Container name is required");
const action = runtime.deps.docker().action;
if (typeof action !== "function") {
return fail("The docker plugin is not available");
}
return runOnTargets(step.hostSelector, context, async (target) => {
return runOnTargets(step.hostSelector, context, runtime, async (target) => {
if (context.dryRun) {
return {
output: `Would ${step.action} container ${container} on ${target.name}`,
};
}
const result = await runDockerAction(
context.userId,
target.id,
container,
step.action,
);
return result.ok
? { output: `Container ${container}: ${step.action} done` }
: { output: "", error: result.error };
try {
await action(target.id, container, step.action);
return { output: `Container ${container}: ${step.action} done` };
} catch (error) {
return { output: "", error: errorText(error) };
}
});
}
async function runTunnel(
step: Extract<Step, { type: "tunnel" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const name = renderTemplate(step.tunnelName, context.template);
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
const tunnels = runtime.deps.tunnels();
const act = step.action === "connect" ? tunnels.start : tunnels.stop;
if (typeof act !== "function") {
return fail("The tunnels plugin is not available");
}
// A disconnect goes through the tunnels plugin's manual stop, which also
// holds off its retries, so the tunnel stays down.
const result = await runTunnelAction(context.userId, step.action, name);
if (!result.ok) return fail(result.error);
try {
await act(name);
} catch (error) {
return fail(errorText(error));
}
return ok(
step.action === "connect"
? `Tunnel ${name} connected`
@@ -257,26 +298,23 @@ async function runTunnel(
async function runWol(
step: Extract<Step, { type: "wol" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const host = await resolveTargets(
{ kind: "host", hostId: step.hostId },
context,
);
const target = host.targets[0];
if (!target?.host) return fail("Host not found or not accessible");
const host = await runtime.ctx.hosts.get(step.hostId).catch(() => null);
if (!host) return fail("Host not found or not accessible");
const name = host.name || host.ip;
const mac = (target.host as { macAddress?: string }).macAddress;
if (!mac) return fail("Host has no MAC address configured");
if (context.dryRun) return ok(`Would wake ${target.name} (${mac})`);
const wake = runtime.deps.wakeOnLan().wake;
if (typeof wake !== "function") {
return fail("The wake-on-lan plugin is not available");
}
if (context.dryRun) return ok(`Would wake ${name}`);
try {
const { sendWakeOnLan, isValidMac } =
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}`);
await wake(step.hostId);
return ok(`Sent magic packet to ${name}`);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
return fail(errorText(error));
}
}
@@ -310,9 +348,10 @@ async function runSetVar(
async function runOnTargets(
selector: Extract<Step, { type: "run_command" }>["hostSelector"],
context: StepExecutionContext,
runtime: StepRuntime,
run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>,
): Promise<StepResult> {
const { targets, skipped } = await resolveTargets(selector, context);
const { targets, skipped } = await resolveTargets(selector, context, runtime);
if (targets.length === 0) {
return fail(
@@ -363,25 +402,27 @@ async function execOnHost(
command: string,
elevated: boolean | undefined,
context: StepExecutionContext,
runtime: StepRuntime,
timeoutMs: number | undefined,
): Promise<{ output?: string; error?: string }> {
const sshHost = host as ResolvedTarget["host"] & { sudoPassword?: string };
const timeout = stepTimeout(context, timeoutMs, DEFAULT_STEP_TIMEOUT_MS);
if (timeout <= 0) return { error: "Run deadline exceeded" };
try {
const result = await withSshConnection(
sshHost as never,
{ pool: "fleet", purpose: "fleet" },
async (client) => {
if (elevated) {
return execElevated(client, command, sshHost.sudoPassword, {
timeoutMs: timeout,
});
}
return execCommand(client, command, timeout);
},
);
const result = await runtime.ctx.ssh.withConnection<
{ stdout: string; stderr: string; code: number | null },
Client
>(host, { pool: "automations", purpose: "fleet" }, async (client) => {
if (elevated) {
return execElevated(
client,
command,
host.sudoPassword as string | undefined,
{ timeoutMs: timeout },
);
}
return execCommand(client, command, timeout);
});
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.code === 0 || result.code === null) {
@@ -389,6 +430,6 @@ async function execOnHost(
}
return { error: `Exited with code ${result.code}`, output };
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
return { error: errorText(error) };
}
}
@@ -1,4 +1,6 @@
import type { Step } from "../../../../../src/types/automations.js";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { Step } from "../../types.js";
import type { Deps } from "../deps.js";
import type { TemplateContext } from "../template.js";
/** What every executor returns. `output` is what later steps can read. */
@@ -12,6 +14,12 @@ export interface StepResult {
halt?: { status: "success" | "failed" };
}
/** What the executors reach the rest of Termix through. */
export interface StepRuntime {
ctx: PluginContext;
deps: Deps;
}
export interface StepExecutionContext {
userId: string;
automationId: number;
@@ -1,7 +1,4 @@
import type {
MetricPath,
Operator,
} from "../../../../src/types/automations.js";
import type { MetricPath, Operator } from "../types.js";
/**
* Operator evaluation and metric extraction.
+121
View File
@@ -0,0 +1,121 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
import type { DockerEventKind } from "../types.js";
/*
* Other plugins, as automations reaches them. Every call runs as the current
* actor (the automation's owner, set by the engine with ctx.asUser). A
* missing provider answers an empty handle, so each method is probed before
* it is called.
*/
export interface SnippetsAccessV1 {
list?: () => Promise<Array<{ id: number; name: string; isNote?: boolean }>>;
get: (id: number) => Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null>;
resolveCommand: (
id: number,
vars: { ip?: string; username?: string; port?: number; name?: string },
inputValues?: Record<string, string>,
) => Promise<string | null>;
}
export interface FleetsAccessV1 {
list: () => Promise<Array<{ id: number; name: string }>>;
members: (
fleetId: number,
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
}
export interface TunnelsAccessV1 {
start: (name: string) => Promise<void>;
stop: (name: string) => Promise<void>;
}
export interface DockerContainersV1 {
action: (
hostId: number,
container: string,
action: "start" | "stop" | "restart",
) => Promise<void>;
}
export interface DockerEvent {
hostId: number;
container: string;
event: DockerEventKind;
}
export interface DockerEventsV1 {
subscribe: (
hostId: number,
listener: (event: DockerEvent) => void,
) => Promise<() => void>;
}
export interface MetricsViewersV1 {
register: (
hostId: number,
) => Promise<{ viewerSessionId: string } | { skipped: true; reason: string }>;
heartbeat: (viewerSessionId: string) => boolean;
unregister: (hostId: number, viewerSessionId: string) => void;
}
/** Provided by the wake-on-lan plugin. It resolves the host's MAC itself. */
export interface WakeOnLanV1 {
wake: (hostId: number) => Promise<void>;
}
/** The plugin id behind each service, for "needs <plugin>" messages. */
export const PROVIDERS = {
snippets: "snippets.access",
fleets: "fleets.access",
tunnels: "tunnels.access",
docker: "docker.containers",
"docker-events": "docker.events",
"host-metrics": "host-metrics.viewers",
"wake-on-lan": "wake-on-lan.send",
} as const;
export type ProviderKey = keyof typeof PROVIDERS;
/** The plugin a provider key belongs to. */
export function pluginOf(key: ProviderKey): string {
return key === "docker-events" ? "docker" : key;
}
export type Deps = ReturnType<typeof createDeps>;
export function createDeps(services: PluginServices) {
function handle<T extends object>(name: string): Partial<T> {
try {
return services.get<T>(name) as Partial<T>;
} catch {
return {};
}
}
return {
/** Which providers are running now. */
available(key: ProviderKey): boolean {
return services.providers(PROVIDERS[key]).length > 0;
},
providers(): Record<ProviderKey, boolean> {
const result = {} as Record<ProviderKey, boolean>;
for (const key of Object.keys(PROVIDERS) as ProviderKey[]) {
result[key] = services.providers(PROVIDERS[key]).length > 0;
}
return result;
},
snippets: () => handle<SnippetsAccessV1>(PROVIDERS.snippets),
fleets: () => handle<FleetsAccessV1>(PROVIDERS.fleets),
tunnels: () => handle<TunnelsAccessV1>(PROVIDERS.tunnels),
docker: () => handle<DockerContainersV1>(PROVIDERS.docker),
dockerEvents: () => handle<DockerEventsV1>(PROVIDERS["docker-events"]),
viewers: () => handle<MetricsViewersV1>(PROVIDERS["host-metrics"]),
wakeOnLan: () => handle<WakeOnLanV1>(PROVIDERS["wake-on-lan"]),
};
}
@@ -1,7 +1,6 @@
import type { AutomationDefinition } from "../../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import { subscribeDockerEvents, type DockerEvent } from "./docker.js";
import { onDockerEvent } from "./triggers.js";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { Deps, DockerEvent } from "./deps.js";
import type { DockerEvent as TriggerDockerEvent } from "./triggers.js";
/**
* Keeps a docker.events subscription for every host a docker_event trigger
@@ -21,95 +20,71 @@ interface Watch {
subscribedAt: number;
}
const watches = new Map<string, Watch>();
export function createDockerWatcher(
ctx: Pick<PluginContext, "asUser">,
deps: Pick<Deps, "dockerEvents">,
watchedHosts: () => Promise<Map<number, string>>,
onEvent: (event: TriggerDockerEvent) => Promise<void>,
) {
const watches = new Map<string, Watch>();
const keyOf = (hostId: number, userId: string) => `${userId}:${hostId}`;
const keyOf = (hostId: number, userId: string) => `${userId}:${hostId}`;
/** Hosts named by an enabled docker_event trigger, with the owning user. */
export async function listDockerWatchedHosts(): Promise<Map<number, string>> {
const watched = new Map<number, string>();
try {
const rows = await createCurrentAutomationRepository().listAllEnabled();
for (const row of rows) {
let definition: AutomationDefinition;
try {
definition = JSON.parse(row.definition) as AutomationDefinition;
} catch {
continue;
}
const trigger = definition.trigger;
if (trigger?.kind !== "docker_event") continue;
const selector = trigger.hostSelector;
if (selector?.kind === "host") {
watched.set(selector.hostId, row.userId);
} else if (selector?.kind === "hosts") {
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
}
// Fleet and "all" selectors are deliberately not expanded: watching
// every host a user owns for container state is far too costly.
}
} catch {
return watched;
}
return watched;
}
/** Brings the subscriptions in line with the watched hosts. */
export async function reconcileDockerWatch(
now: number = Date.now(),
watched?: Map<number, string>,
): Promise<void> {
const wanted = watched ?? (await listDockerWatchedHosts());
const wantedKeys = new Set(
[...wanted].map(([hostId, userId]) => keyOf(hostId, userId)),
);
for (const [key, watch] of watches) {
if (wantedKeys.has(key)) continue;
watch.unsubscribe?.();
watches.delete(key);
}
for (const [hostId, userId] of wanted) {
const key = keyOf(hostId, userId);
let watch = watches.get(key);
if (!watch) {
watch = {
listener: (event) => {
void onDockerEvent({
hostId: event.hostId,
ownerUserId: userId,
container: event.container,
event: event.event,
}).catch(() => undefined);
},
unsubscribe: null,
subscribedAt: 0,
};
watches.set(key, watch);
}
if (watch.subscribedAt && now - watch.subscribedAt < RESUBSCRIBE_MS) {
continue;
}
try {
watch.unsubscribe = await subscribeDockerEvents(
userId,
hostId,
watch.listener,
return {
/** Brings the subscriptions in line with the watched hosts. */
async reconcile(now: number = Date.now()): Promise<void> {
const wanted = await watchedHosts();
const wantedKeys = new Set(
[...wanted].map(([hostId, userId]) => keyOf(hostId, userId)),
);
watch.subscribedAt = watch.unsubscribe ? now : 0;
} catch {
watch.subscribedAt = 0;
}
}
}
/** Drops every subscription, for shutdown and tests. */
export function resetDockerWatcher(): void {
for (const watch of watches.values()) watch.unsubscribe?.();
watches.clear();
for (const [key, watch] of watches) {
if (wantedKeys.has(key)) continue;
watch.unsubscribe?.();
watches.delete(key);
}
for (const [hostId, userId] of wanted) {
const key = keyOf(hostId, userId);
let watch = watches.get(key);
if (!watch) {
watch = {
listener: (event) => {
void onEvent({
hostId: event.hostId,
ownerUserId: userId,
container: event.container,
event: event.event,
}).catch(() => undefined);
},
unsubscribe: null,
subscribedAt: 0,
};
watches.set(key, watch);
}
if (watch.subscribedAt && now - watch.subscribedAt < RESUBSCRIBE_MS) {
continue;
}
const subscribe = deps.dockerEvents().subscribe;
if (typeof subscribe !== "function") {
watch.subscribedAt = 0;
continue;
}
try {
const current = watch;
watch.unsubscribe = await ctx.asUser(userId, () =>
subscribe(hostId, current.listener),
);
watch.subscribedAt = now;
} catch {
watch.subscribedAt = 0;
}
}
},
/** Drops every subscription, on deactivate. */
reset(): void {
for (const watch of watches.values()) watch.unsubscribe?.();
watches.clear();
},
};
}
-85
View File
@@ -1,85 +0,0 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
let current: PluginServices | null = null;
/** Set in activate, cleared on deactivate. */
export function setDockerServices(services: PluginServices | null): void {
current = services;
}
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
export interface DockerEvent {
hostId: number;
container: string;
event: DockerEventName;
}
/** The docker plugin's "docker.containers" service, version 1. */
interface DockerService {
action: (
hostId: number,
container: string,
action: "start" | "stop" | "restart",
) => Promise<void>;
}
/** The docker plugin's "docker.events" service, version 1. */
interface DockerEventsService {
subscribe: (
hostId: number,
listener: (event: DockerEvent) => void,
) => Promise<() => void>;
}
const UNAVAILABLE = "The docker plugin is not available";
function service<T extends object>(
name: string,
userId: string,
): Partial<T> | null {
try {
return current?.get<T>(name, { userId }) ?? null;
} catch {
return null;
}
}
/**
* Starts, stops or restarts a container as userId. Optional: without the
* docker plugin the step fails with a clear message.
*/
export async function runDockerAction(
userId: string,
hostId: number,
container: string,
action: "start" | "stop" | "restart",
): Promise<{ ok: boolean; unavailable?: boolean; error?: string }> {
const docker = service<DockerService>("docker.containers", userId);
if (!docker || typeof docker.action !== "function") {
return { ok: false, unavailable: true, error: UNAVAILABLE };
}
try {
await docker.action(hostId, container, action);
return { ok: true };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Subscribes `listener` to container changes on a host as userId, or
* resolves null while the docker plugin is off.
*/
export async function subscribeDockerEvents(
userId: string,
hostId: number,
listener: (event: DockerEvent) => void,
): Promise<(() => void) | null> {
const events = service<DockerEventsService>("docker.events", userId);
if (!events || typeof events.subscribe !== "function") return null;
return events.subscribe(hostId, listener);
}
+91 -59
View File
@@ -1,22 +1,24 @@
import type {
AutomationDefinition,
RunStatus,
Step,
} from "../../../../src/types/automations.js";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition, RunStatus, Step } from "../types.js";
import {
DEFAULT_MAX_RUN_SECONDS,
MAX_AUTOMATION_DEPTH,
MAX_STEP_OUTPUT_BYTES,
} 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";
} from "../types.js";
import { executeStep } from "./actions/index.js";
import type { StepExecutionContext, StepResult } from "./actions/types.js";
import { compare } from "./conditions.js";
import type { Deps } from "./deps.js";
import type {
AutomationEngineRow,
AutomationRepository,
} from "./repository.js";
import { missingPlugins } from "./requirements.js";
import { renderTemplate, type TemplateContext } from "./template.js";
/** Emitted when a run fails, so an automation_failed trigger can react. */
export const TOPIC_AUTOMATION_FAILED = "plugin.automations.automation_failed";
export interface RunRequest {
automationId: number;
triggerType: string;
@@ -36,38 +38,53 @@ export interface RunOutcome {
}
/**
* Executes automations.
*
* Both HTTP handlers and the metrics hooks live in this same process, so this
* is a plain singleton rather than anything cross-process. State that has to
* survive a restart (cooldowns, dwell windows) lives in the database; the only
* thing held in memory is the set of runs currently in flight, which is
* meaningless after a restart anyway.
* Executes automations. One per activation. State that has to survive a
* restart (cooldowns, dwell windows) lives in the database; the only thing
* held in memory is the set of runs in flight, which is meaningless after a
* restart anyway.
*/
export class AutomationEngine {
private static instance: AutomationEngine;
private readonly running = new Set<number>();
private readonly queued = new Map<number, number>();
static getInstance(): AutomationEngine {
if (!AutomationEngine.instance) {
AutomationEngine.instance = new AutomationEngine();
}
return AutomationEngine.instance;
}
constructor(
private readonly ctx: PluginContext,
private readonly repository: AutomationRepository,
private readonly deps: Deps,
) {}
isRunning(automationId: number): boolean {
return this.running.has(automationId);
}
/**
* Runs an automation as its owner. Never throws: a failure is the outcome.
*/
async run(request: RunRequest): Promise<RunOutcome> {
const repository = createCurrentAutomationRepository();
const automation = await repository.findById(request.automationId);
let automation: AutomationEngineRow | null;
try {
automation = await this.repository.findById(request.automationId);
} catch (err) {
return { runId: null, status: "failed", error: errorText(err) };
}
if (!automation) {
return { runId: null, status: "failed", error: "Automation not found" };
}
const owner = automation;
try {
return await this.ctx.asUser(owner.userId, () =>
this.runAsOwner(owner, request),
);
} catch (err) {
return { runId: null, status: "failed", error: errorText(err) };
}
}
private async runAsOwner(
automation: AutomationEngineRow,
request: RunRequest,
): Promise<RunOutcome> {
const repository = this.repository;
const depth = request.depth ?? 0;
const ancestry = request.ancestry ?? [];
@@ -99,6 +116,30 @@ export class AutomationEngine {
};
}
// A trigger or step whose plugin is off skips the whole run with the
// reason on record, rather than running half of it.
const missing = missingPlugins(definition, this.deps);
if (missing.length > 0) {
const reason = `Needs the ${missing.join(", ")} plugin${missing.length > 1 ? "s" : ""}`;
this.ctx.log.info(
`Skipping automation ${automation.id} (${request.triggerType}): ${reason}`,
);
const run = await repository.createRun({
automationId: automation.id,
userId: automation.userId,
triggerType: request.triggerType,
triggerContext: JSON.stringify(request.triggerContext ?? {}),
status: "skipped",
parentRunId: request.parentRunId ?? null,
});
await repository.finishRun(run.id, {
status: "skipped",
error: reason,
durationMs: 0,
});
return { runId: run.id, status: "skipped", error: reason };
}
// A second trigger while a run is in flight is recorded as skipped rather
// than dropped silently, so the history explains what happened.
//
@@ -182,10 +223,7 @@ export class AutomationEngine {
let host: TemplateContext["host"];
if (request.triggerHostId) {
try {
const resolved = await resolveHostById(
request.triggerHostId,
automation.userId,
);
const resolved = await this.ctx.hosts.get(request.triggerHostId);
if (resolved) {
host = {
id: request.triggerHostId,
@@ -245,7 +283,7 @@ export class AutomationEngine {
}
} catch (err) {
status = "failed";
error = err instanceof Error ? err.message : String(err);
error = errorText(err);
} finally {
this.running.delete(automation.id);
}
@@ -257,31 +295,21 @@ export class AutomationEngine {
});
if (status === "failed") {
statsLogger.warn(`Automation "${automation.name}" failed`, {
operation: "automation_run_failed",
automationId: automation.id,
runId: run.id,
error,
});
this.ctx.log.warn(
`Automation "${automation.name}" (${automation.id}) failed in run ${run.id}: ${error ?? "unknown error"}`,
);
// An automation_failed handler that itself fails must not re-announce
// its own failure, so the event is not emitted for runs that this event
// already started.
if (request.triggerType !== "internal_event") {
// Emitted onto the bus rather than imported from hosts/: the bus is
// fire-and-forget, so this no longer needs the lazy-import dance that
// was working around the repositories cycle.
notifyAutomationInternalEvent(
"automation_failed",
automation.userId,
undefined,
{
automationId: automation.id,
automationName: automation.name,
runId: run.id,
error: error ?? null,
},
);
this.ctx.events.emit(TOPIC_AUTOMATION_FAILED, {
userId: automation.userId,
automationId: automation.id,
automationName: automation.name,
runId: run.id,
error: error ?? null,
});
}
}
@@ -301,7 +329,7 @@ export class AutomationEngine {
error?: string;
halted?: { status: "success" | "failed" };
}> {
const repository = createCurrentAutomationRepository();
const repository = this.repository;
for (const step of steps) {
if (step.enabled === false) continue;
@@ -382,12 +410,12 @@ export class AutomationEngine {
let result: StepResult;
try {
result = await executeStep(step, context);
result = await executeStep(step, context, {
ctx: this.ctx,
deps: this.deps,
});
} catch (err) {
result = {
success: false,
error: err instanceof Error ? err.message : String(err),
};
result = { success: false, error: errorText(err) };
}
const { text, truncated } = truncate(result.output);
@@ -432,6 +460,10 @@ export class AutomationEngine {
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** Keeps a single step's output from bloating the database. */
function truncate(output: string | undefined): {
text: string | null;
@@ -1,105 +1,101 @@
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { listAutomationWatchedHosts } from "./triggers.js";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { Deps } from "./deps.js";
/**
* Keeps metric collection running for hosts an automation watches.
*
* Heavy metric collection is normally started by a UI viewer and stops when
* the last one leaves, which means threshold rules only ever evaluated while
* somebody had the host open. Automations register a synthetic viewer instead
* of bypassing that mechanism, so a real viewer arriving or leaving still
* behaves exactly as before.
*
* The catch is `cleanupInactiveViewers`, which drops any viewer whose
* heartbeat is older than 120s. Without the heartbeat below, headless polling
* would quietly stop two minutes after it started.
* Host metrics only polls hosts somebody is looking at. Automations register
* as a viewer through the host-metrics.viewers service, as the automation's
* owner, and heartbeat every tick so the viewer is not reaped. A heartbeat
* that comes back false means host-metrics restarted and forgot the viewer,
* so the next tick registers it again.
*/
export interface ViewerRegistry {
registerViewer(hostId: number, sessionId: string, userId: string): void;
unregisterViewer(hostId: number, sessionId: string): void;
updateHeartbeat(sessionId: string): boolean;
interface Registered {
userId: string;
viewerSessionId: string;
}
const SESSION_PREFIX = "automation:";
let registry: ViewerRegistry | null = null;
const registered = new Map<number, string>();
export function setViewerRegistry(next: ViewerRegistry | null): void {
registry = next;
}
export function automationSessionId(hostId: number): string {
return `${SESSION_PREFIX}${hostId}`;
}
/**
* Brings the set of synthetic viewers in line with what the enabled
* automations currently watch, and heartbeats the ones that stay.
*/
export async function reconcileHeadlessViewers(): Promise<{
added: number;
removed: number;
active: number;
}> {
if (!registry) return { added: 0, removed: 0, active: 0 };
let watched: Map<number, string>;
try {
watched = await listAutomationWatchedHosts();
} catch {
return { added: 0, removed: 0, active: registered.size };
}
let added = 0;
let removed = 0;
for (const [hostId, userId] of watched) {
const sessionId = automationSessionId(hostId);
if (registered.has(hostId)) {
// Refresh before the 120s reaper would take it.
registry.updateHeartbeat(sessionId);
continue;
}
export function createHeadlessViewers(
ctx: Pick<PluginContext, "asUser" | "log">,
deps: Pick<Deps, "viewers">,
watchedHosts: () => Promise<Map<number, string>>,
) {
const registered = new Map<number, Registered>();
async function release(hostId: number, entry: Registered): Promise<void> {
registered.delete(hostId);
const unregister = deps.viewers().unregister;
if (typeof unregister !== "function") return;
try {
registry.registerViewer(hostId, sessionId, userId);
registered.set(hostId, userId);
added++;
} catch (error) {
statsLogger.warn("Could not start headless metrics for a host", {
operation: "automation_headless_register_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
for (const hostId of [...registered.keys()]) {
if (watched.has(hostId)) continue;
try {
registry.unregisterViewer(hostId, automationSessionId(hostId));
await ctx.asUser(entry.userId, () =>
unregister(hostId, entry.viewerSessionId),
);
} catch {
// Already gone; drop it either way.
}
registered.delete(hostId);
removed++;
}
return { added, removed, active: registered.size };
}
/** Drops every synthetic viewer, for shutdown and tests. */
export function releaseHeadlessViewers(): void {
if (registry) {
for (const hostId of registered.keys()) {
try {
registry.unregisterViewer(hostId, automationSessionId(hostId));
} catch {
// Nothing useful to do during teardown.
return {
/** Brings the viewers in line with what the automations watch. */
async reconcile(): Promise<{ added: number; removed: number }> {
const viewers = deps.viewers();
if (typeof viewers.register !== "function") {
// host-metrics is off: its viewers went with it.
registered.clear();
return { added: 0, removed: 0 };
}
}
}
registered.clear();
const watched = await watchedHosts();
let added = 0;
let removed = 0;
for (const [hostId, entry] of [...registered]) {
if (watched.get(hostId) === entry.userId) continue;
await release(hostId, entry);
removed++;
}
for (const [hostId, userId] of watched) {
const existing = registered.get(hostId);
if (existing) {
const alive = await ctx
.asUser(userId, () => viewers.heartbeat!(existing.viewerSessionId))
.catch(() => false);
if (alive) continue;
registered.delete(hostId);
}
try {
const result = await ctx.asUser(userId, () =>
viewers.register!(hostId),
);
if ("viewerSessionId" in result) {
registered.set(hostId, {
userId,
viewerSessionId: result.viewerSessionId,
});
added++;
}
} catch (error) {
ctx.log.warn(
`Could not start headless metrics for host ${hostId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
return { added, removed };
},
/** Drops every viewer, on deactivate. */
async releaseAll(): Promise<void> {
for (const [hostId, entry] of [...registered]) {
await release(hostId, entry);
}
},
registeredHosts: () => [...registered.keys()],
};
}
-79
View File
@@ -1,79 +0,0 @@
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.
*
* safeOutboundFetch refuses private and loopback addresses, which is the right
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
* on a LAN that many installs actually use. Private delivery therefore needs
* both a channel opt-in and an exact host in the administrator allowlist;
* scheme validation, DNS pinning and redirect refusal remain in force.
*/
export interface AutomationFetchOptions {
method?: string;
headers?: Record<string, string>;
body?: string;
allowPrivateNetwork?: boolean;
timeoutMs?: number;
}
export async function automationFetch(
url: string,
options: AutomationFetchOptions = {},
): Promise<Response> {
const {
method = "GET",
headers,
body,
allowPrivateNetwork,
timeoutMs = 30_000,
} = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 1));
const init: RequestInit = {
method,
headers: body
? { "Content-Type": "application/json", ...(headers ?? {}) }
: headers,
body,
signal: controller.signal,
};
try {
if (allowPrivateNetwork) {
return await privateNetworkFetch(url, init);
}
return await safeOutboundFetch(url, init);
} finally {
clearTimeout(timer);
}
}
/**
* The opt-in path still goes through the guarded resolver. Only an exact host
* authorized by an administrator may resolve to a private address.
*/
async function privateNetworkFetch(
rawUrl: string,
init: RequestInit,
): Promise<Response> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error("Invalid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Only http and https URLs are allowed");
}
if (parsed.username || parsed.password) {
throw new Error("URLs with embedded credentials are not allowed");
}
const allowlist = await readNotificationPrivateAllowlist();
return safeOutboundFetch(rawUrl, init, allowlist);
}
+48 -50
View File
@@ -1,63 +1,61 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { setPluginServices } from "./snippets.js";
import { setTunnelServices } from "./tunnels.js";
import { setDockerServices } from "./docker.js";
import { startAutomationsService, stopAutomationsService } from "./routes.js";
import {
startAutomationScheduler,
stopAutomationScheduler,
} from "./scheduler.js";
import { onInternalEvent } from "./triggers.js";
import { createDeps } from "./deps.js";
import { createDockerWatcher } from "./docker-watcher.js";
import { AutomationEngine } from "./engine.js";
import { createHeadlessViewers } from "./headless-viewer.js";
import { createAutomationRepository } from "./repository.js";
import { registerRoutes } from "./routes.js";
import { createScheduler, STARTUP_DELAY_MS, TICK_MS } from "./scheduler.js";
import { createAutomationsService } from "./service.js";
import { createTriggers } from "./triggers.js";
/** What the tunnels plugin emits when a tunnel drops without being asked to. */
interface TunnelDisconnectedEvent {
userId?: string;
hostId?: number;
tunnelName?: string;
}
export type { AutomationsAccessV1, AutomationSummary } from "./service.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
setPluginServices(ctx.services);
ctx.disposables.add(() => setPluginServices(null));
setTunnelServices(ctx.services);
ctx.disposables.add(() => setTunnelServices(null));
setDockerServices(ctx.services);
ctx.disposables.add(() => setDockerServices(null));
const repository = await createAutomationRepository(ctx.db, async () => {
const channels = await ctx.notify.channels();
return channels.map((channel) => channel.id);
});
const deps = createDeps(ctx.services);
const engine = new AutomationEngine(ctx, repository, deps);
const triggers = createTriggers(repository, engine, ctx.log);
triggers.subscribe(ctx.events);
// Nothing arrives here while the tunnels plugin is off, which is the whole
// of the optional dependency.
ctx.events.on("plugin.tunnels.tunnel_disconnected", (payload) => {
const event = payload as TunnelDisconnectedEvent;
if (!event?.userId) return;
void onInternalEvent({
event: "tunnel_disconnected",
userId: event.userId,
hostId: event.hostId,
details: { tunnelName: event.tunnelName },
}).catch((error: unknown) => {
ctx.log.warn(
`Tunnel disconnect trigger failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
const viewers = createHeadlessViewers(ctx, deps, () =>
triggers.watchedHosts("metric_threshold"),
);
const docker = createDockerWatcher(
ctx,
deps,
() => triggers.watchedHosts("docker_event"),
triggers.onDockerEvent,
);
ctx.disposables.add(() => {
docker.reset();
void viewers.releaseAll();
});
startAutomationsService(
const scheduler = createScheduler({
repository,
engine,
log: ctx.log,
reconcile: [() => viewers.reconcile(), (now) => docker.reconcile(now)],
});
ctx.schedule.after(STARTUP_DELAY_MS, () => scheduler.tick());
ctx.schedule.every(TICK_MS, () => scheduler.tick());
registerRoutes(
// The webhook route authenticates on its own per-automation token rather
// than a session, which is the point of an inbound webhook.
ctx.http.router({ public: ["/webhook/:token"] }),
ctx.http.router<Router>({ public: ["/webhook/:token"] }),
{ ctx, repository, engine, deps },
);
startAutomationScheduler();
ctx.log.info(
"Automations routes mounted at /plugin-api/automations; scheduler started",
ctx.services.provide(
"automations.access",
createAutomationsService(ctx, repository, engine, deps),
);
}
export async function deactivate() {
stopAutomationScheduler();
stopAutomationsService();
}
export async function deactivate() {}
-193
View File
@@ -1,193 +0,0 @@
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { automationFetch } from "./http.js";
import type { TemplateContext } from "./template.js";
/**
* Notification delivery for automations.
*
* The alert engine special-cased Discord because its dispatcher only knew
* webhook and ntfy; here every transport goes through one switch, so adding a
* channel type is a single edit.
*/
export interface AutomationChannel {
id: number;
type: string;
config: string;
}
export interface AutomationNotification {
title: string;
body: string;
severity: "info" | "warning" | "critical";
context?: TemplateContext;
}
const NTFY_PRIORITY: Record<string, number> = {
info: 2,
warning: 3,
critical: 5,
};
const NTFY_TAGS: Record<string, string> = {
info: "information_source",
warning: "warning",
critical: "rotating_light",
};
const DISCORD_COLORS: Record<string, number> = {
info: 3066993,
warning: 16753920,
critical: 15158332,
};
export async function sendAutomationNotification(
channel: AutomationChannel,
notification: AutomationNotification,
): Promise<void> {
let config: Record<string, unknown>;
try {
config = JSON.parse(channel.config) as Record<string, unknown>;
} catch {
throw new Error("Channel configuration is not valid JSON");
}
const allowPrivateNetwork = config.allowPrivateNetwork === true;
switch (channel.type) {
case "webhook":
return sendWebhook(config, notification, allowPrivateNetwork);
case "ntfy":
return sendNtfy(config, notification, allowPrivateNetwork);
case "discord":
return sendDiscord(config, notification, allowPrivateNetwork);
default:
throw new Error(`Unsupported channel type: ${channel.type}`);
}
}
function requireUrl(config: Record<string, unknown>): string {
const url = typeof config.url === "string" ? config.url.trim() : "";
if (!url) throw new Error("Channel is missing a URL");
return url;
}
async function sendWebhook(
config: Record<string, unknown>,
notification: AutomationNotification,
allowPrivateNetwork: boolean,
): Promise<void> {
const url = requireUrl(config);
const method = config.method === "PUT" ? "PUT" : "POST";
const headers =
config.headers && typeof config.headers === "object"
? (config.headers as Record<string, string>)
: {};
const response = await automationFetch(url, {
method,
headers,
body: JSON.stringify({
title: notification.title,
hostName:
notification.context?.host?.name ??
notification.context?.trigger?.hostName,
hostId:
notification.context?.host?.id ?? notification.context?.trigger?.hostId,
ruleName: notification.title,
ruleId: notification.context?.run?.automationId,
triggerType: notification.context?.trigger?.type,
value: notification.context?.trigger?.value,
threshold: notification.context?.trigger?.threshold,
message: notification.body,
severity: notification.severity,
timestamp: new Date().toISOString(),
}),
allowPrivateNetwork,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
}
async function sendNtfy(
config: Record<string, unknown>,
notification: AutomationNotification,
allowPrivateNetwork: boolean,
): Promise<void> {
const base = requireUrl(config).replace(/\/$/, "");
const topic = typeof config.topic === "string" ? config.topic.trim() : "";
if (!topic) throw new Error("ntfy channel is missing a topic");
const headers: Record<string, string> = {
Title: notification.title || "Termix automation",
Priority: String(NTFY_PRIORITY[notification.severity] ?? 3),
Tags: NTFY_TAGS[notification.severity] ?? "information_source",
};
if (typeof config.token === "string" && config.token) {
headers.Authorization = `Bearer ${config.token}`;
}
const response = await automationFetch(`${base}/${topic}`, {
method: "POST",
headers,
body: notification.body || notification.title,
allowPrivateNetwork,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
}
async function sendDiscord(
config: Record<string, unknown>,
notification: AutomationNotification,
allowPrivateNetwork: boolean,
): Promise<void> {
const url = requireUrl(config);
const payload: Record<string, unknown> = {
embeds: [
{
title: notification.title || "Termix automation",
description: notification.body || undefined,
color: DISCORD_COLORS[notification.severity] ?? 3447003,
timestamp: new Date().toISOString(),
},
],
};
if (typeof config.username === "string" && config.username) {
payload.username = config.username;
}
if (typeof config.avatar_url === "string" && config.avatar_url) {
payload.avatar_url = config.avatar_url;
}
const response = await automationFetch(url, {
method: "POST",
body: JSON.stringify(payload),
allowPrivateNetwork,
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`,
);
}
}
/** Fire-and-forget wrapper for callers that must not block on delivery. */
export function sendAutomationNotificationSafely(
channel: AutomationChannel,
notification: AutomationNotification,
): void {
sendAutomationNotification(channel, notification).catch((error) => {
statsLogger.warn("Automation notification failed", {
operation: "automation_notification_error",
channelId: channel.id,
type: channel.type,
error: error instanceof Error ? error.message : String(error),
});
});
}
@@ -0,0 +1,738 @@
import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
import {
automations as automationsDef,
channels as channelsDef,
runSteps as runStepsDef,
runs as runsDef,
schedules as schedulesDef,
triggerState as triggerStateDef,
} from "./tables.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// Tables come from ctx.db.define, which the SDK hands back untyped, and the
// drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
type Row = Record<string, any>;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface AutomationRow {
id: number;
user_id: string;
name: string;
description: string | null;
enabled: number;
definition: string;
definition_version: number;
concurrency_policy: string;
max_run_seconds: number;
dry_run: number;
last_run_at: string | null;
last_run_status: string | null;
created_at: string;
updated_at: string;
channels: number[];
}
export interface AutomationRunRow {
id: number;
automation_id: number;
user_id: string;
trigger_type: string;
trigger_context: string | null;
status: string;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
error: string | null;
dry_run: number;
parent_run_id: number | null;
automation_name?: string | null;
}
export interface AutomationRunStepRow {
id: number;
run_id: number;
step_index: number;
step_id: string;
step_type: string;
status: string;
started_at: string;
finished_at: string | null;
output: string | null;
error: string | null;
truncated: number;
}
/** The shape the engine loads. */
export interface AutomationEngineRow {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
export interface TriggerStateRow {
automationId: number;
stateKey: string;
breachStartedAt: string | null;
lastFiredAt: string | null;
lastValue: number | null;
lastObservedState: string | null;
}
export interface DueScheduleRow {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}
export type AutomationRepository = Awaited<
ReturnType<typeof createAutomationRepository>
>;
/**
* Automation storage. `ownedChannelIds` answers which of core's notification
* channels the user owns, so a link can never point at another user's.
*/
export async function createAutomationRepository(
db: PluginDatabase,
ownedChannelIds: (userId: string) => Promise<number[]>,
) {
const automations: Table = await db.define(automationsDef);
const triggerState: Table = await db.define(triggerStateDef);
const schedules: Table = await db.define(schedulesDef);
const runs: Table = await db.define(runsDef);
const runSteps: Table = await db.define(runStepsDef);
const channels: Table = await db.define(channelsDef);
const client = () => db.client<Drizzle>();
/** Inserts one row and returns its id. MySQL has no RETURNING. */
async function insertId(
table: Table,
values: Record<string, unknown>,
): Promise<number> {
const drizzle = await client();
if (db.dialect === "mysql") {
const result = await drizzle.insert(table).values(values);
const header = Array.isArray(result) ? result[0] : result;
return Number(header?.insertId);
}
const rows = await drizzle
.insert(table)
.values(values)
.returning({ id: table.id });
return rows[0].id as number;
}
async function listChannelIds(automationId: number): Promise<number[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select({ channelId: channels.channelId })
.from(channels)
.where(eq(channels.automationId, automationId));
return rows.map((row) => row.channelId as number);
}
async function replaceChannels(
automationId: number,
userId: string,
channelIds: number[],
): Promise<void> {
const drizzle = await client();
await drizzle
.delete(channels)
.where(eq(channels.automationId, automationId));
if (channelIds.length === 0) return;
const owned = new Set(await ownedChannelIds(userId));
const linked = [...new Set(channelIds)].filter((id) => owned.has(id));
if (linked.length > 0) {
await drizzle
.insert(channels)
.values(linked.map((channelId) => ({ automationId, channelId })));
}
}
async function findForUser(
id: number,
userId: string,
): Promise<AutomationRow | null> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)))
.limit(1);
if (!rows[0]) return null;
return mapAutomationRow(rows[0], await listChannelIds(id));
}
async function getTriggerState(
automationId: number,
stateKey: string,
): Promise<TriggerStateRow | null> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(triggerState)
.where(
and(
eq(triggerState.automationId, automationId),
eq(triggerState.stateKey, stateKey),
),
)
.limit(1);
return rows[0] ? mapTriggerStateRow(rows[0]) : null;
}
return {
async list(userId: string): Promise<AutomationRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(eq(automations.userId, userId))
.orderBy(asc(automations.name));
if (rows.length === 0) return [];
// One query for every automation's channels rather than one per row.
const links: Row[] = await drizzle
.select({
automationId: channels.automationId,
channelId: channels.channelId,
})
.from(channels)
.where(
inArray(
channels.automationId,
rows.map((row) => row.id),
),
);
const byAutomation = new Map<number, number[]>();
for (const link of links) {
const list = byAutomation.get(link.automationId) ?? [];
list.push(link.channelId);
byAutomation.set(link.automationId, list);
}
return rows.map((row) => mapAutomationRow(row, byAutomation.get(row.id)));
},
findForUser,
async findById(id: number): Promise<AutomationEngineRow | null> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(eq(automations.id, id))
.limit(1);
return rows[0] ? mapEngineRow(rows[0]) : null;
},
async create(input: {
userId: string;
name: string;
description?: string | null;
enabled?: boolean;
definition: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
}): Promise<AutomationRow> {
const now = new Date().toISOString();
const id = await insertId(automations, {
userId: input.userId,
name: input.name,
description: input.description ?? null,
enabled: input.enabled ?? true,
definition: input.definition,
concurrencyPolicy: input.concurrencyPolicy ?? "skip",
maxRunSeconds: input.maxRunSeconds ?? 300,
dryRun: input.dryRun ?? false,
createdAt: now,
updatedAt: now,
});
await replaceChannels(id, input.userId, input.channels ?? []);
await db.persist();
const created = await findForUser(id, input.userId);
if (!created) throw new Error("Automation could not be read back");
return created;
},
async update(
id: number,
userId: string,
input: {
name?: string;
description?: string | null;
enabled?: boolean;
definition?: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
},
): Promise<AutomationRow | null> {
const existing = await findForUser(id, userId);
if (!existing) return null;
const { channels: channelIds, ...fields } = input;
const drizzle = await client();
if (Object.keys(fields).length > 0) {
await drizzle
.update(automations)
.set({ ...fields, updatedAt: new Date().toISOString() })
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
}
if (channelIds) await replaceChannels(id, userId, channelIds);
await db.persist();
return findForUser(id, userId);
},
async delete(id: number, userId: string): Promise<boolean> {
const existing = await findForUser(id, userId);
if (!existing) return false;
const drizzle = await client();
await drizzle
.delete(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
await db.persist();
return true;
},
/**
* Enabled automations owned by one user. Wildcard targets must never
* reach across users.
*/
async listEnabledForUser(userId: string): Promise<AutomationEngineRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(
and(eq(automations.enabled, true), eq(automations.userId, userId)),
);
return rows.map(mapEngineRow);
},
async listAllEnabled(): Promise<AutomationEngineRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(eq(automations.enabled, true));
return rows.map(mapEngineRow);
},
getTriggerState,
async upsertTriggerState(input: {
automationId: number;
stateKey: string;
breachStartedAt?: string | null;
lastFiredAt?: string | null;
lastValue?: number | null;
lastObservedState?: string | null;
}): Promise<void> {
const existing = await getTriggerState(
input.automationId,
input.stateKey,
);
const updatedAt = new Date().toISOString();
const drizzle = await client();
if (existing) {
const values: Record<string, unknown> = { updatedAt };
if (input.breachStartedAt !== undefined)
values.breachStartedAt = input.breachStartedAt;
if (input.lastFiredAt !== undefined)
values.lastFiredAt = input.lastFiredAt;
if (input.lastValue !== undefined) values.lastValue = input.lastValue;
if (input.lastObservedState !== undefined)
values.lastObservedState = input.lastObservedState;
await drizzle
.update(triggerState)
.set(values)
.where(
and(
eq(triggerState.automationId, input.automationId),
eq(triggerState.stateKey, input.stateKey),
),
);
} else {
await drizzle.insert(triggerState).values({
automationId: input.automationId,
stateKey: input.stateKey,
breachStartedAt: input.breachStartedAt ?? null,
lastFiredAt: input.lastFiredAt ?? null,
lastValue: input.lastValue ?? null,
lastObservedState: input.lastObservedState ?? null,
updatedAt,
});
}
await db.persist({ lazy: true });
},
async clearBreach(automationId: number, stateKey: string): Promise<void> {
const drizzle = await client();
await drizzle
.update(triggerState)
.set({ breachStartedAt: null, updatedAt: new Date().toISOString() })
.where(
and(
eq(triggerState.automationId, automationId),
eq(triggerState.stateKey, stateKey),
),
);
await db.persist({ lazy: true });
},
/** Dwell windows the scheduler has to re-check without a fresh sample. */
async listOpenBreaches(): Promise<TriggerStateRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(triggerState)
.where(sql`${triggerState.breachStartedAt} IS NOT NULL`);
return rows.map(mapTriggerStateRow);
},
async upsertSchedule(input: {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}): Promise<void> {
const drizzle = await client();
const existing: Row[] = await drizzle
.select({ id: schedules.id })
.from(schedules)
.where(eq(schedules.automationId, input.automationId))
.limit(1);
if (existing[0]) {
await drizzle
.update(schedules)
.set({
cron: input.cron,
intervalSeconds: input.intervalSeconds,
timezone: input.timezone,
nextDueAt: input.nextDueAt,
})
.where(eq(schedules.automationId, input.automationId));
} else {
await drizzle.insert(schedules).values(input);
}
await db.persist();
},
async deleteSchedule(automationId: number): Promise<void> {
const drizzle = await client();
await drizzle
.delete(schedules)
.where(eq(schedules.automationId, automationId));
await db.persist();
},
/** Schedules due at or before `now`, joined to their enabled automation. */
async listDueSchedules(now: string): Promise<DueScheduleRow[]> {
const drizzle = await client();
return drizzle
.select({
automationId: schedules.automationId,
cron: schedules.cron,
intervalSeconds: schedules.intervalSeconds,
timezone: schedules.timezone,
nextDueAt: schedules.nextDueAt,
})
.from(schedules)
.innerJoin(automations, eq(automations.id, schedules.automationId))
.where(
and(eq(automations.enabled, true), lte(schedules.nextDueAt, now)),
);
},
async markScheduleTicked(
automationId: number,
nextDueAt: string | null,
lastTickAt: string,
): Promise<void> {
const drizzle = await client();
await drizzle
.update(schedules)
.set({ nextDueAt, lastTickAt })
.where(eq(schedules.automationId, automationId));
await db.persist({ lazy: true });
},
async createRun(input: {
automationId: number;
userId: string;
triggerType: string;
triggerContext?: string | null;
status: string;
dryRun?: boolean;
parentRunId?: number | null;
}): Promise<{ id: number }> {
const id = await insertId(runs, {
automationId: input.automationId,
userId: input.userId,
triggerType: input.triggerType,
triggerContext: input.triggerContext ?? null,
status: input.status,
startedAt: new Date().toISOString(),
dryRun: input.dryRun ?? false,
parentRunId: input.parentRunId ?? null,
});
await db.persist({ lazy: true });
return { id };
},
async finishRun(
runId: number,
input: {
status: string;
error?: string | null;
durationMs?: number | null;
},
): Promise<void> {
const drizzle = await client();
await drizzle
.update(runs)
.set({
status: input.status,
error: input.error ?? null,
finishedAt: new Date().toISOString(),
durationMs: input.durationMs ?? null,
})
.where(eq(runs.id, runId));
const run: Row[] = await drizzle
.select({ automationId: runs.automationId, startedAt: runs.startedAt })
.from(runs)
.where(eq(runs.id, runId))
.limit(1);
if (run[0]) {
await drizzle
.update(automations)
.set({ lastRunAt: run[0].startedAt, lastRunStatus: input.status })
.where(eq(automations.id, run[0].automationId));
}
await db.persist();
},
async listRuns(
userId: string,
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
const offset = Math.max(options.offset ?? 0, 0);
const where = options.automationId
? and(
eq(runs.userId, userId),
eq(runs.automationId, options.automationId),
)
: eq(runs.userId, userId);
const drizzle = await client();
const rows: Row[] = await drizzle
.select({ run: runs, automationName: automations.name })
.from(runs)
.leftJoin(automations, eq(automations.id, runs.automationId))
.where(where)
.orderBy(desc(runs.startedAt), desc(runs.id))
.limit(limit)
.offset(offset);
return rows.map((row) => ({
...mapRunRow(row.run),
automation_name: row.automationName ?? null,
}));
},
async findRunForUser(
runId: number,
userId: string,
): Promise<AutomationRunRow | null> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(runs)
.where(and(eq(runs.id, runId), eq(runs.userId, userId)))
.limit(1);
return rows[0] ? mapRunRow(rows[0]) : null;
},
async createRunStep(input: {
runId: number;
stepIndex: number;
stepId: string;
stepType: string;
status: string;
}): Promise<number> {
const id = await insertId(runSteps, {
...input,
startedAt: new Date().toISOString(),
});
await db.persist({ lazy: true });
return id;
},
async finishRunStep(
stepRowId: number,
input: {
status: string;
output?: string | null;
error?: string | null;
truncated?: boolean;
},
): Promise<void> {
const drizzle = await client();
await drizzle
.update(runSteps)
.set({
status: input.status,
output: input.output ?? null,
error: input.error ?? null,
truncated: input.truncated ?? false,
finishedAt: new Date().toISOString(),
})
.where(eq(runSteps.id, stepRowId));
await db.persist({ lazy: true });
},
async listRunSteps(runId: number): Promise<AutomationRunStepRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(runSteps)
.where(eq(runSteps.runId, runId))
.orderBy(asc(runSteps.stepIndex));
return rows.map(mapRunStepRow);
},
/** Trims run history. Called from the scheduler's daily sweep. */
async pruneRunsOlderThan(days: number): Promise<number> {
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
const drizzle = await client();
const old: Row[] = await drizzle
.select({ id: runs.id })
.from(runs)
.where(lt(runs.startedAt, cutoff));
if (old.length === 0) return 0;
await drizzle.delete(runs).where(lt(runs.startedAt, cutoff));
await db.persist();
return old.length;
},
/** Marks runs left behind by a crash so they do not block concurrency. */
async failStaleRunningRuns(olderThanIso: string): Promise<number> {
const where = and(
eq(runs.status, "running"),
lt(runs.startedAt, olderThanIso),
);
const drizzle = await client();
const stale: Row[] = await drizzle
.select({ id: runs.id })
.from(runs)
.where(where);
if (stale.length === 0) return 0;
await drizzle
.update(runs)
.set({
status: "failed",
error: "Interrupted by a server restart",
finishedAt: new Date().toISOString(),
})
.where(where);
await db.persist();
return stale.length;
},
};
}
function mapAutomationRow(row: Row, channelIds: number[] = []): AutomationRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
description: row.description ?? null,
enabled: row.enabled ? 1 : 0,
definition: row.definition,
definition_version: row.definitionVersion,
concurrency_policy: row.concurrencyPolicy,
max_run_seconds: row.maxRunSeconds,
dry_run: row.dryRun ? 1 : 0,
last_run_at: row.lastRunAt ?? null,
last_run_status: row.lastRunStatus ?? null,
created_at: row.createdAt,
updated_at: row.updatedAt,
channels: channelIds,
};
}
function mapEngineRow(row: Row): AutomationEngineRow {
return {
id: row.id,
userId: row.userId,
name: row.name,
enabled: !!row.enabled,
definition: row.definition,
concurrencyPolicy: row.concurrencyPolicy,
maxRunSeconds: row.maxRunSeconds,
dryRun: !!row.dryRun,
};
}
function mapTriggerStateRow(row: Row): TriggerStateRow {
return {
automationId: row.automationId,
stateKey: row.stateKey,
breachStartedAt: row.breachStartedAt ?? null,
lastFiredAt: row.lastFiredAt ?? null,
lastValue: row.lastValue ?? null,
lastObservedState: row.lastObservedState ?? null,
};
}
function mapRunRow(row: Row): AutomationRunRow {
return {
id: row.id,
automation_id: row.automationId,
user_id: row.userId,
trigger_type: row.triggerType,
trigger_context: row.triggerContext ?? null,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
duration_ms: row.durationMs ?? null,
error: row.error ?? null,
dry_run: row.dryRun ? 1 : 0,
parent_run_id: row.parentRunId ?? null,
};
}
function mapRunStepRow(row: Row): AutomationRunStepRow {
return {
id: row.id,
run_id: row.runId,
step_index: row.stepIndex,
step_id: row.stepId,
step_type: row.stepType,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
output: row.output ?? null,
error: row.error ?? null,
truncated: row.truncated ? 1 : 0,
};
}
@@ -0,0 +1,93 @@
import type { AutomationDefinition, HostSelector, Step } from "../types.js";
import { pluginOf, type Deps, type ProviderKey } from "./deps.js";
/**
* The providers a definition cannot run without. A trigger or step whose
* plugin is off makes the whole automation wait for it, rather than half of
* it running.
*/
export function requiredProviders(
definition: AutomationDefinition | null | undefined,
): ProviderKey[] {
const needed = new Set<ProviderKey>();
if (!definition) return [];
const selector = (value: HostSelector | undefined) => {
if (value?.kind === "fleet") needed.add("fleets");
};
const trigger = definition.trigger;
switch (trigger?.kind) {
case "metric_threshold":
case "health_check":
needed.add("host-metrics");
selector(trigger.hostSelector);
break;
case "docker_event":
needed.add("docker-events");
selector(trigger.hostSelector);
break;
case "host_status":
selector(trigger.hostSelector);
break;
case "internal_event":
if (trigger.event === "tunnel_disconnected") needed.add("tunnels");
selector(trigger.hostSelector);
break;
default:
break;
}
const walk = (steps: Step[] | undefined) => {
for (const step of steps ?? []) {
if (step.enabled === false) continue;
switch (step.type) {
case "run_snippet":
needed.add("snippets");
selector(step.hostSelector);
break;
case "run_command":
selector(step.hostSelector);
break;
case "docker":
needed.add("docker");
selector(step.hostSelector);
break;
case "tunnel":
needed.add("tunnels");
break;
case "wol":
needed.add("wake-on-lan");
break;
case "if":
walk(step.then);
walk(step.else);
break;
default:
break;
}
}
};
walk(definition.steps);
return [...needed];
}
/** Plugin ids the definition needs that are not running, deduplicated. */
export function missingPlugins(
definition: AutomationDefinition | null | undefined,
deps: Pick<Deps, "available">,
): string[] {
const missing = requiredProviders(definition)
.filter((key) => !deps.available(key))
.map(pluginOf);
return [...new Set(missing)];
}
export function parseDefinition(raw: string): AutomationDefinition | null {
try {
return JSON.parse(raw) as AutomationDefinition;
} catch {
return null;
}
}
File diff suppressed because it is too large. Load diff
+135 -194
View File
@@ -1,217 +1,158 @@
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 type { PluginLogger } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition } from "../types.js";
import { computeNextDueAt } from "./cron.js";
import { hasDwelled, isCoolingDown } from "./conditions.js";
import { reconcileDockerWatch, resetDockerWatcher } from "./docker-watcher.js";
import { AutomationEngine } from "./engine.js";
import { reconcileHeadlessViewers } from "./headless-viewer.js";
import {
subscribeAutomationTriggers,
unsubscribeAutomationTriggers,
} from "./triggers.js";
import type { AutomationEngine } from "./engine.js";
import type { AutomationRepository } from "./repository.js";
/**
* The one timer the automations feature owns.
*
* Every other periodic job in the backend is its own module-level setInterval;
* this deliberately is not one per automation. A single tick handles due
* schedules, dwell windows that need re-checking without a fresh sample, the
* synthetic viewer heartbeat, and history pruning.
* The one tick the automations plugin runs. It handles due schedules, dwell
* windows that need re-checking without a fresh sample, the watchers that
* keep metrics and Docker events flowing for watched hosts, and history
* pruning. activate() drives it through ctx.schedule.
*/
const TICK_MS = 15_000;
const STARTUP_DELAY_MS = 30_000;
export const TICK_MS = 15_000;
export const STARTUP_DELAY_MS = 30_000;
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const RUN_RETENTION_DAYS = 30;
/** A "running" row older than this belongs to a process that is gone. */
const STALE_RUN_MS = 6 * 60 * 60 * 1000;
let tickTimer: NodeJS.Timeout | null = null;
let startupTimer: NodeJS.Timeout | null = null;
let lastPruneAt = 0;
let ticking = false;
export function startAutomationScheduler(): void {
if (tickTimer) return;
// Event-driven triggers come through the bus; the interval below only drives
// the schedule-based ones.
subscribeAutomationTriggers();
startupTimer = setTimeout(() => {
void tick();
}, STARTUP_DELAY_MS);
startupTimer.unref?.();
tickTimer = setInterval(() => {
void tick();
}, TICK_MS);
tickTimer.unref?.();
export interface SchedulerDeps {
repository: AutomationRepository;
engine: Pick<AutomationEngine, "run">;
log: PluginLogger;
/** Watchers brought in line with the enabled automations every tick. */
reconcile: Array<(now: number) => Promise<unknown>>;
}
export function stopAutomationScheduler(): void {
if (tickTimer) clearInterval(tickTimer);
if (startupTimer) clearTimeout(startupTimer);
tickTimer = null;
startupTimer = null;
unsubscribeAutomationTriggers();
resetDockerWatcher();
}
export function createScheduler(deps: SchedulerDeps) {
const { repository, engine, log } = deps;
let lastPruneAt = 0;
let ticking = false;
/** Exposed for tests; the interval calls this. */
export async function tick(now: Date = new Date()): Promise<void> {
// A slow tick must not overlap the next one.
if (ticking) return;
ticking = true;
const errorText = (error: unknown) =>
error instanceof Error ? error.message : String(error);
try {
await reconcileHeadlessViewers().catch(() => undefined);
await runDueSchedules(now);
await recheckOpenBreaches(now);
await reconcileDockerWatch(now.getTime()).catch(() => undefined);
await pruneIfDue(now);
} catch (error) {
statsLogger.warn("Automation scheduler tick failed", {
operation: "automation_scheduler_tick_error",
error: error instanceof Error ? error.message : String(error),
});
} finally {
ticking = false;
}
}
async function runDueSchedules(now: Date): Promise<void> {
const due = await repository.listDueSchedules(now.toISOString());
async function runDueSchedules(now: Date): Promise<void> {
const repository = createCurrentAutomationRepository();
const due = await repository.listDueSchedules(now.toISOString());
for (const schedule of due) {
const automation = await repository.findById(schedule.automationId);
if (!automation) continue;
// Background work can only touch a user's data while their key resolves.
if (!canAccess(automation.userId)) {
statsLogger.warn("Skipping scheduled automation: data key unavailable", {
operation: "automation_schedule_locked",
automationId: automation.id,
});
continue;
}
const nextDueAt = computeNextDueAt(
{
cron: schedule.cron,
intervalSeconds: schedule.intervalSeconds,
timezone: schedule.timezone,
},
now,
);
await repository.markScheduleTicked(
schedule.automationId,
nextDueAt,
now.toISOString(),
);
AutomationEngine.getInstance()
.run({
automationId: schedule.automationId,
triggerType: "schedule",
triggerContext: { scheduledFor: now.toISOString() },
})
.catch((error) => {
statsLogger.warn("Scheduled automation failed to start", {
operation: "automation_schedule_error",
automationId: schedule.automationId,
error: error instanceof Error ? error.message : String(error),
});
});
}
}
/**
* Fires sustained breaches whose window has elapsed.
*
* Without this a dwell window only completes when another sample happens to
* arrive, so a breach that starts just before polling stops would never fire.
*/
async function recheckOpenBreaches(now: Date): Promise<void> {
const repository = createCurrentAutomationRepository();
const open = await repository.listOpenBreaches();
const nowMs = now.getTime();
for (const state of open) {
const automation = await repository.findById(state.automationId);
if (!automation || !automation.enabled) continue;
if (!canAccess(automation.userId)) continue;
let definition: AutomationDefinition;
try {
definition = JSON.parse(automation.definition) as AutomationDefinition;
} catch {
continue;
}
const trigger = definition.trigger;
if (trigger?.kind !== "metric_threshold") continue;
if (!trigger.forSeconds) continue;
if (!hasDwelled(state.breachStartedAt, trigger.forSeconds, nowMs)) continue;
if (isCoolingDown(state.lastFiredAt, trigger.cooldownMinutes, nowMs)) {
continue;
}
const hostId = Number(state.stateKey.split(":")[0]);
await repository.upsertTriggerState({
automationId: automation.id,
stateKey: state.stateKey,
lastFiredAt: now.toISOString(),
});
AutomationEngine.getInstance()
.run({
automationId: automation.id,
triggerType: "metric_threshold",
triggerContext: {
hostId,
value: state.lastValue,
threshold: trigger.value,
metric: trigger.metric.path,
sustained: true,
for (const schedule of due) {
const nextDueAt = computeNextDueAt(
{
cron: schedule.cron,
intervalSeconds: schedule.intervalSeconds,
timezone: schedule.timezone,
},
triggerHostId: Number.isFinite(hostId) ? hostId : undefined,
})
.catch(() => undefined);
}
}
now,
);
await repository.markScheduleTicked(
schedule.automationId,
nextDueAt,
now.toISOString(),
);
async function pruneIfDue(now: Date): Promise<void> {
if (now.getTime() - lastPruneAt < PRUNE_INTERVAL_MS) return;
lastPruneAt = now.getTime();
const repository = createCurrentAutomationRepository();
try {
await repository.failStaleRunningRuns(
new Date(now.getTime() - STALE_RUN_MS).toISOString(),
);
const deleted = await repository.pruneRunsOlderThan(RUN_RETENTION_DAYS);
if (deleted > 0) {
statsLogger.info(`Pruned ${deleted} old automation run(s)`, {
operation: "automation_run_prune",
deleted,
});
void engine
.run({
automationId: schedule.automationId,
triggerType: "schedule",
triggerContext: { scheduledFor: now.toISOString() },
})
.catch((error: unknown) =>
log.warn(
`Scheduled automation ${schedule.automationId} failed to start: ${errorText(error)}`,
),
);
}
} catch (error) {
statsLogger.warn("Automation run pruning failed", {
operation: "automation_run_prune_error",
error: error instanceof Error ? error.message : String(error),
});
}
}
function canAccess(userId: string): boolean {
try {
return DataCrypto.canUserAccessData(userId);
} catch {
return false;
/**
* Fires sustained breaches whose window has elapsed. Without this a dwell
* window only completes when another sample happens to arrive.
*/
async function recheckOpenBreaches(now: Date): Promise<void> {
const open = await repository.listOpenBreaches();
const nowMs = now.getTime();
for (const state of open) {
const automation = await repository.findById(state.automationId);
if (!automation || !automation.enabled) continue;
let definition: AutomationDefinition;
try {
definition = JSON.parse(automation.definition) as AutomationDefinition;
} catch {
continue;
}
const trigger = definition.trigger;
if (trigger?.kind !== "metric_threshold") continue;
if (!trigger.forSeconds) continue;
if (!hasDwelled(state.breachStartedAt, trigger.forSeconds, nowMs)) {
continue;
}
if (isCoolingDown(state.lastFiredAt, trigger.cooldownMinutes, nowMs)) {
continue;
}
const hostId = Number(state.stateKey.split(":")[0]);
await repository.upsertTriggerState({
automationId: automation.id,
stateKey: state.stateKey,
lastFiredAt: now.toISOString(),
});
void engine
.run({
automationId: automation.id,
triggerType: "metric_threshold",
triggerContext: {
hostId,
value: state.lastValue,
threshold: trigger.value,
metric: trigger.metric.path,
sustained: true,
},
triggerHostId: Number.isFinite(hostId) ? hostId : undefined,
})
.catch(() => undefined);
}
}
async function pruneIfDue(now: Date): Promise<void> {
if (now.getTime() - lastPruneAt < PRUNE_INTERVAL_MS) return;
lastPruneAt = now.getTime();
try {
await repository.failStaleRunningRuns(
new Date(now.getTime() - STALE_RUN_MS).toISOString(),
);
const deleted = await repository.pruneRunsOlderThan(RUN_RETENTION_DAYS);
if (deleted > 0) log.info(`Pruned ${deleted} old automation run(s)`);
} catch (error) {
log.warn(`Automation run pruning failed: ${errorText(error)}`);
}
}
return {
async tick(now: Date = new Date()): Promise<void> {
// A slow tick must not overlap the next one.
if (ticking) return;
ticking = true;
try {
for (const reconcile of deps.reconcile) {
await reconcile(now.getTime()).catch((error: unknown) =>
log.warn(`Automation watcher failed: ${errorText(error)}`),
);
}
await runDueSchedules(now);
await recheckOpenBreaches(now);
await pruneIfDue(now);
} catch (error) {
log.warn(`Automation scheduler tick failed: ${errorText(error)}`);
} finally {
ticking = false;
}
},
};
}
+139
View File
@@ -0,0 +1,139 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition, RunStatus } from "../types.js";
import type { Deps } from "./deps.js";
import type { AutomationEngine } from "./engine.js";
import type { AutomationRepository } from "./repository.js";
import { missingPlugins, parseDefinition } from "./requirements.js";
import { syncSchedule } from "./routes.js";
import { validateDefinition } from "./validate.js";
export interface AutomationSummary {
id: number;
name: string;
description: string | null;
enabled: boolean;
triggerKind: string | null;
lastRunAt: string | null;
lastRunStatus: string | null;
missingPlugins: string[];
}
/**
* The "automations.access" service, version 1. Runs as the caller's user,
* gated on automations.view; create and run also need automations.create and
* automations.run.
*/
export interface AutomationsAccessV1 {
list: () => Promise<AutomationSummary[]>;
get: (
id: number,
) => Promise<
(AutomationSummary & { definition: AutomationDefinition | null }) | null
>;
/**
* Validates the definition the same way the create route does. Starts
* disabled unless `enabled` is set, so an automation nobody watched run
* does not start firing on its own.
*/
create: (input: {
name: string;
description?: string | null;
definition: unknown;
enabled?: boolean;
}) => Promise<{ id: number; name: string }>;
run: (
id: number,
options?: { dryRun?: boolean },
) => Promise<{ runId: number | null; status: RunStatus; error?: string }>;
}
export function createAutomationsService(
ctx: PluginContext,
repository: AutomationRepository,
engine: Pick<AutomationEngine, "run">,
deps: Deps,
): AutomationsAccessV1 {
const actor = (method: string) => {
const userId = ctx.currentActor();
if (!userId) throw new Error(`automations.access.${method} needs an actor`);
return userId;
};
const summarize = (
row: Awaited<ReturnType<AutomationRepository["list"]>>[number],
) => {
const definition = parseDefinition(row.definition);
if (definition?.trigger?.kind === "webhook") {
definition.trigger = { ...definition.trigger, tokenHash: "" };
}
return {
summary: {
id: row.id,
name: row.name,
description: row.description,
enabled: !!row.enabled,
triggerKind: definition?.trigger?.kind ?? null,
lastRunAt: row.last_run_at,
lastRunStatus: row.last_run_status,
missingPlugins: missingPlugins(definition, deps),
},
definition,
};
};
return {
list: async () => {
const rows = await repository.list(actor("list"));
return rows.map((row) => summarize(row).summary);
},
get: async (id) => {
const row = await repository.findForUser(id, actor("get"));
if (!row) return null;
const { summary, definition } = summarize(row);
return { ...summary, definition };
},
create: async (input) => {
const userId = actor("create");
if (!(await ctx.rbac.has("create"))) {
throw new Error('Creating automations needs "automations.create"');
}
const name = typeof input.name === "string" ? input.name.trim() : "";
if (!name) throw new Error("Name is required");
const validated = validateDefinition(input.definition);
if (!validated.ok || !validated.definition) {
throw new Error(
validated.error ?? "The automation definition is invalid",
);
}
if (validated.definition.trigger.kind === "webhook") {
// A webhook's token is only ever shown by the create route.
throw new Error("Webhook automations are created from the editor");
}
const created = await repository.create({
userId,
name,
description: input.description ?? null,
definition: JSON.stringify(validated.definition),
enabled: input.enabled ?? false,
});
await syncSchedule(repository, created.id, validated.definition);
return { id: created.id, name: created.name };
},
run: async (id, options = {}) => {
const userId = actor("run");
if (!(await ctx.rbac.has("run"))) {
throw new Error('Running automations needs "automations.run"');
}
const existing = await repository.findForUser(id, userId);
if (!existing) throw new Error("Automation not found");
return engine.run({
automationId: id,
triggerType: "manual",
triggerContext: { manual: true, requestedBy: userId },
dryRun: options.dryRun === true,
});
},
};
}
@@ -1,91 +0,0 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
let current: PluginServices | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginServices(services: PluginServices | null): void {
current = services;
}
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
interface SnippetsAccess {
get: (id: number) => Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null>;
resolveCommand: (
id: number,
vars: { ip?: string; username?: string; port?: number; name?: string },
inputValues?: Record<string, string>,
) => Promise<string | null>;
}
/**
* A snippet's command, resolved against a target host and run as userId.
* Null when the snippets plugin is off, the user may not use it, or the
* snippet does not exist. Optional: the run_snippet step fails with a clear
* message without it, rather than the plugin failing to activate.
*/
export async function resolveSnippetCommandFor(
userId: string,
id: number,
vars: { ip?: string; username?: string; port?: number; name?: string },
inputValues?: Record<string, string>,
): Promise<string | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.resolveCommand(id, vars, inputValues);
} catch {
return null;
}
}
export async function getSnippet(
userId: string,
id: number,
): Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.get(id);
} catch {
return null;
}
}
/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */
interface FleetsAccess {
members: (
fleetId: number,
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
}
/**
* A fleet's effective member host ids, for a "fleet" target selector. Empty
* when the fleets plugin is off, the user may not use it, or the fleet does
* not exist - the step then runs against no hosts rather than failing.
*/
export async function fleetMemberIds(
userId: string,
fleetId: number,
): Promise<number[]> {
if (!current) return [];
try {
const members = await current
.get<FleetsAccess>("fleets.access", { userId })
.members(fleetId);
return members.map((member) => member.id);
} catch {
return [];
}
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+197
View File
@@ -0,0 +1,197 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
real,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/*
* Adopted from core's automation tables, so column and index names are the
* legacy ones and the rename carries every row across.
*
* The links between these tables, and a channel link to core's
* notification_channels, are plain integers here: the SDK only references
* users and ssh_data. The adoption migrations keep the legacy FOREIGN KEY
* clauses by hand, so deleting an automation still cascades.
*/
export const automations = adoptLegacyTable(
"automations",
defineTable(
"automations",
{
id: id(),
userId: refUser(),
name: text().notNull(),
description: text(),
enabled: boolean().notNull().default(true),
// The whole trigger and steps graph, read and written as a unit.
definition: text().notNull(),
definitionVersion: integer().notNull().default(1),
concurrencyPolicy: text().notNull().default("skip"),
maxRunSeconds: integer().notNull().default(300),
dryRun: boolean().notNull().default(false),
lastRunAt: text(),
lastRunStatus: text(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{ name: "idx_automations_user", columns: ["userId", "enabled"] },
],
},
),
);
/**
* Durable per-target trigger state, so cooldowns and dwell windows survive a
* restart. state_key scopes it to what the trigger watches ("<hostId>",
* "<hostId>:/data", "<hostId>:<container>").
*/
export const triggerState = adoptLegacyTable(
"automation_trigger_state",
defineTable(
"trigger_state",
{
id: id(),
automationId: integer().notNull(),
stateKey: varchar().notNull(),
breachStartedAt: text(),
lastFiredAt: text(),
lastValue: real(),
lastObservedState: text(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_automation_trigger_state_key",
columns: ["automationId", "stateKey"],
},
],
},
),
);
export const schedules = adoptLegacyTable(
"automation_schedules",
defineTable(
"schedules",
{
id: id(),
automationId: integer().notNull(),
cron: text(),
intervalSeconds: integer(),
timezone: text(),
nextDueAt: timestamp(),
lastTickAt: text(),
},
{
uniques: [
{
name: "idx_automation_schedules_automation",
columns: ["automationId"],
},
],
indexes: [
{ name: "idx_automation_schedules_due", columns: ["nextDueAt"] },
],
},
),
);
export const runs = adoptLegacyTable(
"automation_runs",
defineTable(
"runs",
{
id: id(),
automationId: integer().notNull(),
userId: refUser(),
triggerType: text().notNull(),
triggerContext: text(),
status: text().notNull(),
startedAt: timestamp().notNull().defaultNow(),
finishedAt: text(),
durationMs: integer(),
error: text(),
dryRun: boolean().notNull().default(false),
// Set when one automation invoked another, so a chain can be traced.
parentRunId: integer(),
},
{
indexes: [
{
name: "idx_automation_runs_automation",
columns: ["automationId", "startedAt"],
},
{ name: "idx_automation_runs_user", columns: ["userId", "startedAt"] },
],
},
),
);
export const runSteps = adoptLegacyTable(
"automation_run_steps",
defineTable(
"run_steps",
{
id: id(),
runId: integer().notNull(),
stepIndex: integer().notNull(),
stepId: text().notNull(),
stepType: text().notNull(),
status: text().notNull(),
startedAt: timestamp().notNull().defaultNow(),
finishedAt: text(),
output: text(),
error: text(),
truncated: boolean().notNull().default(false),
},
{
indexes: [
{
name: "idx_automation_run_steps_run",
columns: ["runId", "stepIndex"],
},
],
},
),
);
/** Which of core's notification channels an automation is linked to. */
export const channels = adoptLegacyTable(
"automation_channels",
defineTable(
"channels",
{
id: id(),
automationId: integer().notNull(),
channelId: integer().notNull(),
},
{
uniques: [
{
name: "idx_automation_channels_pair",
columns: ["automationId", "channelId"],
},
],
},
),
);
export const tables = [
automations,
triggerState,
schedules,
runs,
runSteps,
channels,
];
+383 -362
View File
@@ -1,11 +1,9 @@
import type { PluginEvents, PluginLogger } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition, HostSelector, Trigger } from "../types.js";
import type {
AutomationDefinition,
HostSelector,
Trigger,
} 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";
AutomationEngineRow,
AutomationRepository,
} from "./repository.js";
import {
compare,
extractMetricValue,
@@ -15,11 +13,7 @@ import {
severityForValue,
type MetricsSnapshot,
} from "./conditions.js";
import { AutomationEngine } from "./engine.js";
import {
pluginEvents,
TOPICS,
} from "../../../../src/backend/plugins/events.js";
import { TOPIC_AUTOMATION_FAILED, type AutomationEngine } from "./engine.js";
/**
* Matches events against automation triggers and decides what fires.
@@ -68,377 +62,404 @@ interface LoadedAutomation {
definition: AutomationDefinition;
}
async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> {
try {
const rows =
await createCurrentAutomationRepository().listEnabledForUser(userId);
const loaded: LoadedAutomation[] = [];
for (const row of rows) {
try {
loaded.push({
row,
definition: JSON.parse(row.definition) as AutomationDefinition,
});
} catch {
// A malformed definition should not stop the others from evaluating.
export type Triggers = ReturnType<typeof createTriggers>;
/** Built once per activation. */
export function createTriggers(
repository: AutomationRepository,
engine: Pick<AutomationEngine, "run">,
log: PluginLogger,
) {
async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> {
try {
const rows = await repository.listEnabledForUser(userId);
const loaded: LoadedAutomation[] = [];
for (const row of rows) {
try {
loaded.push({
row,
definition: JSON.parse(row.definition) as AutomationDefinition,
});
} catch {
// A malformed definition should not stop the others from evaluating.
}
}
return loaded;
} catch {
return [];
}
return loaded;
} catch {
return [];
}
}
/** Whether a selector covers a host. Ownership is checked by the caller. */
function selectorCoversHost(
selector: HostSelector | undefined,
hostId: number,
): boolean {
if (!selector) return true;
switch (selector.kind) {
case "all":
case "trigger":
return true;
case "host":
return selector.hostId === hostId;
case "hosts":
return selector.hostIds.includes(hostId);
case "fleet":
// Fleet membership is resolved at execution time; evaluate optimistically
// so a fleet-scoped trigger still reaches the engine.
return true;
default:
return false;
/** Whether a selector covers a host. Ownership is checked by the caller. */
function selectorCoversHost(
selector: HostSelector | undefined,
hostId: number,
): boolean {
if (!selector) return true;
switch (selector.kind) {
case "all":
case "trigger":
return true;
case "host":
return selector.hostId === hostId;
case "hosts":
return selector.hostIds.includes(hostId);
case "fleet":
// Fleet membership is resolved at execution time; evaluate optimistically
// so a fleet-scoped trigger still reaches the engine.
return true;
default:
return false;
}
}
}
async function fire(
automation: AutomationEngineRow,
stateKey: string,
triggerType: string,
triggerContext: Record<string, unknown>,
hostId?: number,
): Promise<void> {
const repository = createCurrentAutomationRepository();
await repository.upsertTriggerState({
automationId: automation.id,
stateKey,
lastFiredAt: new Date().toISOString(),
});
AutomationEngine.getInstance()
.run({
async function fire(
automation: AutomationEngineRow,
stateKey: string,
triggerType: string,
triggerContext: Record<string, unknown>,
hostId?: number,
): Promise<void> {
await repository.upsertTriggerState({
automationId: automation.id,
triggerType,
triggerContext,
triggerHostId: hostId,
})
.catch((error) => {
statsLogger.warn("Automation run failed to start", {
operation: "automation_trigger_error",
stateKey,
lastFiredAt: new Date().toISOString(),
});
engine
.run({
automationId: automation.id,
error: error instanceof Error ? error.message : String(error),
triggerType,
triggerContext,
triggerHostId: hostId,
})
.catch((error: unknown) => {
log.warn(
`Automation ${automation.id} failed to start: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
});
}
/**
* Metric samples. Called for every polled host, including hosts polled only
* because an automation asked for them.
*/
export async function onMetrics(event: MetricEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "metric_threshold") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
const value = extractMetricValue(event.metrics, trigger.metric);
if (value === null) continue;
const stateKey = metricStateKey(event.hostId, trigger.metric);
const state = await repository.getTriggerState(row.id, stateKey);
const breaching = compare(value, trigger.operator, trigger.value);
if (!breaching) {
if (state?.breachStartedAt) {
await repository.clearBreach(row.id, stateKey);
}
continue;
}
// Open the dwell window on the first breaching sample.
if (!state?.breachStartedAt) {
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
breachStartedAt: new Date(now).toISOString(),
lastValue: value,
});
if (trigger.forSeconds) continue;
}
const breachStartedAt =
state?.breachStartedAt ?? new Date(now).toISOString();
if (!hasDwelled(breachStartedAt, trigger.forSeconds, now)) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"metric_threshold",
{
hostId: event.hostId,
value,
threshold: trigger.value,
operator: trigger.operator,
metric: trigger.metric.path,
mount: "mount" in trigger.metric ? trigger.metric.mount : undefined,
severity: severityForValue(value, trigger.severity),
},
event.hostId,
);
}
}
/** Host reachability transitions. Only edges fire, never steady state. */
export async function onStatus(event: StatusEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
const observed = event.online ? "online" : "offline";
/**
* Metric samples. Called for every polled host, including hosts polled only
* because an automation asked for them.
*/
async function onMetrics(event: MetricEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "host_status") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "metric_threshold") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
const stateKey = String(event.hostId);
const state = await repository.getTriggerState(row.id, stateKey);
const value = extractMetricValue(event.metrics, trigger.metric);
if (value === null) continue;
if (state?.lastObservedState === observed) continue;
const stateKey = metricStateKey(event.hostId, trigger.metric);
const state = await repository.getTriggerState(row.id, stateKey);
const breaching = compare(value, trigger.operator, trigger.value);
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
lastObservedState: observed,
});
// The first observation establishes a baseline rather than firing, so a
// restart does not announce every host as though it just changed.
if (!state?.lastObservedState) continue;
if (trigger.to !== observed) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"host_status",
{ hostId: event.hostId, status: observed },
event.hostId,
);
}
}
export async function onHealthCheck(event: HealthEvent): Promise<void> {
const automations = await loadEnabledFor(event.userId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
const observed = event.ok ? "recovered" : "failing";
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "health_check") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
if (trigger.checkId && trigger.checkId !== event.checkId) continue;
const stateKey = `${event.hostId}:${event.checkId}`;
const state = await repository.getTriggerState(row.id, stateKey);
if (state?.lastObservedState === observed) continue;
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
lastObservedState: observed,
});
if (!state?.lastObservedState) continue;
if (trigger.to !== observed) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"health_check",
{
hostId: event.hostId,
checkId: event.checkId,
state: observed,
detail: event.detail,
},
event.hostId,
);
}
}
export async function onDockerEvent(event: DockerEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "docker_event") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
if (trigger.container && trigger.container !== event.container) continue;
if (trigger.event !== event.event) continue;
const stateKey = `${event.hostId}:${event.container}`;
const state = await repository.getTriggerState(row.id, stateKey);
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"docker_event",
{
hostId: event.hostId,
container: event.container,
event: event.event,
},
event.hostId,
);
}
}
export async function onInternalEvent(event: InternalEvent): Promise<void> {
const automations = await loadEnabledFor(event.userId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "internal_event") continue;
if (trigger.event !== event.event) continue;
if (
event.hostId !== undefined &&
!selectorCoversHost(trigger.hostSelector, event.hostId)
) {
continue;
}
const stateKey = event.hostId ? String(event.hostId) : "global";
const state = await repository.getTriggerState(row.id, stateKey);
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"internal_event",
{ event: event.event, hostId: event.hostId, ...(event.details ?? {}) },
event.hostId,
);
}
}
let unsubscribers: Array<() => void> = [];
/**
* Subscribes the engine to the event bus.
*
* The hosts modules publish onto the bus and know nothing about automations;
* this is the other half of that arrangement, and it is what keeps the edge
* one-directional (automations may import repositories, hosts modules must not
* import automations).
*
* Handlers are async and the bus is fire-and-forget, so a rejection here is
* logged by the bus rather than surfacing to whichever host feature emitted.
*/
export function subscribeAutomationTriggers(): void {
if (unsubscribers.length > 0) return;
unsubscribers = [
pluginEvents.on("plugin.host-metrics.snapshot", (payload) =>
onMetrics(payload as MetricEvent),
),
pluginEvents.on(TOPICS.hostStatus, (payload) =>
onStatus(payload as StatusEvent),
),
pluginEvents.on("plugin.host-metrics.health-check", (payload) =>
onHealthCheck(payload as HealthEvent),
),
pluginEvents.on(TOPICS.internalEvent, (payload) =>
onInternalEvent(payload as InternalEvent),
),
// An SSH login seen by the terminal, which host-metrics used to relay.
pluginEvents.on(TOPICS.hostLogin, (payload) => {
const login = payload as {
hostId: number;
userId: string;
sshUser?: string;
fromIp?: string;
};
if (!login?.userId) return;
return onInternalEvent({
event: "user_login",
userId: login.userId,
hostId: login.hostId,
details: { sshUser: login.sshUser, fromIp: login.fromIp },
});
}),
];
}
export function unsubscribeAutomationTriggers(): void {
for (const unsubscribe of unsubscribers) unsubscribe();
unsubscribers = [];
}
/**
* Hosts that an enabled automation watches, so the poller knows to collect
* metrics for them even when nobody is looking.
*/
export async function listAutomationWatchedHosts(): Promise<
Map<number, string>
> {
const watched = new Map<number, string>();
try {
const rows = await createCurrentAutomationRepository().listAllEnabled();
for (const row of rows) {
let definition: AutomationDefinition;
try {
definition = JSON.parse(row.definition) as AutomationDefinition;
} catch {
if (!breaching) {
if (state?.breachStartedAt) {
await repository.clearBreach(row.id, stateKey);
}
continue;
}
const trigger: Trigger | undefined = definition.trigger;
// Only metric thresholds need heavy collection; status triggers are
// already served by the cheap reachability probe.
if (trigger?.kind !== "metric_threshold") continue;
const selector = trigger.hostSelector;
if (selector?.kind === "host") {
watched.set(selector.hostId, row.userId);
} else if (selector?.kind === "hosts") {
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
// Open the dwell window on the first breaching sample.
if (!state?.breachStartedAt) {
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
breachStartedAt: new Date(now).toISOString(),
lastValue: value,
});
if (trigger.forSeconds) continue;
}
// Fleet and "all" selectors are resolved by the scheduler, which can
// expand them without blocking this call.
const breachStartedAt =
state?.breachStartedAt ?? new Date(now).toISOString();
if (!hasDwelled(breachStartedAt, trigger.forSeconds, now)) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"metric_threshold",
{
hostId: event.hostId,
value,
threshold: trigger.value,
operator: trigger.operator,
metric: trigger.metric.path,
mount: "mount" in trigger.metric ? trigger.metric.mount : undefined,
severity: severityForValue(value, trigger.severity),
},
event.hostId,
);
}
}
/** Host reachability transitions. Only edges fire, never steady state. */
async function onStatus(event: StatusEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const now = Date.now();
const observed = event.online ? "online" : "offline";
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "host_status") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
const stateKey = String(event.hostId);
const state = await repository.getTriggerState(row.id, stateKey);
if (state?.lastObservedState === observed) continue;
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
lastObservedState: observed,
});
// The first observation establishes a baseline rather than firing, so a
// restart does not announce every host as though it just changed.
if (!state?.lastObservedState) continue;
if (trigger.to !== observed) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"host_status",
{ hostId: event.hostId, status: observed },
event.hostId,
);
}
}
async function onHealthCheck(event: HealthEvent): Promise<void> {
const automations = await loadEnabledFor(event.userId);
const now = Date.now();
const observed = event.ok ? "recovered" : "failing";
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "health_check") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
if (trigger.checkId && trigger.checkId !== event.checkId) continue;
const stateKey = `${event.hostId}:${event.checkId}`;
const state = await repository.getTriggerState(row.id, stateKey);
if (state?.lastObservedState === observed) continue;
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
lastObservedState: observed,
});
if (!state?.lastObservedState) continue;
if (trigger.to !== observed) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"health_check",
{
hostId: event.hostId,
checkId: event.checkId,
state: observed,
detail: event.detail,
},
event.hostId,
);
}
}
async function onDockerEvent(event: DockerEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "docker_event") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
if (trigger.container && trigger.container !== event.container) continue;
if (trigger.event !== event.event) continue;
const stateKey = `${event.hostId}:${event.container}`;
const state = await repository.getTriggerState(row.id, stateKey);
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"docker_event",
{
hostId: event.hostId,
container: event.container,
event: event.event,
},
event.hostId,
);
}
}
async function onInternalEvent(event: InternalEvent): Promise<void> {
const automations = await loadEnabledFor(event.userId);
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "internal_event") continue;
if (trigger.event !== event.event) continue;
if (
event.hostId !== undefined &&
!selectorCoversHost(trigger.hostSelector, event.hostId)
) {
continue;
}
const stateKey = event.hostId ? String(event.hostId) : "global";
const state = await repository.getTriggerState(row.id, stateKey);
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"internal_event",
{ event: event.event, hostId: event.hostId, ...(event.details ?? {}) },
event.hostId,
);
}
}
/**
* Hosts an enabled automation watches with the given trigger kind, with
* the owning user. Fleet and "all" selectors are not expanded: collecting
* for every host a user owns is far too costly.
*/
async function watchedHosts(
kind: "metric_threshold" | "docker_event",
): Promise<Map<number, string>> {
const watched = new Map<number, string>();
try {
for (const row of await repository.listAllEnabled()) {
let definition: AutomationDefinition;
try {
definition = JSON.parse(row.definition) as AutomationDefinition;
} catch {
continue;
}
const trigger: Trigger | undefined = definition.trigger;
if (trigger?.kind !== kind) continue;
const selector = trigger.hostSelector;
if (selector?.kind === "host") {
watched.set(selector.hostId, row.userId);
} else if (selector?.kind === "hosts") {
for (const hostId of selector.hostIds)
watched.set(hostId, row.userId);
}
}
} catch {
return watched;
}
} catch {
return watched;
}
return watched;
/**
* Subscribes to the topics the triggers read. Every listener is disposed
* with the plugin. Handlers are async and the bus is fire-and-forget, so a
* rejection is logged here rather than reaching whoever emitted.
*/
function subscribe(events: PluginEvents): void {
const on = <T>(
topic: string,
handler: (payload: T) => Promise<void> | undefined,
) =>
events.on(topic, (payload) => {
void Promise.resolve(handler(payload as T)).catch((error: unknown) =>
log.warn(
`Automation trigger for ${topic} failed: ${
error instanceof Error ? error.message : String(error)
}`,
),
);
});
on<MetricEvent>("plugin.host-metrics.snapshot", onMetrics);
on<HealthEvent>("plugin.host-metrics.health-check", onHealthCheck);
on<StatusEvent>("host.status", onStatus);
on<InternalEvent>("internal.event", (event) =>
event?.userId ? onInternalEvent(event) : undefined,
);
// An SSH login seen by the terminal.
on<{ hostId: number; userId: string; sshUser?: string; fromIp?: string }>(
"host.login",
(login) =>
login?.userId
? onInternalEvent({
event: "user_login",
userId: login.userId,
hostId: login.hostId,
details: { sshUser: login.sshUser, fromIp: login.fromIp },
})
: undefined,
);
// Nothing arrives here while the tunnels plugin is off, which is the
// whole of that optional dependency.
on<{ userId?: string; hostId?: number; tunnelName?: string }>(
"plugin.tunnels.tunnel_disconnected",
(event) =>
event?.userId
? onInternalEvent({
event: "tunnel_disconnected",
userId: event.userId,
hostId: event.hostId,
details: { tunnelName: event.tunnelName },
})
: undefined,
);
on<{
userId?: string;
automationId?: number;
automationName?: string;
runId?: number;
error?: string | null;
}>(TOPIC_AUTOMATION_FAILED, (event) =>
event?.userId
? onInternalEvent({
event: "automation_failed",
userId: event.userId,
details: {
automationId: event.automationId,
automationName: event.automationName,
runId: event.runId,
error: event.error ?? null,
},
})
: undefined,
);
}
return {
onMetrics,
onStatus,
onHealthCheck,
onDockerEvent,
onInternalEvent,
watchedHosts,
subscribe,
};
}
@@ -1,63 +0,0 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
let current: PluginServices | null = null;
/** Set in activate, cleared on deactivate. */
export function setTunnelServices(services: PluginServices | null): void {
current = services;
}
/** The tunnels plugin, as ctx.services.get("tunnels.access") returns it. */
interface TunnelsAccess {
start: (name: string) => Promise<void>;
stop: (name: string) => Promise<void>;
}
export interface TunnelActionResult {
ok: boolean;
/** True when the tunnels plugin is off or the user may not use it. */
unavailable?: boolean;
error?: string;
}
/**
* Connects or disconnects a saved tunnel as userId. Optional: without the
* tunnels plugin the step fails with a clear message rather than the plugin
* failing to activate.
*/
export async function runTunnelAction(
userId: string,
action: "connect" | "disconnect",
name: string,
): Promise<TunnelActionResult> {
if (!current) {
return {
ok: false,
unavailable: true,
error: "The tunnels plugin is not available",
};
}
let tunnels: TunnelsAccess;
try {
tunnels = current.get<TunnelsAccess>("tunnels.access", { userId });
} catch {
return {
ok: false,
unavailable: true,
error: "The tunnels plugin is not available",
};
}
try {
if (action === "connect") await tunnels.start(name);
else await tunnels.stop(name);
return { ok: true };
} catch (error) {
return {
ok: false,
unavailable: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
+153
View File
@@ -0,0 +1,153 @@
import type { AutomationDefinition, Step, Trigger } from "../types.js";
import { AUTOMATION_DEFINITION_VERSION } from "../types.js";
import { isValidCron, isValidTimezone } from "./cron.js";
const TRIGGER_KINDS = new Set([
"metric_threshold",
"host_status",
"health_check",
"schedule",
"docker_event",
"internal_event",
"webhook",
]);
const STEP_TYPES = new Set([
"notify",
"http",
"run_snippet",
"run_command",
"docker",
"tunnel",
"wol",
"wait",
"set_var",
"if",
"run_automation",
"stop",
]);
const OPERATORS = new Set([
">",
"<",
">=",
"<=",
"==",
"!=",
"contains",
"not_contains",
"changed",
]);
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Validates a definition before it is stored. The engine treats the stored
* blob as trusted, so everything it relies on is checked once here.
*/
export function validateDefinition(value: unknown): {
ok: boolean;
error?: string;
definition?: AutomationDefinition;
} {
if (typeof value !== "object" || value === null) {
return { ok: false, error: "Definition must be an object" };
}
const candidate = value as Partial<AutomationDefinition>;
const trigger = candidate.trigger as Trigger | undefined;
if (!trigger || !TRIGGER_KINDS.has(trigger.kind)) {
return { ok: false, error: "Unknown or missing trigger kind" };
}
if (trigger.kind === "metric_threshold") {
if (!OPERATORS.has(trigger.operator)) {
return { ok: false, error: "Unknown comparison operator" };
}
if (typeof trigger.value !== "number" || !Number.isFinite(trigger.value)) {
return { ok: false, error: "Threshold value must be a number" };
}
if (!trigger.metric?.path) {
return { ok: false, error: "Trigger is missing a metric" };
}
}
if (trigger.kind === "schedule") {
const hasInterval =
typeof trigger.intervalSeconds === "number" &&
trigger.intervalSeconds > 0;
const hasCron = isNonEmptyString(trigger.cron);
if (!hasInterval && !hasCron) {
return { ok: false, error: "Schedule needs an interval or a cron" };
}
if (hasCron && !isValidCron(trigger.cron as string)) {
return { ok: false, error: "Cron expression is not valid" };
}
if (hasInterval && (trigger.intervalSeconds as number) < 60) {
return { ok: false, error: "Interval must be at least 60 seconds" };
}
if (
isNonEmptyString(trigger.timezone) &&
!isValidTimezone(trigger.timezone)
) {
return { ok: false, error: "Time zone is not valid" };
}
}
const steps = candidate.steps;
if (!Array.isArray(steps)) {
return { ok: false, error: "Definition must include a steps array" };
}
const seen = new Set<string>();
const stepError = validateSteps(steps as Step[], seen);
if (stepError) return { ok: false, error: stepError };
return {
ok: true,
definition: {
version: candidate.version ?? AUTOMATION_DEFINITION_VERSION,
trigger,
steps: steps as Step[],
},
};
}
function validateSteps(steps: Step[], seen: Set<string>): string | null {
for (const step of steps) {
if (!step || typeof step !== "object") return "Step must be an object";
if (!isNonEmptyString(step.id)) return "Every step needs an id";
if (seen.has(step.id)) return `Duplicate step id: ${step.id}`;
seen.add(step.id);
if (!STEP_TYPES.has(step.type)) {
return `Unknown step type: ${step.type}`;
}
if (step.type === "if") {
if (!step.condition || !OPERATORS.has(step.condition.operator)) {
return "Condition needs a valid operator";
}
const thenError = validateSteps(step.then ?? [], seen);
if (thenError) return thenError;
const elseError = validateSteps(step.else ?? [], seen);
if (elseError) return elseError;
}
if (step.type === "http" && !isNonEmptyString(step.url)) {
return "HTTP steps need a URL";
}
if (step.type === "run_command" && !isNonEmptyString(step.command)) {
return "Command steps need a command";
}
if (step.type === "wait" && typeof step.seconds !== "number") {
return "Wait steps need a number of seconds";
}
if (step.type === "set_var" && !isNonEmptyString(step.name)) {
return "Variable steps need a name";
}
}
return null;
}
@@ -1,4 +1,3 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
@@ -12,30 +11,22 @@ import {
Trash2,
Workflow,
} from "lucide-react";
import { Button } from "@/components/button";
import { Badge } from "@/components/badge";
import {
createAutomation,
deleteAutomation,
listAutomationRunSteps,
listAutomationRuns,
listAutomations,
runAutomation,
updateAutomation,
Badge,
Button,
NotificationChannelDialog,
deleteNotificationChannel,
getBasePath,
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@termix/plugin-sdk/ui";
import {
useAutomationsApi,
type AutomationRow,
type AutomationRunRow,
type AutomationRunStepRow,
} from "./automations-api";
import {
deleteNotificationChannel,
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@/api/notification-channels-api";
import { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
import { getSnippets } from "@/api/snippets-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { invokeAction } from "@termix/plugin-sdk/frontend";
import {
AutomationEditor,
@@ -47,6 +38,10 @@ import {
type AutomationEditorOptions,
} from "./automations/editor-types";
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
type PanelTab = "automations" | "runs" | "channels";
const STATUS_CLASS: Record<string, string> = {
@@ -58,25 +53,20 @@ const STATUS_CLASS: Record<string, string> = {
cancelled: "text-muted-foreground",
};
function timeAgo(iso: string | null): string {
type Translate = (key: string, options?: Record<string, unknown>) => string;
function timeAgo(iso: string | null, t: Translate): string {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return "";
const base = "newUi.sidebar.automations.ago";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
if (sec < 60) return t(`${base}.seconds`, { count: sec });
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
if (min < 60) return t(`${base}.minutes`, { count: min });
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
/** Fleets from the fleets plugin, or none when it is not running. */
async function listFleets(): Promise<{ id: number; name: string }[]> {
const result = await invokeAction("fleets.list");
return Array.isArray(result)
? (result as { id: number; name: string }[])
: [];
if (hr < 24) return t(`${base}.hours`, { count: hr });
return t(`${base}.days`, { count: Math.floor(hr / 24) });
}
export function AutomationsPanel({
@@ -87,6 +77,7 @@ export function AutomationsPanel({
onEditingChange?: (editing: boolean) => void;
}) {
const { t } = useTranslation();
const api = useAutomationsApi();
const base = "newUi.sidebar.automations";
const [tab, setTab] = useState<PanelTab>("automations");
@@ -113,60 +104,34 @@ export function AutomationsPanel({
const loadAutomations = useCallback(async () => {
setLoading(true);
try {
setAutomations(await listAutomations());
setAutomations(await api.list());
} catch {
// A failed load leaves the previous list on screen.
} finally {
setLoading(false);
}
}, []);
}, [api]);
const loadRuns = useCallback(async () => {
try {
setRuns(await listAutomationRuns({ limit: 100 }));
setRuns(await api.runs({ limit: 100 }));
} catch {
// Non-fatal.
}
}, []);
}, [api]);
const loadOptions = useCallback(async () => {
const [hosts, snippets, channelsResult, fleets] = await Promise.allSettled([
getSSHHosts({ includeStatus: false }),
getSnippets(),
const [editorOptions, channelsResult] = await Promise.allSettled([
api.editorOptions(),
getNotificationChannels(),
listFleets(),
]);
if (channelsResult.status === "fulfilled") {
setChannels(channelsResult.value);
}
setOptions({
hosts:
hosts.status === "fulfilled"
? hosts.value.map((host) => ({
id: host.id,
name: host.name || host.ip,
}))
: [],
snippets:
snippets.status === "fulfilled"
? snippets.value.map((s) => ({
id: s.id,
// SnippetRow only types its id; the rest is narrowed by callers.
name: typeof s.name === "string" ? s.name : `#${s.id}`,
}))
: [],
channels:
channelsResult.status === "fulfilled"
? channelsResult.value.map((c) => ({ id: c.id, name: c.name }))
: [],
fleets:
fleets.status === "fulfilled"
? fleets.value.map((f) => ({ id: f.id, name: f.name }))
: [],
});
}, []);
if (editorOptions.status === "fulfilled") {
setOptions(editorOptions.value);
}
}, [api]);
useEffect(() => {
if (!active) return;
@@ -222,7 +187,7 @@ export function AutomationsPanel({
setSaving(true);
try {
if (editingId === null) {
const created = await createAutomation({
const created = await api.create({
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
@@ -236,7 +201,7 @@ export function AutomationsPanel({
setEditorOpen(false);
}
} else {
await updateAutomation(editingId, {
await api.update(editingId, {
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
@@ -257,7 +222,7 @@ export function AutomationsPanel({
async function remove(row: AutomationRow) {
if (!confirm(t(`${base}.deleteConfirm`))) return;
try {
await deleteAutomation(row.id);
await api.remove(row.id);
toast.success(t(`${base}.deleted`));
await loadAutomations();
} catch (error) {
@@ -267,7 +232,7 @@ export function AutomationsPanel({
async function run(row: AutomationRow, dryRun: boolean) {
try {
const outcome = await runAutomation(row.id, { dryRun });
const outcome = await api.run(row.id, { dryRun });
toast.success(t(`${base}.runFinished`, { status: outcome.status }));
await loadAutomations();
if (tab === "runs") await loadRuns();
@@ -304,7 +269,7 @@ export function AutomationsPanel({
}
setExpandedRun(runId);
try {
setRunSteps(await listAutomationRunSteps(runId));
setRunSteps(await api.runSteps(runId));
} catch {
setRunSteps([]);
}
@@ -314,7 +279,7 @@ export function AutomationsPanel({
// host manager does, rather than opening a dialog over the app.
if (editorOpen) {
const webhookUrl = webhookToken
? `${window.location.origin}/automations/webhook/${webhookToken}`
? `${window.location.origin}${getBasePath()}/plugin-api/automations/webhook/${webhookToken}`
: "";
return (
@@ -432,7 +397,7 @@ export function AutomationsPanel({
rel="noreferrer"
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
{t(`${base}.docsLink`)}
</a>
</div>
@@ -475,9 +440,20 @@ export function AutomationsPanel({
>
{row.name}
</button>
{!!row.missingPlugins?.length && (
<Badge
variant="outline"
className="text-[10px] border-destructive/50 text-destructive"
title={t(`${base}.needsPluginHint`)}
>
{t(`${base}.needsPlugin`, {
plugin: row.missingPlugins.join(", "),
})}
</Badge>
)}
{!row.enabled && (
<Badge variant="outline" className="text-[10px]">
off
{t(`${base}.offBadge`)}
</Badge>
)}
</div>
@@ -490,7 +466,7 @@ export function AutomationsPanel({
)
: ""}
{row.last_run_status
? ` · ${timeAgo(row.last_run_at)}`
? ` · ${timeAgo(row.last_run_at, t)}`
: ""}
</span>
<Button
@@ -555,7 +531,7 @@ export function AutomationsPanel({
</span>
</div>
<div className="text-[11px] text-muted-foreground">
{timeAgo(entry.started_at)}
{timeAgo(entry.started_at, t)}
{entry.duration_ms !== null
? ` · ${Math.round(entry.duration_ms / 100) / 10}s`
: ""}
@@ -1,4 +1,8 @@
import { authApi, handleApiError } from "@/main-axios";
import { useMemo } from "react";
import {
usePluginApi,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import type {
AutomationDefinition,
ConcurrencyPolicy,
@@ -6,7 +10,7 @@ import type {
StepStatus,
StepType,
TriggerKind,
} from "@/types/automations";
} from "../types";
export interface AutomationRow {
id: number;
@@ -24,6 +28,8 @@ export interface AutomationRow {
created_at: string;
updated_at: string;
channels: number[];
/** Plugins this automation needs that are not running. */
missingPlugins?: string[];
/** Returned once, only when a webhook automation is created. */
webhookToken?: string;
}
@@ -73,76 +79,82 @@ export interface AutomationInput {
channels?: number[];
}
export async function listAutomations(): Promise<AutomationRow[]> {
/** Which optional plugins are running, keyed like the backend's deps. */
export interface AutomationProviders {
snippets: boolean;
fleets: boolean;
tunnels: boolean;
docker: boolean;
"docker-events": boolean;
"host-metrics": boolean;
"wake-on-lan": boolean;
}
export interface AutomationEditorOptionsResponse {
hosts: Array<{ id: number; name: string }>;
snippets: Array<{ id: number; name: string }>;
fleets: Array<{ id: number; name: string }>;
channels: Array<{ id: number; name: string }>;
providers: AutomationProviders;
}
function apiError(error: unknown, action: string): Error {
const data = (
error as { response?: { data?: { error?: string; message?: string } } }
)?.response?.data;
return new Error(
data?.error ||
data?.message ||
(error instanceof Error ? error.message : `Failed to ${action}`),
);
}
async function call<T>(
action: string,
request: () => Promise<{ data: T }>,
): Promise<T> {
try {
return (await authApi.get("/plugin-api/automations")).data;
return (await request()).data;
} catch (error) {
throw handleApiError(error, "fetch automations");
throw apiError(error, action);
}
}
export async function createAutomation(
input: AutomationInput,
): Promise<AutomationRow> {
try {
return (await authApi.post("/plugin-api/automations", input)).data;
} catch (error) {
throw handleApiError(error, "create automation");
}
export function createAutomationsApi(api: PluginApiClient) {
return {
list: () => call<AutomationRow[]>("fetch automations", () => api.get("/")),
editorOptions: () =>
call<AutomationEditorOptionsResponse>("fetch editor options", () =>
api.get("/editor-options"),
),
create: (input: AutomationInput) =>
call<AutomationRow>("create automation", () => api.post("/", input)),
update: (id: number, input: Partial<AutomationInput>) =>
call<AutomationRow>("update automation", () => api.put(`/${id}`, input)),
remove: (id: number) =>
call<{ success: boolean }>("delete automation", () =>
api.delete(`/${id}`),
),
run: (id: number, options: { dryRun?: boolean } = {}) =>
call<AutomationRunOutcome>("run automation", () =>
api.post(`/${id}/run`, options),
),
runs: (
options: { automationId?: number; limit?: number; offset?: number } = {},
) =>
call<AutomationRunRow[]>("fetch automation runs", () =>
api.get("/runs/history", { params: options }),
),
runSteps: (runId: number) =>
call<AutomationRunStepRow[]>("fetch run steps", () =>
api.get(`/runs/${runId}/steps`),
),
};
}
export async function updateAutomation(
id: number,
input: Partial<AutomationInput>,
): Promise<AutomationRow> {
try {
return (await authApi.put(`/plugin-api/automations/${id}`, input)).data;
} catch (error) {
throw handleApiError(error, "update automation");
}
}
export type AutomationsApi = ReturnType<typeof createAutomationsApi>;
export async function deleteAutomation(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/automations/${id}`);
} catch (error) {
throw handleApiError(error, "delete automation");
}
}
export async function runAutomation(
id: number,
options: { dryRun?: boolean } = {},
): Promise<AutomationRunOutcome> {
try {
return (await authApi.post(`/plugin-api/automations/${id}/run`, options))
.data;
} catch (error) {
throw handleApiError(error, "run automation");
}
}
export async function listAutomationRuns(
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
try {
return (
await authApi.get("/plugin-api/automations/runs/history", {
params: options,
})
).data;
} catch (error) {
throw handleApiError(error, "fetch automation runs");
}
}
export async function listAutomationRunSteps(
runId: number,
): Promise<AutomationRunStepRow[]> {
try {
return (await authApi.get(`/plugin-api/automations/runs/${runId}/steps`))
.data;
} catch (error) {
throw handleApiError(error, "fetch run steps");
}
export function useAutomationsApi(): AutomationsApi {
const api = usePluginApi();
return useMemo(() => createAutomationsApi(api), [api]);
}
@@ -1,24 +1,23 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Plus } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import { Switch } from "@/components/switch";
import {
Button,
Input,
Label,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
} from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Plus } from "lucide-react";
import {
AUTOMATION_DEFINITION_VERSION,
type AutomationDefinition,
type ConcurrencyPolicy,
type Step,
} from "@/types/automations";
} from "../../types";
import { StepBlock } from "./StepBlock";
import { TriggerCard, defaultTrigger } from "./TriggerCard";
import { newStepId, type AutomationEditorOptions } from "./editor-types";
@@ -246,7 +245,9 @@ export function AutomationEditor({
setDefinition(parsed as AutomationDefinition);
setJsonError(null);
} catch (error) {
setJsonError(getErrorMessage(error, String(error)));
setJsonError(
error instanceof Error ? error.message : String(error),
);
}
}}
/>
@@ -1,13 +1,13 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Label } from "@/components/label";
import {
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { HostSelector } from "@/types/automations";
} from "@termix/plugin-sdk/ui";
import type { HostSelector } from "../../types";
import type { AutomationEditorOptions } from "./editor-types";
/**
@@ -56,9 +56,11 @@ export function HostSelectorField({
<SelectItem value="host">
{t(`${base}.hostSelector.host`)}
</SelectItem>
<SelectItem value="fleet">
{t(`${base}.hostSelector.fleet`)}
</SelectItem>
{(options.providers.fleets || kind === "fleet") && (
<SelectItem value="fleet">
{t(`${base}.hostSelector.fleet`)}
</SelectItem>
)}
<SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem>
</SelectContent>
</Select>
@@ -1,20 +1,23 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import { Textarea } from "@/components/textarea";
import { Switch } from "@/components/switch";
import {
Button,
Input,
Label,
Textarea,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Step, StepType } from "@/types/automations";
} from "@termix/plugin-sdk/ui";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import type { Step, StepType } from "../../types";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
import {
missingStepProvider,
type AutomationEditorOptions,
} from "./editor-types";
const STEP_TYPES: StepType[] = [
"notify",
@@ -86,11 +89,20 @@ export function StepBlock({
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(`${base}.stepTypes.${type}`)}
</SelectItem>
))}
{STEP_TYPES.map((type) => {
const needs = missingStepProvider(type, options.providers);
// A type whose plugin is off is only listed when this step
// already uses it, marked as needing that plugin.
if (needs && type !== step.type) return null;
return (
<SelectItem key={type} value={type}>
{t(`${base}.stepTypes.${type}`)}
{needs
? ` (${t(`${base}.needsPlugin`, { plugin: needs })})`
: ""}
</SelectItem>
);
})}
</SelectContent>
</Select>
@@ -1,16 +1,19 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Trigger, TriggerKind } from "@/types/automations";
} from "@termix/plugin-sdk/ui";
import type { Trigger, TriggerKind } from "../../types";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
import {
missingTriggerProvider,
type AutomationEditorOptions,
} from "./editor-types";
const TRIGGER_KINDS: TriggerKind[] = [
"metric_threshold",
@@ -98,11 +101,20 @@ export function TriggerCard({
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`${base}.triggerKinds.${kind}`)}
</SelectItem>
))}
{TRIGGER_KINDS.map((kind) => {
const needs = missingTriggerProvider(kind, options.providers);
// A kind whose plugin is off is only listed when this trigger
// already uses it, marked as needing that plugin.
if (needs && kind !== trigger.kind) return null;
return (
<SelectItem key={kind} value={kind}>
{t(`${base}.triggerKinds.${kind}`)}
{needs
? ` (${t(`${base}.needsPlugin`, { plugin: needs })})`
: ""}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
@@ -1,9 +1,14 @@
import type { StepType, TriggerKind } from "../../types";
import type { AutomationProviders } from "../automations-api";
/** Lookup data the editor needs to turn ids into names. */
export interface AutomationEditorOptions {
hosts: Array<{ id: number; name: string }>;
snippets: Array<{ id: number; name: string }>;
channels: Array<{ id: number; name: string }>;
fleets: Array<{ id: number; name: string }>;
/** Which optional plugins are running. */
providers: AutomationProviders;
}
export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
@@ -11,9 +16,57 @@ export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
snippets: [],
channels: [],
fleets: [],
providers: {
snippets: false,
fleets: false,
tunnels: false,
docker: false,
"docker-events": false,
"host-metrics": false,
"wake-on-lan": false,
},
};
/** Short unique id for a new step, stable for the life of the automation. */
export function newStepId(): string {
return `s${Math.random().toString(36).slice(2, 8)}`;
}
/** The plugin a trigger kind needs, when it is not core. */
const TRIGGER_PROVIDERS: Partial<
Record<TriggerKind, keyof AutomationProviders>
> = {
metric_threshold: "host-metrics",
health_check: "host-metrics",
docker_event: "docker-events",
};
/** The plugin a step type needs, when it is not core. */
const STEP_PROVIDERS: Partial<Record<StepType, keyof AutomationProviders>> = {
run_snippet: "snippets",
docker: "docker",
tunnel: "tunnels",
wol: "wake-on-lan",
};
/** The plugin id shown in "needs <plugin>", for a provider key. */
export function providerPlugin(key: keyof AutomationProviders): string {
return key === "docker-events" ? "docker" : key;
}
/** The provider a kind needs that is not running, or null when it can run. */
export function missingTriggerProvider(
kind: TriggerKind,
providers: AutomationProviders,
): string | null {
const key = TRIGGER_PROVIDERS[kind];
return key && !providers[key] ? providerPlugin(key) : null;
}
export function missingStepProvider(
type: StepType,
providers: AutomationProviders,
): string | null {
const key = STEP_PROVIDERS[type];
return key && !providers[key] ? providerPlugin(key) : null;
}
+3 -2
View File
@@ -5,7 +5,7 @@ import type {
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { AutomationsPanel } from "./AutomationsPanel";
import { listAutomations } from "./automations-api";
import { createAutomationsApi } from "./automations-api";
const VIEW_ID = "automations";
@@ -40,7 +40,8 @@ export function activate(app: TermixApp): void {
});
// The assistant's @-mentions list automations through this.
app.registerAction("automations.list", () => listAutomations());
const api = createAutomationsApi(app.api);
app.registerAction("automations.list", () => api.list());
app.registerSlotContribution("onboarding.features", {
actionId: "automations.feature",
File renamed without changes.
@@ -0,0 +1,82 @@
import express from "express";
import { afterEach, describe, expect, it } from "vitest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { activate } from "../../src/backend/index.js";
import {
NOTIFICATION_CHANNELS_DDL,
definition,
manifest,
pluginDir,
startServer,
type TestServer,
} from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("automations activate", () => {
it("provides automations.access and mounts the webhook as public", async () => {
server = await startServer();
expect(server.mock.services.get("automations.access")).toBeDefined();
expect(server.mock.httpRouters).toEqual([{ public: ["/webhook/:token"] }]);
});
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (capability) => {
db = await createTestDb(pluginDir, {
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
});
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
for (const dispose of [...mock.disposals].reverse()) await dispose();
},
);
it("cannot notify without notify:send", async () => {
server = await startServer();
const withoutNotify = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== "notify:send"),
});
await expect(
withoutNotify.ctx.notify.send([1], { title: "t", body: "b" }),
).rejects.toBeInstanceOf(PluginCapabilityError);
});
it("stops every timer on deactivate", async () => {
server = await startServer();
await server.request("POST", "/", {
body: {
name: "Tick",
definition: definition({ kind: "schedule", intervalSeconds: 300 }),
},
});
for (const dispose of [...server.mock.disposals].reverse()) {
await Promise.resolve(dispose()).catch(() => {});
}
server.mock.disposals.length = 0;
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
});
});
@@ -1,131 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginServices } from "@termix/plugin-sdk/backend";
const fired = vi.hoisted(() => [] as unknown[]);
vi.mock("../../src/backend/triggers.js", () => ({
onDockerEvent: async (event: unknown) => {
fired.push(event);
},
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => ({ listAllEnabled: async () => [] }),
}));
import {
reconcileDockerWatch,
resetDockerWatcher,
} from "../../src/backend/docker-watcher.js";
import {
setDockerServices,
runDockerAction,
} from "../../src/backend/docker.js";
type Listener = (event: {
hostId: number;
container: string;
event: string;
}) => void;
/** A docker.events provider that records who subscribed to what. */
function fakeEvents() {
const subscriptions = new Map<string, Set<Listener>>();
const calls: Array<{ userId?: string; hostId: number }> = [];
const services = {
provide: () => {},
providers: () => [],
get: (name: string, options?: { userId?: string }) => {
if (name !== "docker.events") return {};
return {
subscribe: async (hostId: number, listener: Listener) => {
calls.push({ userId: options?.userId, hostId });
const key = `${options?.userId}:${hostId}`;
const set = subscriptions.get(key) ?? new Set<Listener>();
set.add(listener);
subscriptions.set(key, set);
return () => set.delete(listener);
},
};
},
} as unknown as PluginServices;
const emit = (key: string, event: Parameters<Listener>[0]) => {
for (const listener of subscriptions.get(key) ?? []) listener(event);
};
return { services, subscriptions, calls, emit };
}
beforeEach(() => {
fired.length = 0;
});
afterEach(() => {
resetDockerWatcher();
setDockerServices(null);
});
describe("docker watcher", () => {
it("subscribes each watched host as its owner and fires the trigger", async () => {
const events = fakeEvents();
setDockerServices(events.services);
await reconcileDockerWatch(0, new Map([[7, "user-1"]]));
expect(events.calls).toEqual([{ userId: "user-1", hostId: 7 }]);
events.emit("user-1:7", { hostId: 7, container: "web", event: "exited" });
await Promise.resolve();
expect(fired).toEqual([
{ hostId: 7, ownerUserId: "user-1", container: "web", event: "exited" },
]);
});
it("re-subscribes once a minute and drops hosts no longer watched", async () => {
const events = fakeEvents();
setDockerServices(events.services);
const watched = new Map([[7, "user-1"]]);
await reconcileDockerWatch(1_000, watched);
await reconcileDockerWatch(30_000, watched);
expect(events.calls).toHaveLength(1);
await reconcileDockerWatch(62_000, watched);
expect(events.calls).toHaveLength(2);
expect(events.subscriptions.get("user-1:7")?.size).toBe(1);
await reconcileDockerWatch(63_000, new Map());
expect(events.subscriptions.get("user-1:7")?.size).toBe(0);
});
it("does nothing while the docker plugin is off, then picks it up", async () => {
setDockerServices({
get: () => ({}),
} as unknown as PluginServices);
await expect(
reconcileDockerWatch(0, new Map([[7, "user-1"]])),
).resolves.toBeUndefined();
const events = fakeEvents();
setDockerServices(events.services);
await reconcileDockerWatch(1, new Map([[7, "user-1"]]));
expect(events.calls).toHaveLength(1);
});
it("fails a docker step cleanly while the plugin is off", async () => {
setDockerServices({ get: () => ({}) } as unknown as PluginServices);
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
ok: false,
unavailable: true,
error: "The docker plugin is not available",
});
});
it("runs a docker step through the docker service", async () => {
const action = vi.fn(async () => {});
setDockerServices({
get: (name: string) => (name === "docker.containers" ? { action } : {}),
} as unknown as PluginServices);
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
ok: true,
});
expect(action).toHaveBeenCalledWith(7, "web", "restart");
});
});
@@ -0,0 +1,383 @@
import { afterEach, describe, expect, it } from "vitest";
import { definition, startServer, type TestServer } from "./helpers";
/**
* The engine as activate() wires it: the ctx.schedule tick, the services it
* reaches, and what happens when a plugin an automation needs is off.
*/
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
async function create(
s: TestServer,
name: string,
body: ReturnType<typeof definition>,
): Promise<number> {
const created = await s.request("POST", "/", {
body: { name, definition: body },
});
expect(created.status).toBe(201);
return created.body.id;
}
function makeDue(s: TestServer, automationId: number) {
s.db.sqlite
.prepare(
"UPDATE p_automations_schedules SET next_due_at = ? WHERE automation_id = ?",
)
.run(new Date(Date.now() - 60_000).toISOString(), automationId);
}
describe("scheduled automations", () => {
it("fire from the ctx.schedule tick and notify as the owner", async () => {
server = await startServer();
const id = await create(
server,
"Every five minutes",
definition({ kind: "schedule", intervalSeconds: 300 }, [
{
id: "n",
type: "notify",
channelIds: [1],
title: "Ping {{trigger.type}}",
body: "hello",
},
]),
);
makeDue(server, id);
// The tick is registered through ctx.schedule, never a bare timer.
expect(server.mock.scheduled.filter((job) => !job.stopped).length).toBe(2);
await server.mock.runScheduled();
expect(await server.waitForRun(id)).toEqual({
status: "success",
error: null,
});
expect(server.mock.notifications).toEqual([
{
actor: "alice",
channelIds: [1],
notification: expect.objectContaining({
title: "Ping schedule",
body: "hello",
}),
},
]);
// The next due time moved forward, so the next tick does not re-run it.
const due = server.db.sqlite
.prepare(
"SELECT next_due_at FROM p_automations_schedules WHERE automation_id = ?",
)
.get(id) as { next_due_at: string };
expect(new Date(due.next_due_at).getTime()).toBeGreaterThan(Date.now());
});
it("are skipped with a reason while the docker plugin is off, and the engine keeps going", async () => {
server = await startServer();
const needsDocker = await create(
server,
"Restart app",
definition({ kind: "schedule", intervalSeconds: 300 }, [
{
id: "d",
type: "docker",
action: "restart",
container: "app",
hostSelector: { kind: "host", hostId: 1 },
},
]),
);
const plain = await create(
server,
"Plain",
definition({ kind: "schedule", intervalSeconds: 300 }),
);
makeDue(server, needsDocker);
makeDue(server, plain);
await server.mock.runScheduled();
expect(await server.waitForRun(needsDocker)).toEqual({
status: "skipped",
error: "Needs the docker plugin",
});
expect(await server.waitForRun(plain)).toEqual({
status: "success",
error: null,
});
// A manual run says the same thing instead of throwing.
const manual = await server.request("POST", `/${needsDocker}/run`, {
body: {},
});
expect(manual.status).toBe(200);
expect(manual.body).toMatchObject({
status: "skipped",
error: "Needs the docker plugin",
});
});
it("run once docker is back", async () => {
const actions: unknown[] = [];
server = await startServer({
services: {
"docker.containers": {
action: async (...args: unknown[]) => {
actions.push(args);
},
},
},
});
const id = await create(
server,
"Restart app",
definition({ kind: "schedule", intervalSeconds: 300 }, [
{
id: "d",
type: "docker",
action: "restart",
container: "app",
hostSelector: { kind: "host", hostId: 1 },
},
]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
// Host 1 does not resolve here, so the step has no target, but the
// automation is no longer skipped for a missing plugin.
expect(outcome.body.status).not.toBe("skipped");
});
});
describe("automations.access", () => {
it("lists, reads, creates disabled and runs as the caller", async () => {
server = await startServer();
const service = server.service;
const created = await server.mock.ctx.asUser("alice", () =>
service.create({
name: "From the assistant",
definition: definition({ kind: "schedule", intervalSeconds: 600 }),
}),
);
const listed = await server.mock.ctx.asUser("alice", () => service.list());
expect(listed).toEqual([
expect.objectContaining({
id: created.id,
enabled: false,
triggerKind: "schedule",
missingPlugins: [],
}),
]);
const one = await server.mock.ctx.asUser("alice", () =>
service.get(created.id),
);
expect(one?.definition?.trigger.kind).toBe("schedule");
expect(
await server.mock.ctx.asUser("bob", () => service.get(created.id)),
).toBeNull();
const outcome = await server.mock.ctx.asUser("alice", () =>
service.run(created.id, { dryRun: true }),
);
expect(outcome.status).toBe("success");
});
it("refuses an invalid definition", async () => {
server = await startServer();
await expect(
server.mock.ctx.asUser("alice", () =>
server!.service.create({ name: "x", definition: { steps: [] } }),
),
).rejects.toThrow(/trigger/);
});
});
describe("optional steps", () => {
it("fail the step cleanly while the tunnels plugin is off", async () => {
server = await startServer();
const id = await create(
server,
"Tunnel",
definition({ kind: "webhook" }, [
{ id: "t", type: "tunnel", action: "connect", tunnelName: "db" },
]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body).toMatchObject({
status: "skipped",
error: "Needs the tunnels plugin",
});
});
it("start a tunnel through tunnels.access", async () => {
const started: string[] = [];
server = await startServer({
services: {
"tunnels.access": {
start: async (name: string) => {
started.push(name);
},
stop: async () => {},
},
},
});
const id = await create(
server,
"Tunnel",
definition({ kind: "webhook" }, [
{ id: "t", type: "tunnel", action: "connect", tunnelName: "db" },
]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body.status).toBe("success");
expect(started).toEqual(["db"]);
});
it("wake a host through the wake-on-lan service", async () => {
const woken: number[] = [];
server = await startServer({
services: {
"wake-on-lan.send": {
wake: async (hostId: number) => {
woken.push(hostId);
},
},
},
});
const id = await create(
server,
"Wake",
definition({ kind: "webhook" }, [{ id: "w", type: "wol", hostId: 2 }]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body.status).toBe("success");
expect(woken).toEqual([2]);
});
});
describe("watchers", () => {
it("subscribe to docker.events as the owner and fire docker_event triggers", async () => {
let listener: ((event: unknown) => void) | null = null;
const subscribedAs: Array<string | undefined> = [];
server = await startServer({
services: {
"docker.events": {
subscribe: async (_hostId: number, fn: (event: unknown) => void) => {
listener = fn;
subscribedAs.push(server?.mock.ctx.currentActor());
return () => {
listener = null;
};
},
},
},
});
const id = await create(
server,
"Container died",
definition({
kind: "docker_event",
hostSelector: { kind: "host", hostId: 1 },
container: "app",
event: "exited",
cooldownMinutes: 0,
}),
);
await server.mock.runScheduled();
expect(subscribedAs).toEqual(["alice"]);
expect(listener).not.toBeNull();
listener!({ hostId: 1, container: "app", event: "exited" });
expect(await server.waitForRun(id)).toEqual({
status: "success",
error: null,
});
});
it("keep headless metrics viewers registered as the owner", async () => {
const registered: Array<[number, string | undefined]> = [];
let beats = 0;
server = await startServer({
services: {
"host-metrics.viewers": {
register: async (hostId: number) => {
registered.push([hostId, server?.mock.ctx.currentActor()]);
return { viewerSessionId: `v${hostId}` };
},
heartbeat: () => {
beats++;
return true;
},
unregister: () => {},
},
},
});
await create(
server,
"CPU",
definition({
kind: "metric_threshold",
hostSelector: { kind: "hosts", hostIds: [1, 2] },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
cooldownMinutes: 15,
}),
);
await server.mock.runScheduled();
expect(registered).toEqual([
[1, "alice"],
[2, "alice"],
]);
const before = beats;
await server.mock.runScheduled();
// Each tick heartbeats both viewers and registers nothing new.
expect(beats).toBeGreaterThan(before);
expect((beats - before) % 2).toBe(0);
expect(registered).toHaveLength(2);
});
it("fire a metric trigger from a host-metrics snapshot event", async () => {
server = await startServer({
services: {
"host-metrics.viewers": {
register: async () => ({ viewerSessionId: "v" }),
heartbeat: () => true,
unregister: () => {},
},
},
});
const id = await create(
server,
"CPU",
definition({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 1 },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
cooldownMinutes: 15,
}),
);
server.mock.ctx.events.emit("plugin.host-metrics.snapshot", {
hostId: 1,
ownerUserId: "alice",
metrics: { cpu: { percent: 97 } },
});
expect(await server.waitForRun(id)).toEqual({
status: "success",
error: null,
});
});
});
@@ -1,13 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
AutomationDefinition,
Step,
} from "../../../../src/types/automations.js";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition, Step } from "../../src/types.js";
/**
* The engine reaches the database through the repository factory and the
* outside world through the step executors, so both are mocked here. What is
* under test is the run loop itself: ordering, branching, error policy,
* The engine reaches the database through its repository and the outside
* world through the step executors, so both are faked here. What is under
* test is the run loop itself: ordering, branching, error policy,
* concurrency, recursion and dry-run.
*/
@@ -50,15 +49,7 @@ const repository = {
}),
};
const resolveHostById = vi.fn();
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
let hosts: PluginHostSummary[] = [];
const executeStep = vi.fn();
vi.mock("../../src/backend/actions/index.js", () => ({
@@ -67,6 +58,18 @@ vi.mock("../../src/backend/actions/index.js", () => ({
const { AutomationEngine } = await import("../../src/backend/engine.js");
// Every provider is running unless a test says otherwise.
const deps = { available: () => true };
let current: InstanceType<typeof AutomationEngine>;
function getEngine() {
return current;
}
function freshEngine() {
const { ctx } = createFakeContext({ pluginId: "automations", hosts });
current = new AutomationEngine(ctx, repository as never, deps as never);
}
function defineAutomation(
steps: Step[],
overrides: Partial<FakeAutomation> = {},
@@ -102,23 +105,30 @@ beforeEach(() => {
nextRunId = 1;
nextStepRowId = 1;
vi.clearAllMocks();
resolveHostById.mockResolvedValue(null);
hosts = [];
executeStep.mockResolvedValue({ success: true, output: "ok" });
// The singleton carries in-flight state between tests.
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
freshEngine();
});
describe("AutomationEngine.run", () => {
it("adds the trigger host name to the template context", async () => {
defineAutomation([step({ id: "notify", type: "notify" })]);
resolveHostById.mockResolvedValue({
name: "Proxmox Node",
ip: "10.0.0.11",
username: "root",
port: 22,
});
hosts = [
{
id: 11,
userId: "user-1",
name: "Proxmox Node",
ip: "10.0.0.11",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
},
];
freshEngine();
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "metric_threshold",
triggerHostId: 11,
@@ -143,7 +153,7 @@ describe("AutomationEngine.run", () => {
step({ id: "b", type: "http" }),
]);
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -161,7 +171,7 @@ describe("AutomationEngine.run", () => {
]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -178,7 +188,7 @@ describe("AutomationEngine.run", () => {
]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -193,7 +203,7 @@ describe("AutomationEngine.run", () => {
step({ id: "b", type: "http" }),
]);
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -209,7 +219,7 @@ describe("AutomationEngine.run", () => {
]);
executeStep.mockResolvedValueOnce({ success: true, output: "hello" });
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -231,7 +241,7 @@ describe("AutomationEngine.run", () => {
vars: { x: "1" },
});
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -254,7 +264,7 @@ describe("AutomationEngine.run", () => {
}),
]);
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -274,7 +284,7 @@ describe("AutomationEngine.run", () => {
}),
]);
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -296,7 +306,7 @@ describe("AutomationEngine.run", () => {
}),
]);
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "metric_threshold",
triggerContext: { value: 95 },
@@ -316,7 +326,7 @@ describe("AutomationEngine.run", () => {
step({ id: "after", type: "http" }),
]);
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -335,7 +345,7 @@ describe("AutomationEngine.run", () => {
halt: { status: "success" },
});
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -351,7 +361,7 @@ describe("AutomationEngine.run", () => {
halt: { status: "failed" },
});
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -365,7 +375,7 @@ describe("AutomationEngine.run", () => {
step({ id: "nested", type: "run_automation", automationId: 1 }),
]);
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -389,7 +399,7 @@ describe("AutomationEngine.run", () => {
{ id: 2 },
);
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -402,7 +412,7 @@ describe("AutomationEngine.run", () => {
it("refuses to nest deeper than the maximum depth", async () => {
defineAutomation([step({ id: "a", type: "http" })]);
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
depth: 99,
@@ -428,7 +438,7 @@ describe("AutomationEngine.run", () => {
}),
);
const engine = AutomationEngine.getInstance();
const engine = getEngine();
const first = engine.run({ automationId: 1, triggerType: "manual" });
// Let the first run register itself as in flight.
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -461,7 +471,7 @@ describe("AutomationEngine.run", () => {
// No gap between the two: the in-flight slot used to be claimed several
// awaits after it was checked, so both runs got through.
const engine = AutomationEngine.getInstance();
const engine = getEngine();
const [first, second] = await Promise.all([
engine.run({ automationId: 1, triggerType: "manual" }),
engine.run({ automationId: 1, triggerType: "manual" }),
@@ -478,7 +488,7 @@ describe("AutomationEngine.run", () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
repository.createRun.mockRejectedValueOnce(new Error("db down"));
const engine = AutomationEngine.getInstance();
const engine = getEngine();
const failed = await engine.run({
automationId: 1,
triggerType: "manual",
@@ -497,7 +507,7 @@ describe("AutomationEngine.run", () => {
it("propagates the dry-run flag to executors", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: true });
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -509,7 +519,7 @@ describe("AutomationEngine.run", () => {
it("lets a caller force a dry run on a live automation", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: false });
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
dryRun: true,
@@ -520,7 +530,7 @@ describe("AutomationEngine.run", () => {
});
it("fails cleanly when the automation is missing", async () => {
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 404,
triggerType: "manual",
});
@@ -539,7 +549,7 @@ describe("AutomationEngine.run", () => {
dryRun: false,
});
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -551,7 +561,7 @@ describe("AutomationEngine.run", () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockRejectedValueOnce(new Error("connection reset"));
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -568,7 +578,7 @@ describe("AutomationEngine.run", () => {
output: "x".repeat(40_000),
});
await AutomationEngine.getInstance().run({
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -591,7 +601,7 @@ describe("AutomationEngine.run", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const outcome = await AutomationEngine.getInstance().run({
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
@@ -1,136 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const listAutomationWatchedHosts = vi.fn();
vi.mock("../../src/backend/triggers.js", () => ({
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
}));
const {
automationSessionId,
reconcileHeadlessViewers,
releaseHeadlessViewers,
setViewerRegistry,
} = await import("../../src/backend/headless-viewer.js");
const registry = {
registerViewer: vi.fn(),
unregisterViewer: vi.fn(),
updateHeartbeat: vi.fn(() => true),
};
beforeEach(() => {
// Drop any viewers the previous test left behind before the mocks are
// cleared, so those unregister calls are not counted against this test.
releaseHeadlessViewers();
vi.clearAllMocks();
setViewerRegistry(registry);
listAutomationWatchedHosts.mockResolvedValue(new Map());
});
describe("reconcileHeadlessViewers", () => {
it("registers a synthetic viewer for each watched host", async () => {
listAutomationWatchedHosts.mockResolvedValue(
new Map([
[7, "user-1"],
[9, "user-2"],
]),
);
const result = await reconcileHeadlessViewers();
expect(result.added).toBe(2);
expect(registry.registerViewer).toHaveBeenCalledWith(
7,
"automation:7",
"user-1",
);
expect(registry.registerViewer).toHaveBeenCalledWith(
9,
"automation:9",
"user-2",
);
});
it("heartbeats instead of re-registering a host it already holds", async () => {
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
await reconcileHeadlessViewers();
registry.registerViewer.mockClear();
const second = await reconcileHeadlessViewers();
// The 120s reaper drops viewers with a stale heartbeat, so every tick has
// to refresh the ones it is keeping.
expect(registry.updateHeartbeat).toHaveBeenCalledWith("automation:7");
expect(registry.registerViewer).not.toHaveBeenCalled();
expect(second.added).toBe(0);
expect(second.active).toBe(1);
});
it("releases a viewer once no automation watches the host", async () => {
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
await reconcileHeadlessViewers();
listAutomationWatchedHosts.mockResolvedValue(new Map());
const result = await reconcileHeadlessViewers();
expect(result.removed).toBe(1);
expect(result.active).toBe(0);
expect(registry.unregisterViewer).toHaveBeenCalledWith(7, "automation:7");
});
it("does nothing when no registry has been wired up", async () => {
setViewerRegistry(null);
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
const result = await reconcileHeadlessViewers();
expect(result).toEqual({ added: 0, removed: 0, active: 0 });
expect(registry.registerViewer).not.toHaveBeenCalled();
});
it("keeps going when the watch list cannot be loaded", async () => {
listAutomationWatchedHosts.mockRejectedValue(new Error("db down"));
await expect(reconcileHeadlessViewers()).resolves.toMatchObject({
added: 0,
});
});
it("survives a registry that throws on register", async () => {
listAutomationWatchedHosts.mockResolvedValue(
new Map([
[7, "user-1"],
[8, "user-1"],
]),
);
registry.registerViewer.mockImplementationOnce(() => {
throw new Error("nope");
});
const result = await reconcileHeadlessViewers();
// One host failing must not stop the other from being registered.
expect(result.added).toBe(1);
});
});
describe("releaseHeadlessViewers", () => {
it("drops every viewer it is holding", async () => {
listAutomationWatchedHosts.mockResolvedValue(
new Map([
[7, "user-1"],
[8, "user-1"],
]),
);
await reconcileHeadlessViewers();
releaseHeadlessViewers();
expect(registry.unregisterViewer).toHaveBeenCalledTimes(2);
});
});
describe("automationSessionId", () => {
it("namespaces the session so it cannot collide with a real viewer", () => {
expect(automationSessionId(42)).toBe("automation:42");
});
});
@@ -0,0 +1,205 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type {
PluginHostSummary,
PluginNotificationChannel,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import type { AutomationsAccessV1 } from "../../src/backend/service.js";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export const ALL_PERMISSIONS = [
"automations.view",
"automations.create",
"automations.edit",
"automations.delete",
"automations.run",
];
/** Core's notification_channels, which automation_channels points at. */
export const NOTIFICATION_CHANNELS_DDL = `
CREATE TABLE notification_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
export function host(id: number, userId = "alice"): PluginHostSummary {
return {
id,
userId,
name: `host-${id}`,
ip: `10.0.0.${id}`,
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
};
}
/** A snippets.access provider, always there since snippets is a hard dependency. */
export function fakeSnippets() {
return {
list: async () => [{ id: 5, name: "Restart nginx", isNote: false }],
get: async (id: number) =>
id === 5
? {
id: 5,
name: "Restart nginx",
content: "systemctl restart nginx",
isNote: false,
}
: null,
resolveCommand: async (id: number) =>
id === 5 ? "systemctl restart nginx" : null,
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
service: AutomationsAccessV1;
request: (
method: string,
path: string,
options?: { user?: string | null; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
/** Waits until a run for the automation reaches a final status. */
waitForRun: (
automationId: number,
) => Promise<{ status: string; error: string | null }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it: JSON parsed and the acting user set from the
* request, everything else left to the plugin.
*/
export async function startServer(
options: {
permissions?: string[];
services?: Record<string, object>;
hosts?: PluginHostSummary[];
channels?: PluginNotificationChannel[];
before?: (db: TestDb["sqlite"]) => void;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(NOTIFICATION_CHANNELS_DDL);
options.before?.(sqlite);
},
});
for (const user of ["alice", "bob"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
const channels = options.channels ?? [
{ id: 1, name: "ops", type: "webhook", enabled: true },
];
for (const channel of channels) {
db.sqlite
.prepare(
"INSERT INTO notification_channels (id, user_id, name, type, config, enabled) VALUES (?, 'alice', ?, ?, '{}', ?)",
)
.run(channel.id, channel.name, channel.type, channel.enabled ? 1 : 0);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ALL_PERMISSIONS,
hosts: options.hosts ?? [host(1), host(2)],
notificationChannels: channels,
services: { "snippets.access": fakeSnippets(), ...options.services },
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") || undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
service: mock.services.get("automations.access") as AutomationsAccessV1,
async request(method, path, { user = "alice", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
...(user ? { "x-test-user": user } : {}),
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async waitForRun(automationId) {
for (let attempt = 0; attempt < 100; attempt++) {
const row = db.sqlite
.prepare(
"SELECT status, error FROM p_automations_runs WHERE automation_id = ? AND status != 'running' ORDER BY id DESC LIMIT 1",
)
.get(automationId) as
{ status: string; error: string | null } | undefined;
if (row) return row;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`No finished run for automation ${automationId}`);
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
for (const dispose of [...mock.disposals].reverse()) {
await Promise.resolve(dispose()).catch(() => {});
}
db.close();
},
};
}
/** A definition with one step, for tests that only care about the trigger. */
export function definition(
trigger: Record<string, unknown>,
steps: Array<Record<string, unknown>> = [
{ id: "v", type: "set_var", name: "done", value: "yes" },
],
) {
return { version: 1, trigger, steps };
}
@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { NOTIFICATION_CHANNELS_DDL, pluginDir } from "./helpers";
// The six tables as core's SQLite bootstrap created them before 2.9.0, with
// the indexes performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE automations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
definition TEXT NOT NULL,
definition_version INTEGER NOT NULL DEFAULT 1,
concurrency_policy TEXT NOT NULL DEFAULT 'skip',
max_run_seconds INTEGER NOT NULL DEFAULT 300,
dry_run INTEGER NOT NULL DEFAULT 0,
last_run_at TEXT,
last_run_status TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE automation_trigger_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
state_key TEXT NOT NULL,
breach_started_at TEXT,
last_fired_at TEXT,
last_value REAL,
last_observed_state TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE automation_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
cron TEXT,
interval_seconds INTEGER,
timezone TEXT,
next_due_at TEXT,
last_tick_at TEXT
);
CREATE TABLE automation_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL,
trigger_context TEXT,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
duration_ms INTEGER,
error TEXT,
dry_run INTEGER NOT NULL DEFAULT 0,
parent_run_id INTEGER
);
CREATE TABLE automation_run_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE,
step_index INTEGER NOT NULL,
step_id TEXT NOT NULL,
step_type TEXT NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
output TEXT,
error TEXT,
truncated INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE automation_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
);
CREATE INDEX idx_automations_user ON automations (user_id, enabled);
CREATE UNIQUE INDEX idx_automation_trigger_state_key ON automation_trigger_state (automation_id, state_key);
CREATE UNIQUE INDEX idx_automation_schedules_automation ON automation_schedules (automation_id);
CREATE INDEX idx_automation_schedules_due ON automation_schedules (next_due_at);
CREATE INDEX idx_automation_runs_automation ON automation_runs (automation_id, started_at);
CREATE INDEX idx_automation_runs_user ON automation_runs (user_id, started_at);
CREATE INDEX idx_automation_run_steps_run ON automation_run_steps (run_id, step_index);
CREATE UNIQUE INDEX idx_automation_channels_pair ON automation_channels (automation_id, channel_id);
`;
const SEED = `
INSERT INTO users (id, username) VALUES ('alice', 'alice');
INSERT INTO notification_channels (id, user_id, name, type, config) VALUES (4, 'alice', 'ops', 'webhook', '{}');
INSERT INTO automations (id, user_id, name, definition) VALUES (7, 'alice', 'Disk', '{"version":1,"trigger":{"kind":"webhook","tokenHash":"x"},"steps":[]}');
INSERT INTO automation_trigger_state (automation_id, state_key, last_fired_at) VALUES (7, '1:/data', '2026-01-01T00:00:00Z');
INSERT INTO automation_schedules (automation_id, interval_seconds, next_due_at) VALUES (7, 300, '2026-01-01T00:05:00Z');
INSERT INTO automation_runs (id, automation_id, user_id, trigger_type, status) VALUES (3, 7, 'alice', 'manual', 'success');
INSERT INTO automation_run_steps (run_id, step_index, step_id, step_type, status) VALUES (3, 0, 'a', 'notify', 'success');
INSERT INTO automation_channels (automation_id, channel_id) VALUES (7, 4);
`;
const ADOPTED = [
"p_automations_automations",
"p_automations_trigger_state",
"p_automations_schedules",
"p_automations_runs",
"p_automations_run_steps",
"p_automations_channels",
];
const LEGACY = [
"automations",
"automation_trigger_state",
"automation_schedules",
"automation_runs",
"automation_run_steps",
"automation_channels",
];
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
function tables(target: TestDb): string[] {
return (
target.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>
).map((row) => row.name);
}
function indexes(target: TestDb, table: string): string[] {
return (
target.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as Array<{ name: string }>
)
.map((row) => row.name)
.sort();
}
const count = (target: TestDb, table: string) =>
(
target.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
n: number;
}
).n;
describe("adopting the automation tables", () => {
it("keeps every row, index and cascade when the legacy tables exist", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(NOTIFICATION_CHANNELS_DDL);
sqlite.exec(LEGACY_DDL);
sqlite.exec(SEED);
},
});
const names = tables(db);
for (const table of ADOPTED) expect(names).toContain(table);
for (const table of LEGACY) expect(names).not.toContain(table);
for (const table of ADOPTED) expect(count(db, table)).toBe(1);
expect(
db.sqlite
.prepare("SELECT name FROM p_automations_automations WHERE id = 7")
.get(),
).toEqual({ name: "Disk" });
// Only one copy of each legacy index, never a second one.
expect(indexes(db, "p_automations_trigger_state")).toEqual([
"idx_automation_trigger_state_key",
]);
expect(indexes(db, "p_automations_runs")).toEqual([
"idx_automation_runs_automation",
"idx_automation_runs_user",
]);
// The foreign keys followed the rename: deleting the automation still
// takes its state, schedule, runs, steps and channel links with it.
db.sqlite
.prepare("DELETE FROM p_automations_automations WHERE id = 7")
.run();
for (const table of ADOPTED) expect(count(db, table)).toBe(0);
});
it("creates the same tables on a fresh install", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
});
const names = tables(db);
for (const table of ADOPTED) expect(names).toContain(table);
for (const table of LEGACY) expect(names).not.toContain(table);
expect(indexes(db, "p_automations_schedules")).toEqual([
"idx_automation_schedules_automation",
"idx_automation_schedules_due",
]);
db.sqlite.exec(`
INSERT INTO users (id, username) VALUES ('alice', 'alice');
INSERT INTO p_automations_automations (id, user_id, name, definition) VALUES (1, 'alice', 'A', '{}');
INSERT INTO p_automations_runs (id, automation_id, user_id, trigger_type, status) VALUES (1, 1, 'alice', 'manual', 'success');
INSERT INTO p_automations_run_steps (run_id, step_index, step_id, step_type, status) VALUES (1, 0, 'a', 'notify', 'success');
`);
// Deleting the user cascades all the way down.
db.sqlite.prepare("DELETE FROM users WHERE id = 'alice'").run();
expect(count(db, "p_automations_automations")).toBe(0);
expect(count(db, "p_automations_run_steps")).toBe(0);
});
});
@@ -1,44 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const automationFetch = vi.fn();
vi.mock("../../src/backend/http.js", () => ({
automationFetch: (...args: unknown[]) => automationFetch(...args),
}));
const { sendAutomationNotification } =
await import("../../src/backend/notify.js");
beforeEach(() => {
automationFetch.mockReset();
automationFetch.mockResolvedValue({ ok: true });
});
describe("sendAutomationNotification", () => {
it("keeps alert-compatible host and rule fields in webhook payloads", async () => {
await sendAutomationNotification(
{ id: 1, type: "webhook", config: '{"url":"https://example.com"}' },
{
title: "CPU warning",
body: "cpu.percent is at 97",
severity: "warning",
context: {
host: { id: 11, name: "Proxmox Node" },
trigger: { value: 97, threshold: 90 },
run: { automationId: 42 },
},
},
);
const options = automationFetch.mock.calls[0][1] as RequestInit;
expect(JSON.parse(options.body as string)).toMatchObject({
hostName: "Proxmox Node",
hostId: 11,
ruleName: "CPU warning",
ruleId: 42,
value: 97,
threshold: 90,
message: "cpu.percent is at 97",
});
});
});
+191 -473
View File
@@ -1,513 +1,231 @@
import crypto from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, Response, Router } from "express";
import type { AutomationDefinition } from "../../../../src/types/automations.js";
import { afterEach, describe, expect, it } from "vitest";
import { definition, startServer, type TestServer } from "./helpers";
/**
* Route-level behaviour: validation, ownership and webhook token handling. The repository and engine are mocked; what matters here is what
* the HTTP layer accepts, rejects and hands back.
*/
let server: TestServer | null = null;
const state = vi.hoisted(() => ({
currentUserId: "user-1",
rows: [] as Array<Record<string, unknown>>,
nextId: 1,
}));
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
vi.mock("../../../../src/backend/utils/logger.js", () => ({
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
const repository = vi.hoisted(() => ({
list: vi.fn(),
findForUser: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
listAllEnabled: vi.fn(),
listRuns: vi.fn(),
findRunForUser: vi.fn(),
listRunSteps: vi.fn(),
upsertSchedule: vi.fn(),
deleteSchedule: vi.fn(),
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
const run = vi.hoisted(() => vi.fn());
vi.mock("../../src/backend/engine.js", () => ({
AutomationEngine: { getInstance: () => ({ run }) },
}));
vi.mock(
"../../../../src/backend/database/routes/automation-dispatch.js",
() => ({
registerAutomationsRouter: vi.fn(),
unregisterAutomationsRouter: vi.fn(),
}),
);
vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
requirePermission:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../../src/backend/utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => undefined),
getAuditUsername: vi.fn(async () => "alice"),
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const { router } = await import("../../src/backend/routes.js");
/** Runs the whole middleware chain for the route, not just its handler. */
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
query?: Record<string, unknown>;
} = {},
) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (...args: unknown[]) => 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}`);
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
query: overrides.query ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
for (const entry of layer.route.stack) {
let advanced = false;
await entry.handle(req, res, () => {
advanced = true;
});
if (!advanced) break;
}
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
function definition(
overrides: Partial<AutomationDefinition> = {},
): AutomationDefinition {
return {
version: 1,
trigger: {
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
},
steps: [{ id: "a", type: "notify", channelIds: [1] }],
...overrides,
} as AutomationDefinition;
}
beforeEach(() => {
state.currentUserId = "user-1";
state.rows = [];
state.nextId = 1;
vi.clearAllMocks();
repository.list.mockImplementation(async (userId: string) =>
state.rows.filter((row) => row.user_id === userId),
);
repository.findForUser.mockImplementation(
async (id: number, userId: string) =>
state.rows.find((row) => row.id === id && row.user_id === userId) ?? null,
);
repository.create.mockImplementation(
async (input: Record<string, unknown>) => {
const row = {
id: state.nextId++,
user_id: input.userId,
name: input.name,
definition: input.definition,
enabled: input.enabled === false ? 0 : 1,
channels: input.channels ?? [],
};
state.rows.push(row);
return row;
},
);
repository.delete.mockImplementation(async (id: number, userId: string) => {
const index = state.rows.findIndex(
(row) => row.id === id && row.user_id === userId,
);
if (index === -1) return false;
state.rows.splice(index, 1);
return true;
});
repository.listAllEnabled.mockImplementation(async () =>
state.rows.map((row) => ({
id: row.id,
userId: row.user_id,
definition: row.definition,
enabled: true,
})),
);
repository.upsertSchedule.mockResolvedValue(undefined);
repository.deleteSchedule.mockResolvedValue(undefined);
run.mockResolvedValue({ runId: 1, status: "success" });
afterEach(async () => {
await server?.close();
server = null;
});
describe("POST /", () => {
it("creates an automation from a valid definition", async () => {
const res = await invoke("post", "/", {
body: { name: "Disk watch", definition: definition() },
});
const schedule = definition({ kind: "schedule", intervalSeconds: 300 });
expect(res.statusCode).toBe(201);
expect(res.jsonBody?.name).toBe("Disk watch");
describe("automation routes", () => {
it("creates, lists, updates and deletes an automation", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: { name: " Nightly ", definition: schedule, channels: [1, 99] },
});
expect(created.status).toBe(201);
expect(created.body).toMatchObject({
name: "Nightly",
enabled: 1,
// 99 is not one of alice's channels, so it is not linked.
channels: [1],
missingPlugins: [],
});
const id = created.body.id;
// A schedule trigger registers its next due time.
const scheduleRow = server.db.sqlite
.prepare(
"SELECT next_due_at FROM p_automations_schedules WHERE automation_id = ?",
)
.get(id) as { next_due_at: string | null };
expect(scheduleRow.next_due_at).toBeTruthy();
const listed = await server.request("GET", "/");
expect(listed.body.map((row: { id: number }) => row.id)).toEqual([id]);
expect(listed.body[0].definition.trigger.kind).toBe("schedule");
const updated = await server.request("PUT", `/${id}`, {
body: { enabled: false, name: "Nightly job" },
});
expect(updated.status).toBe(200);
expect(updated.body).toMatchObject({ enabled: 0, name: "Nightly job" });
expect((await server.request("DELETE", `/${id}`)).status).toBe(200);
expect((await server.request("GET", `/${id}`)).status).toBe(404);
// The schedule goes with it.
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_automations_schedules")
.get(),
).toEqual({ n: 0 });
});
it("requires a name", async () => {
const res = await invoke("post", "/", {
body: { definition: definition() },
it("never shows one user's automations to another", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: { name: "Mine", definition: schedule },
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/name/i);
expect((await server.request("GET", "/", { user: "bob" })).body).toEqual(
[],
);
expect(
(await server.request("GET", `/${created.body.id}`, { user: "bob" }))
.status,
).toBe(404);
expect(
(await server.request("DELETE", `/${created.body.id}`, { user: "bob" }))
.status,
).toBe(404);
});
it("rejects an unknown trigger kind", async () => {
const res = await invoke("post", "/", {
it("rejects an invalid definition", async () => {
server = await startServer();
const response = await server.request("POST", "/", {
body: {
name: "Bad",
definition: { version: 1, trigger: { kind: "nope" }, steps: [] },
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/trigger/i);
});
it("rejects an unknown operator", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
trigger: {
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "cpu.percent" },
operator: "~=",
value: 1,
cooldownMinutes: 5,
},
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/operator/i);
});
it("rejects an unknown step type", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
steps: [{ id: "a", type: "launch_missiles" }],
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/step type/i);
});
it("rejects duplicate step ids, including inside a branch", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
steps: [
{ id: "dup", type: "wait", seconds: 1 },
{
id: "branch",
type: "if",
condition: { left: "1", operator: "==", right: "1" },
then: [{ id: "dup", type: "wait", seconds: 1 }],
},
],
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/duplicate/i);
});
it("rejects an invalid cron expression", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
trigger: { kind: "schedule", cron: "not a cron" },
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/cron/i);
});
it("rejects an interval under a minute", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
definition: {
trigger: { kind: "schedule", intervalSeconds: 5 },
} as Partial<AutomationDefinition>),
steps: [],
},
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/60 seconds/i);
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/60 seconds/);
});
it("registers a schedule for a schedule trigger", async () => {
await invoke("post", "/", {
it("marks an automation that needs a plugin that is off", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: {
name: "Nightly",
definition: definition({
trigger: { kind: "schedule", cron: "0 2 * * *" },
} as Partial<AutomationDefinition>),
name: "Restart app",
definition: definition({ kind: "schedule", intervalSeconds: 300 }, [
{
id: "d",
type: "docker",
action: "restart",
container: "app",
hostSelector: { kind: "host", hostId: 1 },
},
]),
},
});
expect(repository.upsertSchedule).toHaveBeenCalled();
expect(created.body.missingPlugins).toEqual(["docker"]);
});
it("returns a webhook token once and stores only its hash", async () => {
const res = await invoke("post", "/", {
body: {
name: "Hooked",
definition: definition({
trigger: { kind: "webhook", tokenHash: "" },
} as Partial<AutomationDefinition>),
it("offers editor options and the running providers", async () => {
server = await startServer({
services: {
"fleets.access": { list: async () => [{ id: 3, name: "web" }] },
},
});
const response = await server.request("GET", "/editor-options");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
hosts: [
{ id: 1, name: "host-1" },
{ id: 2, name: "host-2" },
],
snippets: [{ id: 5, name: "Restart nginx" }],
fleets: [{ id: 3, name: "web" }],
channels: [{ id: 1, name: "ops" }],
});
expect(response.body.providers).toMatchObject({
snippets: true,
fleets: true,
docker: false,
"host-metrics": false,
"wake-on-lan": false,
});
});
expect(res.statusCode).toBe(201);
const token = res.jsonBody?.webhookToken as string;
expect(token).toMatch(/^[a-f0-9]{64}$/);
it("returns a webhook token once and runs the automation from it", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: { name: "Hook", definition: definition({ kind: "webhook" }) },
});
const token = created.body.webhookToken as string;
expect(token).toHaveLength(64);
expect(created.body.definition.trigger.tokenHash).toBe("");
const stored = JSON.parse(state.rows[0].definition as string);
expect(stored.trigger.tokenHash).not.toBe(token);
expect(stored.trigger.tokenHash).toBe(
const stored = server.db.sqlite
.prepare("SELECT definition FROM p_automations_automations WHERE id = ?")
.get(created.body.id) as { definition: string };
expect(JSON.parse(stored.definition).trigger.tokenHash).toBe(
crypto.createHash("sha256").update(token).digest("hex"),
);
// The hash is never echoed back to the client.
const body = res.jsonBody as { definition: AutomationDefinition };
expect((body.definition.trigger as { tokenHash: string }).tokenHash).toBe(
"",
);
});
it("stores the automation against the caller, not a supplied user id", async () => {
// Authorization here is ownership, the same as the other data routes:
// every read and write is scoped to req.userId, so a client cannot create
// an automation that belongs to somebody else.
await invoke("post", "/", {
body: {
name: "Mine",
definition: definition(),
userId: "user-2",
user_id: "user-2",
},
});
expect(state.rows).toHaveLength(1);
expect(state.rows[0].user_id).toBe("user-1");
});
});
describe("GET /", () => {
it("only returns the caller's automations", async () => {
state.rows.push({
id: 1,
user_id: "user-2",
name: "Theirs",
definition: JSON.stringify(definition()),
channels: [],
});
const res = await invoke("get", "/");
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toHaveLength(0);
});
});
describe("DELETE /:id", () => {
it("will not delete another user's automation", async () => {
state.rows.push({
id: 1,
user_id: "user-2",
name: "Theirs",
definition: JSON.stringify(definition()),
channels: [],
});
const res = await invoke("delete", "/:id", { params: { id: "1" } });
expect(res.statusCode).toBe(404);
expect(state.rows).toHaveLength(1);
});
});
describe("POST /:id/run", () => {
function seedOwned() {
state.rows.push({
id: 1,
user_id: "user-1",
name: "Mine",
definition: JSON.stringify(definition()),
channels: [],
});
}
it("runs an owned automation", async () => {
seedOwned();
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
expect(res.statusCode).toBe(200);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ automationId: 1, triggerType: "manual" }),
);
});
it("passes the dry-run flag through", async () => {
seedOwned();
await invoke("post", "/:id/run", {
params: { id: "1" },
body: { dryRun: true },
});
expect(run).toHaveBeenCalledWith(expect.objectContaining({ dryRun: true }));
});
it("refuses to run someone else's automation", async () => {
state.rows.push({
id: 1,
user_id: "user-2",
name: "Theirs",
definition: JSON.stringify(definition()),
channels: [],
});
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
expect(res.statusCode).toBe(404);
expect(run).not.toHaveBeenCalled();
});
});
describe("POST /webhook/:token", () => {
function seedWebhook(token: string) {
state.rows.push({
id: 1,
user_id: "user-1",
name: "Hooked",
definition: JSON.stringify(
definition({
trigger: {
kind: "webhook",
tokenHash: crypto.createHash("sha256").update(token).digest("hex"),
},
} as Partial<AutomationDefinition>),
),
channels: [],
});
}
it("runs the automation matching the token", async () => {
const token = "a".repeat(64);
seedWebhook(token);
const res = await invoke("post", "/webhook/:token", {
params: { token },
const fired = await server.request("POST", `/webhook/${token}`, {
user: null,
body: { hello: "world" },
});
expect(fired.status).toBe(202);
expect(fired.body.status).toBe("success");
expect(res.statusCode).toBe(202);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ automationId: 1, triggerType: "webhook" }),
);
const wrong = await server.request("POST", `/webhook/${"0".repeat(64)}`, {
user: null,
body: {},
});
expect(wrong.status).toBe(404);
});
it("rejects a token that does not match", async () => {
seedWebhook("a".repeat(64));
const res = await invoke("post", "/webhook/:token", {
params: { token: "b".repeat(64) },
it("runs now, records the steps and lists the run", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: { name: "Now", definition: schedule },
});
expect(res.statusCode).toBe(404);
expect(run).not.toHaveBeenCalled();
});
it("rejects a token too short to be real", async () => {
seedWebhook("a".repeat(64));
const res = await invoke("post", "/webhook/:token", {
params: { token: "short" },
const outcome = await server.request("POST", `/${created.body.id}/run`, {
body: { dryRun: true },
});
expect(outcome.body.status).toBe("success");
expect(res.statusCode).toBe(404);
expect(run).not.toHaveBeenCalled();
const runs = await server.request("GET", "/runs/history");
expect(runs.body[0]).toMatchObject({
automation_id: created.body.id,
automation_name: "Now",
status: "success",
dry_run: 1,
trigger_type: "manual",
});
const steps = await server.request("GET", `/runs/${runs.body[0].id}/steps`);
expect(steps.body).toMatchObject([
{ step_type: "set_var", status: "success" },
]);
expect(
(
await server.request("GET", `/runs/${runs.body[0].id}/steps`, {
user: "bob",
})
).status,
).toBe(404);
});
});
describe("permissions", () => {
const routes: Array<[string, string, string]> = [
["GET", "/", "view"],
["GET", "/editor-options", "view"],
["GET", "/runs/history", "view"],
["GET", "/runs/1/steps", "view"],
["GET", "/1", "view"],
["POST", "/", "create"],
["PUT", "/1", "edit"],
["DELETE", "/1", "delete"],
["POST", "/1/run", "run"],
];
it.each(routes)(
"%s %s answers 403 without automations.%s",
async (method, path) => {
server = await startServer({ permissions: [] });
const response = await server.request(method, path, { body: {} });
expect(response.status).toBe(403);
},
);
it("serves the webhook without a session", async () => {
server = await startServer({ permissions: [] });
const response = await server.request(
"POST",
`/webhook/${"a".repeat(64)}`,
{ user: null, body: {} },
);
expect(response.status).toBe(404);
});
});
@@ -1,8 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
AutomationDefinition,
Trigger,
} from "../../../../src/types/automations.js";
import type { AutomationDefinition, Trigger } from "../../src/types.js";
import { createTriggers } from "../../src/backend/triggers.js";
/**
* Trigger matching, dwell windows and cooldowns. The repository and the engine
@@ -42,16 +40,14 @@ const repository = {
}),
};
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
vi.mock("../../src/backend/engine.js", () => ({
AutomationEngine: { getInstance: () => ({ run }) },
}));
const triggers = await import("../../src/backend/triggers.js");
const log = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
const triggers = createTriggers(repository as never, { run }, log);
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
@@ -415,7 +411,7 @@ describe("onDockerEvent", () => {
});
});
describe("listAutomationWatchedHosts", () => {
describe("watchedHosts", () => {
it("collects hosts from metric triggers so they can be polled headlessly", async () => {
addAutomation({
kind: "metric_threshold",
@@ -426,7 +422,7 @@ describe("listAutomationWatchedHosts", () => {
cooldownMinutes: 15,
});
const watched = await triggers.listAutomationWatchedHosts();
const watched = await triggers.watchedHosts("metric_threshold");
expect([...watched.keys()].sort()).toEqual([3, 4]);
});
@@ -439,6 +435,6 @@ describe("listAutomationWatchedHosts", () => {
cooldownMinutes: 0,
});
expect((await triggers.listAutomationWatchedHosts()).size).toBe(0);
expect((await triggers.watchedHosts("metric_threshold")).size).toBe(0);
});
});
@@ -1,90 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFakeContext } from "@termix/plugin-sdk/testing";
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentNotificationChannelRepository: vi.fn(),
}));
const { setTunnelServices } = await import("../../src/backend/tunnels.js");
const { executeStep } = await import("../../src/backend/actions/index.js");
afterEach(() => {
setTunnelServices(null);
});
function context(dryRun = false) {
return {
userId: "user-1",
automationId: 1,
runId: 1,
dryRun,
template: {},
} as never;
}
const step = (action: "connect" | "disconnect") =>
({
id: "s1",
type: "tunnel",
action,
tunnelName: "7::0::web::8080::db::5432",
}) as never;
describe("the tunnel step", () => {
it("starts and stops the tunnel through tunnels.access as the automation's owner", async () => {
const fake = createFakeContext({ pluginId: "automations" });
const tunnels = {
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
};
fake.ctx.services.provide("tunnels.access", tunnels);
const get = vi.spyOn(fake.ctx.services, "get");
setTunnelServices(fake.ctx.services);
const connected = await executeStep(step("connect"), context());
expect(connected.success).toBe(true);
expect(tunnels.start).toHaveBeenCalledWith("7::0::web::8080::db::5432");
expect(get).toHaveBeenCalledWith("tunnels.access", { userId: "user-1" });
const stopped = await executeStep(step("disconnect"), context());
expect(stopped.success).toBe(true);
expect(tunnels.stop).toHaveBeenCalledWith("7::0::web::8080::db::5432");
});
it("reports the tunnel plugin's own error", async () => {
const fake = createFakeContext({ pluginId: "automations" });
fake.ctx.services.provide("tunnels.access", {
start: vi.fn(async () => {
throw new Error('Tunnel "x" is not configured');
}),
stop: vi.fn(),
});
setTunnelServices(fake.ctx.services);
const result = await executeStep(step("connect"), context());
expect(result.success).toBe(false);
expect(result.error ?? result.output).toMatch(/not configured/);
});
it("fails the step cleanly while the tunnels plugin is off", async () => {
// No provider: the optional dependency is missing.
setTunnelServices(
createFakeContext({ pluginId: "automations" }).ctx.services,
);
const withoutProvider = await executeStep(step("connect"), context());
expect(withoutProvider.success).toBe(false);
expect(withoutProvider.error ?? withoutProvider.output).toMatch(
/tunnels plugin is not available/,
);
setTunnelServices(null);
const inactive = await executeStep(step("disconnect"), context());
expect(inactive.success).toBe(false);
});
it("only describes what it would do on a dry run", async () => {
const result = await executeStep(step("connect"), context(true));
expect(result.success).toBe(true);
expect(result.output).toMatch(/Would connect tunnel/);
});
});
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { defaultTrigger } from "../../src/frontend/automations/TriggerCard";
import { emptyDraft } from "../../src/frontend/automations/AutomationEditor";
import { newStepId } from "../../src/frontend/automations/editor-types";
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
import { AUTOMATION_DEFINITION_VERSION } from "../../src/types";
describe("emptyDraft", () => {
it("starts on a metric threshold with no steps", () => {
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { waitFor } from "@testing-library/react";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
const rows = [
{
id: 1,
name: "Restart app",
description: null,
enabled: 1,
definition: {
version: 1,
trigger: { kind: "schedule", intervalSeconds: 300 },
steps: [],
},
concurrency_policy: "skip",
last_run_at: null,
last_run_status: null,
channels: [],
missingPlugins: ["docker"],
},
];
function stubApi(): PluginApiClient {
return {
get: vi.fn(async (path: string) => {
if (path === "/editor-options") {
return {
data: {
hosts: [],
snippets: [],
fleets: [],
channels: [],
providers: { snippets: true },
},
};
}
return { data: path === "/" ? rows : [] };
}),
post: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: {} })),
} as unknown as PluginApiClient;
}
describe("automations panel", () => {
it("lists automations from the plugin API and marks one that needs a plugin", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
const element = rendered.renderPanel("automations") as HTMLElement;
await waitFor(() => {
expect(element.textContent).toContain("Restart app");
expect(element.textContent).toContain("Needs docker");
});
});
});
+1 -19
View File
@@ -10,12 +10,6 @@
"plugins/ai/src/frontend/terminal/TerminalProposalCard.tsx",
"plugins/ai/src/frontend/use-ai-stream.ts",
"plugins/ai/src/frontend/useMentions.ts",
"plugins/automations/src/frontend/AutomationsPanel.tsx",
"plugins/automations/src/frontend/automations-api.ts",
"plugins/automations/src/frontend/automations/AutomationEditor.tsx",
"plugins/automations/src/frontend/automations/HostSelectorField.tsx",
"plugins/automations/src/frontend/automations/StepBlock.tsx",
"plugins/automations/src/frontend/automations/TriggerCard.tsx",
"plugins/file-manager/src/frontend/FileManager.tsx",
"plugins/file-manager/src/frontend/FileManagerContextMenu.tsx",
"plugins/file-manager/src/frontend/FileManagerDialogs.tsx",
@@ -81,22 +75,10 @@
"plugins/ai/src/backend/routes.ts",
"plugins/ai/src/backend/tools/executor.ts",
"plugins/ai/src/backend/tools/read-tools.ts",
"plugins/automations/src/backend/actions/host-targets.ts",
"plugins/automations/src/backend/actions/index.ts",
"plugins/automations/src/backend/actions/types.ts",
"plugins/automations/src/backend/conditions.ts",
"plugins/automations/src/backend/docker-watcher.ts",
"plugins/automations/src/backend/engine.ts",
"plugins/automations/src/backend/headless-viewer.ts",
"plugins/automations/src/backend/http.ts",
"plugins/automations/src/backend/notify.ts",
"plugins/automations/src/backend/routes.ts",
"plugins/automations/src/backend/scheduler.ts",
"plugins/automations/src/backend/triggers.ts",
"plugins/file-manager/src/backend/index.ts",
"plugins/file-manager/src/backend/operation-routes.ts",
"plugins/proxmox/src/backend/routes.ts",
"plugins/tailscale/src/backend/routes.ts"
],
"plugin-to-plugin": ["plugins/ai/src/backend/tools/executor.ts"]
"plugin-to-plugin": []
}
+3 -3
View File
@@ -1,6 +1,6 @@
/**
* The one place a login becomes a session: JWT, cookie, response body,
* audit line and the automation event. Every login method ends here, so
* audit line and the user_login internal event. Every login method ends here, so
* they all behave the same way.
*/
@@ -10,7 +10,7 @@ import { authLogger } from "../utils/logger.js";
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { notifyAutomationInternalEvent } from "../hosts/automation-events.js";
import { emitInternalEvent } from "../hosts/internal-events.js";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import type { UserRecord } from "../database/repositories/user-repository.js";
@@ -112,7 +112,7 @@ export async function issueSession(
userAgent,
success: true,
});
notifyAutomationInternalEvent("user_login", user.id, undefined, {
emitInternalEvent("user_login", user.id, undefined, {
username: user.username,
ipAddress,
});
-152
View File
@@ -2030,158 +2030,6 @@ const migrateSchema = () => {
}
// --- alerts end ---
// --- automations begin ---
try {
sqlite.prepare("SELECT id FROM automations LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
definition TEXT NOT NULL,
definition_version INTEGER NOT NULL DEFAULT 1,
concurrency_policy TEXT NOT NULL DEFAULT 'skip',
max_run_seconds INTEGER NOT NULL DEFAULT 300,
dry_run INTEGER NOT NULL DEFAULT 0,
last_run_at TEXT,
last_run_status TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automations table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_trigger_state LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_trigger_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
state_key TEXT NOT NULL,
breach_started_at TEXT,
last_fired_at TEXT,
last_value REAL,
last_observed_state TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_trigger_state table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_schedules LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
cron TEXT,
interval_seconds INTEGER,
timezone TEXT,
next_due_at TEXT,
last_tick_at TEXT
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_schedules table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_runs LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL,
trigger_context TEXT,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
duration_ms INTEGER,
error TEXT,
dry_run INTEGER NOT NULL DEFAULT 0,
parent_run_id INTEGER
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_runs table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_run_steps LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_run_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE,
step_index INTEGER NOT NULL,
step_id TEXT NOT NULL,
step_type TEXT NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
output TEXT,
error TEXT,
truncated INTEGER NOT NULL DEFAULT 0
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_run_steps table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_channels LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_channels table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- automations end ---
// --- homepage begin ---
try {
@@ -125,51 +125,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
},
// Automations.
{
name: "idx_automations_user",
table: "automations",
columns: "user_id, enabled",
},
{
name: "idx_automation_trigger_state_key",
table: "automation_trigger_state",
columns: "automation_id, state_key",
unique: true,
},
{
name: "idx_automation_schedules_automation",
table: "automation_schedules",
columns: "automation_id",
unique: true,
},
{
name: "idx_automation_schedules_due",
table: "automation_schedules",
columns: "next_due_at",
},
{
name: "idx_automation_runs_automation",
table: "automation_runs",
columns: "automation_id, started_at",
},
{
name: "idx_automation_runs_user",
table: "automation_runs",
columns: "user_id, started_at",
},
{
name: "idx_automation_run_steps_run",
table: "automation_run_steps",
columns: "run_id, step_index",
},
{
name: "idx_automation_channels_pair",
table: "automation_channels",
columns: "automation_id, channel_id",
unique: true,
},
// AI assistant.
{
name: "idx_ai_providers_user_label",
-159
View File
@@ -13,7 +13,6 @@ import {
varchar,
int,
boolean,
double,
index,
uniqueIndex,
foreignKey,
@@ -1032,164 +1031,6 @@ export const notificationChannels = mysqlTable("notification_channels", {
});
// --- alerts end ---
// --- automations begin ---
export const automations = mysqlTable(
"automations",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
enabled: boolean("enabled").notNull().default(true),
// The whole trigger + steps graph, shaped by AutomationDefinition. Read and
// written as a unit, never queried by its inner structure.
definition: text("definition").notNull(),
definitionVersion: int("definition_version").notNull().default(1),
concurrencyPolicy: text("concurrency_policy").notNull().default("skip"),
maxRunSeconds: int("max_run_seconds").notNull().default(300),
dryRun: boolean("dry_run").notNull().default(false),
lastRunAt: text("last_run_at"),
lastRunStatus: text("last_run_status"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// The scheduler sweeps enabled automations for one user at a time.
(table) => [index("idx_automations_user").on(table.userId, table.enabled)],
);
/**
* Durable per-target trigger state. state_key scopes a trigger to what it is
* actually watching ("<hostId>", "<hostId>:/data", "<hostId>:<container>"), so
* a sustained-breach window can track one filesystem rather than a whole host.
* Living in the database rather than memory means cooldowns and dwell windows
* survive a restart.
*/
export const automationTriggerState = mysqlTable(
"automation_trigger_state",
{
id: int("id").autoincrement().primaryKey(),
automationId: int("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
stateKey: varchar("state_key", { length: 255 }).notNull(),
breachStartedAt: text("breach_started_at"),
lastFiredAt: text("last_fired_at"),
lastValue: double("last_value"),
lastObservedState: text("last_observed_state"),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_automation_trigger_state_key").on(
table.automationId,
table.stateKey,
),
],
);
export const automationSchedules = mysqlTable(
"automation_schedules",
{
id: int("id").autoincrement().primaryKey(),
automationId: int("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
cron: text("cron"),
intervalSeconds: int("interval_seconds"),
timezone: text("timezone"),
nextDueAt: varchar("next_due_at", { length: 255 }),
lastTickAt: text("last_tick_at"),
},
(table) => [
uniqueIndex("idx_automation_schedules_automation").on(table.automationId),
index("idx_automation_schedules_due").on(table.nextDueAt),
],
);
export const automationRuns = mysqlTable(
"automation_runs",
{
id: int("id").autoincrement().primaryKey(),
automationId: int("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
triggerType: text("trigger_type").notNull(),
triggerContext: text("trigger_context"),
status: varchar("status", { length: 255 }).notNull(),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
finishedAt: text("finished_at"),
durationMs: int("duration_ms"),
error: text("error"),
dryRun: boolean("dry_run").notNull().default(false),
// Set when one automation invoked another, so a chain can be traced.
parentRunId: int("parent_run_id"),
},
(table) => [
index("idx_automation_runs_automation").on(
table.automationId,
table.startedAt,
),
index("idx_automation_runs_user").on(table.userId, table.startedAt),
],
);
export const automationRunSteps = mysqlTable(
"automation_run_steps",
{
id: int("id").autoincrement().primaryKey(),
runId: int("run_id")
.notNull()
.references(() => automationRuns.id, { onDelete: "cascade" }),
stepIndex: int("step_index").notNull(),
stepId: text("step_id").notNull(),
stepType: text("step_type").notNull(),
status: varchar("status", { length: 255 }).notNull(),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
finishedAt: text("finished_at"),
output: text("output"),
error: text("error"),
truncated: boolean("truncated")
.notNull()
.default(false),
},
(table) => [
index("idx_automation_run_steps_run").on(table.runId, table.stepIndex),
],
);
export const automationChannels = mysqlTable(
"automation_channels",
{
id: int("id").autoincrement().primaryKey(),
automationId: int("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
channelId: int("channel_id")
.notNull()
.references(() => notificationChannels.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("idx_automation_channels_pair").on(
table.automationId,
table.channelId,
),
],
);
// --- automations end ---
// --- homepage begin ---
export const homepageItems = mysqlTable(
-159
View File
@@ -14,7 +14,6 @@ import {
integer,
serial,
boolean,
doublePrecision,
index,
uniqueIndex,
foreignKey,
@@ -1033,164 +1032,6 @@ export const notificationChannels = pgTable("notification_channels", {
});
// --- alerts end ---
// --- automations begin ---
export const automations = pgTable(
"automations",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
enabled: boolean("enabled").notNull().default(true),
// The whole trigger + steps graph, shaped by AutomationDefinition. Read and
// written as a unit, never queried by its inner structure.
definition: text("definition").notNull(),
definitionVersion: integer("definition_version").notNull().default(1),
concurrencyPolicy: text("concurrency_policy").notNull().default("skip"),
maxRunSeconds: integer("max_run_seconds").notNull().default(300),
dryRun: boolean("dry_run").notNull().default(false),
lastRunAt: text("last_run_at"),
lastRunStatus: text("last_run_status"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// The scheduler sweeps enabled automations for one user at a time.
(table) => [index("idx_automations_user").on(table.userId, table.enabled)],
);
/**
* Durable per-target trigger state. state_key scopes a trigger to what it is
* actually watching ("<hostId>", "<hostId>:/data", "<hostId>:<container>"), so
* a sustained-breach window can track one filesystem rather than a whole host.
* Living in the database rather than memory means cooldowns and dwell windows
* survive a restart.
*/
export const automationTriggerState = pgTable(
"automation_trigger_state",
{
id: serial("id").primaryKey(),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
stateKey: varchar("state_key", { length: 255 }).notNull(),
breachStartedAt: text("breach_started_at"),
lastFiredAt: text("last_fired_at"),
lastValue: doublePrecision("last_value"),
lastObservedState: text("last_observed_state"),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_automation_trigger_state_key").on(
table.automationId,
table.stateKey,
),
],
);
export const automationSchedules = pgTable(
"automation_schedules",
{
id: serial("id").primaryKey(),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
cron: text("cron"),
intervalSeconds: integer("interval_seconds"),
timezone: text("timezone"),
nextDueAt: varchar("next_due_at", { length: 255 }),
lastTickAt: text("last_tick_at"),
},
(table) => [
uniqueIndex("idx_automation_schedules_automation").on(table.automationId),
index("idx_automation_schedules_due").on(table.nextDueAt),
],
);
export const automationRuns = pgTable(
"automation_runs",
{
id: serial("id").primaryKey(),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
triggerType: text("trigger_type").notNull(),
triggerContext: text("trigger_context"),
status: varchar("status", { length: 255 }).notNull(),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
finishedAt: text("finished_at"),
durationMs: integer("duration_ms"),
error: text("error"),
dryRun: boolean("dry_run").notNull().default(false),
// Set when one automation invoked another, so a chain can be traced.
parentRunId: integer("parent_run_id"),
},
(table) => [
index("idx_automation_runs_automation").on(
table.automationId,
table.startedAt,
),
index("idx_automation_runs_user").on(table.userId, table.startedAt),
],
);
export const automationRunSteps = pgTable(
"automation_run_steps",
{
id: serial("id").primaryKey(),
runId: integer("run_id")
.notNull()
.references(() => automationRuns.id, { onDelete: "cascade" }),
stepIndex: integer("step_index").notNull(),
stepId: text("step_id").notNull(),
stepType: text("step_type").notNull(),
status: varchar("status", { length: 255 }).notNull(),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
finishedAt: text("finished_at"),
output: text("output"),
error: text("error"),
truncated: boolean("truncated")
.notNull()
.default(false),
},
(table) => [
index("idx_automation_run_steps_run").on(table.runId, table.stepIndex),
],
);
export const automationChannels = pgTable(
"automation_channels",
{
id: serial("id").primaryKey(),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
channelId: integer("channel_id")
.notNull()
.references(() => notificationChannels.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("idx_automation_channels_pair").on(
table.automationId,
table.channelId,
),
],
);
// --- automations end ---
// --- homepage begin ---
export const homepageItems = pgTable(
-159
View File
@@ -2,7 +2,6 @@ import {
sqliteTable,
text,
integer,
real,
index,
uniqueIndex,
foreignKey,
@@ -1029,164 +1028,6 @@ export const notificationChannels = sqliteTable("notification_channels", {
});
// --- alerts end ---
// --- automations begin ---
export const automations = sqliteTable(
"automations",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
// The whole trigger + steps graph, shaped by AutomationDefinition. Read and
// written as a unit, never queried by its inner structure.
definition: text("definition").notNull(),
definitionVersion: integer("definition_version").notNull().default(1),
concurrencyPolicy: text("concurrency_policy").notNull().default("skip"),
maxRunSeconds: integer("max_run_seconds").notNull().default(300),
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
lastRunAt: text("last_run_at"),
lastRunStatus: text("last_run_status"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// The scheduler sweeps enabled automations for one user at a time.
(table) => [index("idx_automations_user").on(table.userId, table.enabled)],
);
/**
* Durable per-target trigger state. state_key scopes a trigger to what it is
* actually watching ("<hostId>", "<hostId>:/data", "<hostId>:<container>"), so
* a sustained-breach window can track one filesystem rather than a whole host.
* Living in the database rather than memory means cooldowns and dwell windows
* survive a restart.
*/
export const automationTriggerState = sqliteTable(
"automation_trigger_state",
{
id: integer("id").primaryKey({ autoIncrement: true }),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
stateKey: text("state_key").notNull(),
breachStartedAt: text("breach_started_at"),
lastFiredAt: text("last_fired_at"),
lastValue: real("last_value"),
lastObservedState: text("last_observed_state"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_automation_trigger_state_key").on(
table.automationId,
table.stateKey,
),
],
);
export const automationSchedules = sqliteTable(
"automation_schedules",
{
id: integer("id").primaryKey({ autoIncrement: true }),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
cron: text("cron"),
intervalSeconds: integer("interval_seconds"),
timezone: text("timezone"),
nextDueAt: text("next_due_at"),
lastTickAt: text("last_tick_at"),
},
(table) => [
uniqueIndex("idx_automation_schedules_automation").on(table.automationId),
index("idx_automation_schedules_due").on(table.nextDueAt),
],
);
export const automationRuns = sqliteTable(
"automation_runs",
{
id: integer("id").primaryKey({ autoIncrement: true }),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
triggerType: text("trigger_type").notNull(),
triggerContext: text("trigger_context"),
status: text("status").notNull(),
startedAt: text("started_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
finishedAt: text("finished_at"),
durationMs: integer("duration_ms"),
error: text("error"),
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
// Set when one automation invoked another, so a chain can be traced.
parentRunId: integer("parent_run_id"),
},
(table) => [
index("idx_automation_runs_automation").on(
table.automationId,
table.startedAt,
),
index("idx_automation_runs_user").on(table.userId, table.startedAt),
],
);
export const automationRunSteps = sqliteTable(
"automation_run_steps",
{
id: integer("id").primaryKey({ autoIncrement: true }),
runId: integer("run_id")
.notNull()
.references(() => automationRuns.id, { onDelete: "cascade" }),
stepIndex: integer("step_index").notNull(),
stepId: text("step_id").notNull(),
stepType: text("step_type").notNull(),
status: text("status").notNull(),
startedAt: text("started_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
finishedAt: text("finished_at"),
output: text("output"),
error: text("error"),
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
},
(table) => [
index("idx_automation_run_steps_run").on(table.runId, table.stepIndex),
],
);
export const automationChannels = sqliteTable(
"automation_channels",
{
id: integer("id").primaryKey({ autoIncrement: true }),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
channelId: integer("channel_id")
.notNull()
.references(() => notificationChannels.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("idx_automation_channels_pair").on(
table.automationId,
table.channelId,
),
],
);
// --- automations end ---
// --- homepage begin ---
export const homepageItems = sqliteTable(
@@ -1,784 +0,0 @@
import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm";
import {
automationChannels,
automationRunSteps,
automationRuns,
automationSchedules,
automationTriggerState,
automations,
notificationChannels,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
type AutomationRecord = typeof automations.$inferSelect;
type AutomationRunRecord = typeof automationRuns.$inferSelect;
type AutomationRunStepRecord = typeof automationRunSteps.$inferSelect;
type TriggerStateRecord = typeof automationTriggerState.$inferSelect;
type ScheduleRecord = typeof automationSchedules.$inferSelect;
export interface AutomationRow {
id: number;
user_id: string;
name: string;
description: string | null;
enabled: number;
definition: string;
definition_version: number;
concurrency_policy: string;
max_run_seconds: number;
dry_run: number;
last_run_at: string | null;
last_run_status: string | null;
created_at: string;
updated_at: string;
channels: number[];
}
export interface AutomationRunRow {
id: number;
automation_id: number;
user_id: string;
trigger_type: string;
trigger_context: string | null;
status: string;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
error: string | null;
dry_run: number;
parent_run_id: number | null;
automation_name?: string | null;
}
export interface AutomationRunStepRow {
id: number;
run_id: number;
step_index: number;
step_id: string;
step_type: string;
status: string;
started_at: string;
finished_at: string | null;
output: string | null;
error: string | null;
truncated: number;
}
/** The shape the engine loads; camelCase and already parsed where useful. */
export interface AutomationEngineRow {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
export interface TriggerStateRow {
automationId: number;
stateKey: string;
breachStartedAt: string | null;
lastFiredAt: string | null;
lastValue: number | null;
lastObservedState: string | null;
}
export interface DueScheduleRow {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}
export class AutomationRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async list(userId: string): Promise<AutomationRow[]> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(eq(automations.userId, userId))
.orderBy(asc(automations.name));
if (rows.length === 0) return [];
// One query for every automation's channels rather than one per row.
const links = await this.context.drizzle
.select({
automationId: automationChannels.automationId,
channelId: automationChannels.channelId,
})
.from(automationChannels)
.where(
inArray(
automationChannels.automationId,
rows.map((row) => row.id),
),
);
const byAutomation = new Map<number, number[]>();
for (const link of links) {
const list = byAutomation.get(link.automationId) ?? [];
list.push(link.channelId);
byAutomation.set(link.automationId, list);
}
return rows.map((row) => mapAutomationRow(row, byAutomation.get(row.id)));
}
async findForUser(id: number, userId: string): Promise<AutomationRow | null> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)))
.limit(1);
if (!rows[0]) return null;
return mapAutomationRow(rows[0], await this.listChannelIds(id));
}
async findById(id: number): Promise<AutomationEngineRow | null> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(eq(automations.id, id))
.limit(1);
return rows[0] ? mapEngineRow(rows[0]) : null;
}
async create(input: {
userId: string;
name: string;
description?: string | null;
enabled?: boolean;
definition: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
now?: string;
}): Promise<AutomationRow> {
const now = input.now ?? new Date().toISOString();
const [created] = await insertReturning(this.context, automations, {
userId: input.userId,
name: input.name,
description: input.description ?? null,
enabled: input.enabled ?? true,
definition: input.definition,
concurrencyPolicy: input.concurrencyPolicy ?? "skip",
maxRunSeconds: input.maxRunSeconds ?? 300,
dryRun: input.dryRun ?? false,
createdAt: now,
updatedAt: now,
});
const channels = await this.replaceChannels(
created.id,
input.userId,
input.channels ?? [],
);
await this.afterWrite();
return mapAutomationRow(created, channels);
}
async update(
id: number,
userId: string,
input: {
name?: string;
description?: string | null;
enabled?: boolean;
definition?: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
now?: string;
},
): Promise<AutomationRow | null> {
const { channels, now, ...fields } = input;
const values: Record<string, unknown> = { ...fields };
if (Object.keys(values).length > 0) {
values.updatedAt = now ?? new Date().toISOString();
const [updated] = await updateReturning(
this.context,
automations,
values,
and(eq(automations.id, id), eq(automations.userId, userId)),
);
if (!updated) return null;
} else {
const existing = await this.findForUser(id, userId);
if (!existing) return null;
}
if (channels) {
await this.replaceChannels(id, userId, channels);
}
await this.afterWrite();
return this.findForUser(id, userId);
}
async delete(id: number, userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
const deleted = rowsAffected(result) > 0;
if (deleted) await this.afterWrite();
return deleted;
}
async setEnabled(
id: number,
userId: string,
enabled: boolean,
): Promise<boolean> {
const result = await this.context.drizzle
.update(automations)
.set({ enabled, updatedAt: new Date().toISOString() })
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
const changed = rowsAffected(result) > 0;
if (changed) await this.afterWrite();
return changed;
}
/**
* Enabled automations owned by whoever owns the given host. Wildcard targets
* must never reach across users, which is the bug the alert engine shipped
* with.
*/
async listEnabledForUser(userId: string): Promise<AutomationEngineRow[]> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(
and(eq(automations.enabled, true), eq(automations.userId, userId)),
);
return rows.map(mapEngineRow);
}
async listAllEnabled(): Promise<AutomationEngineRow[]> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(eq(automations.enabled, true));
return rows.map(mapEngineRow);
}
// --- trigger state ---
async getTriggerState(
automationId: number,
stateKey: string,
): Promise<TriggerStateRow | null> {
const rows = await this.context.drizzle
.select()
.from(automationTriggerState)
.where(
and(
eq(automationTriggerState.automationId, automationId),
eq(automationTriggerState.stateKey, stateKey),
),
)
.limit(1);
return rows[0] ? mapTriggerStateRow(rows[0]) : null;
}
async upsertTriggerState(input: {
automationId: number;
stateKey: string;
breachStartedAt?: string | null;
lastFiredAt?: string | null;
lastValue?: number | null;
lastObservedState?: string | null;
}): Promise<void> {
const existing = await this.getTriggerState(
input.automationId,
input.stateKey,
);
const updatedAt = new Date().toISOString();
if (existing) {
const values: Record<string, unknown> = { updatedAt };
if (input.breachStartedAt !== undefined)
values.breachStartedAt = input.breachStartedAt;
if (input.lastFiredAt !== undefined)
values.lastFiredAt = input.lastFiredAt;
if (input.lastValue !== undefined) values.lastValue = input.lastValue;
if (input.lastObservedState !== undefined)
values.lastObservedState = input.lastObservedState;
await this.context.drizzle
.update(automationTriggerState)
.set(values)
.where(
and(
eq(automationTriggerState.automationId, input.automationId),
eq(automationTriggerState.stateKey, input.stateKey),
),
);
} else {
await this.context.drizzle.insert(automationTriggerState).values({
automationId: input.automationId,
stateKey: input.stateKey,
breachStartedAt: input.breachStartedAt ?? null,
lastFiredAt: input.lastFiredAt ?? null,
lastValue: input.lastValue ?? null,
lastObservedState: input.lastObservedState ?? null,
updatedAt,
});
}
await this.afterWrite();
}
async clearBreach(automationId: number, stateKey: string): Promise<void> {
await this.context.drizzle
.update(automationTriggerState)
.set({ breachStartedAt: null, updatedAt: new Date().toISOString() })
.where(
and(
eq(automationTriggerState.automationId, automationId),
eq(automationTriggerState.stateKey, stateKey),
),
);
await this.afterWrite();
}
/** Dwell windows the scheduler has to re-check without a fresh sample. */
async listOpenBreaches(): Promise<TriggerStateRow[]> {
const rows = await this.context.drizzle
.select()
.from(automationTriggerState)
.where(sql`${automationTriggerState.breachStartedAt} IS NOT NULL`);
return rows.map(mapTriggerStateRow);
}
// --- schedules ---
async upsertSchedule(input: {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}): Promise<void> {
const existing = await this.context.drizzle
.select({ id: automationSchedules.id })
.from(automationSchedules)
.where(eq(automationSchedules.automationId, input.automationId))
.limit(1);
if (existing[0]) {
await this.context.drizzle
.update(automationSchedules)
.set({
cron: input.cron,
intervalSeconds: input.intervalSeconds,
timezone: input.timezone,
nextDueAt: input.nextDueAt,
})
.where(eq(automationSchedules.automationId, input.automationId));
} else {
await this.context.drizzle.insert(automationSchedules).values(input);
}
await this.afterWrite();
}
async deleteSchedule(automationId: number): Promise<void> {
await this.context.drizzle
.delete(automationSchedules)
.where(eq(automationSchedules.automationId, automationId));
await this.afterWrite();
}
/** Schedules due at or before `now`, joined to their enabled automation. */
async listDueSchedules(now: string): Promise<DueScheduleRow[]> {
const rows = await this.context.drizzle
.select({
automationId: automationSchedules.automationId,
cron: automationSchedules.cron,
intervalSeconds: automationSchedules.intervalSeconds,
timezone: automationSchedules.timezone,
nextDueAt: automationSchedules.nextDueAt,
})
.from(automationSchedules)
.innerJoin(
automations,
eq(automations.id, automationSchedules.automationId),
)
.where(
and(
eq(automations.enabled, true),
lte(automationSchedules.nextDueAt, now),
),
);
return rows;
}
async markScheduleTicked(
automationId: number,
nextDueAt: string | null,
lastTickAt: string,
): Promise<void> {
await this.context.drizzle
.update(automationSchedules)
.set({ nextDueAt, lastTickAt })
.where(eq(automationSchedules.automationId, automationId));
await this.afterWrite();
}
// --- runs ---
async createRun(input: {
automationId: number;
userId: string;
triggerType: string;
triggerContext?: string | null;
status: string;
dryRun?: boolean;
parentRunId?: number | null;
now?: string;
}): Promise<AutomationRunRow> {
const [created] = await insertReturning(this.context, automationRuns, {
automationId: input.automationId,
userId: input.userId,
triggerType: input.triggerType,
triggerContext: input.triggerContext ?? null,
status: input.status,
startedAt: input.now ?? new Date().toISOString(),
dryRun: input.dryRun ?? false,
parentRunId: input.parentRunId ?? null,
});
await this.afterWrite();
return mapRunRow(created);
}
async finishRun(
runId: number,
input: {
status: string;
error?: string | null;
finishedAt?: string;
durationMs?: number | null;
},
): Promise<void> {
const finishedAt = input.finishedAt ?? new Date().toISOString();
await this.context.drizzle
.update(automationRuns)
.set({
status: input.status,
error: input.error ?? null,
finishedAt,
durationMs: input.durationMs ?? null,
})
.where(eq(automationRuns.id, runId));
const run = await this.context.drizzle
.select({
automationId: automationRuns.automationId,
startedAt: automationRuns.startedAt,
})
.from(automationRuns)
.where(eq(automationRuns.id, runId))
.limit(1);
if (run[0]) {
await this.context.drizzle
.update(automations)
.set({ lastRunAt: run[0].startedAt, lastRunStatus: input.status })
.where(eq(automations.id, run[0].automationId));
}
await this.afterWrite();
}
async listRuns(
userId: string,
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
const offset = Math.max(options.offset ?? 0, 0);
const where = options.automationId
? and(
eq(automationRuns.userId, userId),
eq(automationRuns.automationId, options.automationId),
)
: eq(automationRuns.userId, userId);
const rows = await this.context.drizzle
.select({
run: automationRuns,
automationName: automations.name,
})
.from(automationRuns)
.leftJoin(automations, eq(automations.id, automationRuns.automationId))
.where(where)
.orderBy(desc(automationRuns.startedAt), desc(automationRuns.id))
.limit(limit)
.offset(offset);
return rows.map((row) => ({
...mapRunRow(row.run),
automation_name: row.automationName ?? null,
}));
}
async findRunForUser(
runId: number,
userId: string,
): Promise<AutomationRunRow | null> {
const rows = await this.context.drizzle
.select()
.from(automationRuns)
.where(
and(eq(automationRuns.id, runId), eq(automationRuns.userId, userId)),
)
.limit(1);
return rows[0] ? mapRunRow(rows[0]) : null;
}
async countRunningFor(automationId: number): Promise<number> {
const rows = await this.context.drizzle
.select({ id: automationRuns.id })
.from(automationRuns)
.where(
and(
eq(automationRuns.automationId, automationId),
eq(automationRuns.status, "running"),
),
);
return rows.length;
}
// --- run steps ---
async createRunStep(input: {
runId: number;
stepIndex: number;
stepId: string;
stepType: string;
status: string;
now?: string;
}): Promise<number> {
const [created] = await insertReturning(this.context, automationRunSteps, {
runId: input.runId,
stepIndex: input.stepIndex,
stepId: input.stepId,
stepType: input.stepType,
status: input.status,
startedAt: input.now ?? new Date().toISOString(),
});
await this.afterWrite();
return created.id;
}
async finishRunStep(
stepRowId: number,
input: {
status: string;
output?: string | null;
error?: string | null;
truncated?: boolean;
finishedAt?: string;
},
): Promise<void> {
await this.context.drizzle
.update(automationRunSteps)
.set({
status: input.status,
output: input.output ?? null,
error: input.error ?? null,
truncated: input.truncated ?? false,
finishedAt: input.finishedAt ?? new Date().toISOString(),
})
.where(eq(automationRunSteps.id, stepRowId));
await this.afterWrite();
}
async listRunSteps(runId: number): Promise<AutomationRunStepRow[]> {
const rows = await this.context.drizzle
.select()
.from(automationRunSteps)
.where(eq(automationRunSteps.runId, runId))
.orderBy(asc(automationRunSteps.stepIndex));
return rows.map(mapRunStepRow);
}
/**
* Trims run history. Called from the scheduler's daily sweep rather than on
* every write, which is what made the alert engine's pruning expensive.
*/
async pruneRunsOlderThan(days: number): Promise<number> {
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
const result = await this.context.drizzle
.delete(automationRuns)
.where(lt(automationRuns.startedAt, cutoff));
const deleted = rowsAffected(result);
if (deleted > 0) await this.afterWrite();
return deleted;
}
/** Marks runs left behind by a crash so they do not block concurrency. */
async failStaleRunningRuns(olderThanIso: string): Promise<number> {
const result = await this.context.drizzle
.update(automationRuns)
.set({
status: "failed",
error: "Interrupted by a server restart",
finishedAt: new Date().toISOString(),
})
.where(
and(
eq(automationRuns.status, "running"),
lt(automationRuns.startedAt, olderThanIso),
),
);
const affected = rowsAffected(result);
if (affected > 0) await this.afterWrite();
return affected;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(automations)
.where(eq(automations.userId, userId));
const deleted = rowsAffected(result);
if (deleted > 0) await this.afterWrite();
return deleted;
}
private async listChannelIds(automationId: number): Promise<number[]> {
const rows = await this.context.drizzle
.select({ channelId: automationChannels.channelId })
.from(automationChannels)
.where(eq(automationChannels.automationId, automationId));
return rows.map((row) => row.channelId);
}
private async replaceChannels(
automationId: number,
userId: string,
channelIds: number[],
): Promise<number[]> {
await this.context.drizzle
.delete(automationChannels)
.where(eq(automationChannels.automationId, automationId));
if (channelIds.length === 0) return [];
// One lookup for the whole set instead of a query per channel.
const owned = await this.context.drizzle
.select({ id: notificationChannels.id })
.from(notificationChannels)
.where(
and(
eq(notificationChannels.userId, userId),
inArray(notificationChannels.id, channelIds),
),
);
const linked = owned.map((row) => row.id);
if (linked.length > 0) {
await this.context.drizzle
.insert(automationChannels)
.values(linked.map((channelId) => ({ automationId, channelId })));
}
return linked;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function mapAutomationRow(
row: AutomationRecord,
channels: number[] = [],
): AutomationRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
description: row.description ?? null,
enabled: row.enabled ? 1 : 0,
definition: row.definition,
definition_version: row.definitionVersion,
concurrency_policy: row.concurrencyPolicy,
max_run_seconds: row.maxRunSeconds,
dry_run: row.dryRun ? 1 : 0,
last_run_at: row.lastRunAt ?? null,
last_run_status: row.lastRunStatus ?? null,
created_at: row.createdAt,
updated_at: row.updatedAt,
channels,
};
}
function mapEngineRow(row: AutomationRecord): AutomationEngineRow {
return {
id: row.id,
userId: row.userId,
name: row.name,
enabled: !!row.enabled,
definition: row.definition,
concurrencyPolicy: row.concurrencyPolicy,
maxRunSeconds: row.maxRunSeconds,
dryRun: !!row.dryRun,
};
}
function mapTriggerStateRow(row: TriggerStateRecord): TriggerStateRow {
return {
automationId: row.automationId,
stateKey: row.stateKey,
breachStartedAt: row.breachStartedAt ?? null,
lastFiredAt: row.lastFiredAt ?? null,
lastValue: row.lastValue ?? null,
lastObservedState: row.lastObservedState ?? null,
};
}
function mapRunRow(row: AutomationRunRecord): AutomationRunRow {
return {
id: row.id,
automation_id: row.automationId,
user_id: row.userId,
trigger_type: row.triggerType,
trigger_context: row.triggerContext ?? null,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
duration_ms: row.durationMs ?? null,
error: row.error ?? null,
dry_run: row.dryRun ? 1 : 0,
parent_run_id: row.parentRunId ?? null,
};
}
function mapRunStepRow(row: AutomationRunStepRecord): AutomationRunStepRow {
return {
id: row.id,
run_id: row.runId,
step_index: row.stepIndex,
step_id: row.stepId,
step_type: row.stepType,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
output: row.output ?? null,
error: row.error ?? null,
truncated: row.truncated ? 1 : 0,
};
}
@@ -6,7 +6,6 @@ import type { DatabaseContext } from "./database-context.js";
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
import { AiRepository } from "./ai-repository.js";
import { NotificationChannelRepository } from "./notification-channel-repository.js";
import { AutomationRepository } from "./automation-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
import { CredentialRepository } from "./credential-repository.js";
@@ -155,13 +154,6 @@ export function createCurrentNotificationChannelRepository(): NotificationChanne
);
}
export function createCurrentAutomationRepository(): AutomationRepository {
return new AutomationRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("automation_repository_write"),
);
}
export function createCurrentApiKeyRepository(): ApiKeyRepository {
return new ApiKeyRepository(
createCurrentRepositoryContext(),
+5 -8
View File
@@ -14,7 +14,7 @@ import {
pickResolvedPassword,
pickResolvedUsername,
} from "../../hosts/credential-username.js";
import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js";
import { emitInternalEvent } from "../../hosts/internal-events.js";
import {
createCurrentCredentialRepository,
createCurrentOpksshTokenRepository,
@@ -534,12 +534,9 @@ router.post(
success: true,
});
notifyAutomationInternalEvent(
"host_added",
userId,
createdHost.id as number,
{ name: String(name ?? ip) },
);
emitInternalEvent("host_added", userId, createdHost.id as number, {
name: String(name ?? ip),
});
res.json(stripSensitiveFields(resolvedHost));
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
@@ -2483,7 +2480,7 @@ router.delete(
success: true,
});
notifyAutomationInternalEvent("host_deleted", userId, numericHostId, {
emitInternalEvent("host_deleted", userId, numericHostId, {
name: hostToDelete.name ?? hostToDelete.ip,
});
-35
View File
@@ -1,35 +0,0 @@
/**
* One-line hand-off from any hosts feature to anything listening for a generic
* named event (not tied to metrics polling).
*
* This now publishes onto the ctx.events bus rather than reaching into the
* automations engine directly. The two properties callers relied on are
* unchanged: it is fire-and-forget, and it creates no static edge to the
* automations layer (which reads repositories that several hosts modules also
* pull in, so a static import would close a cycle). The automations engine
* subscribes to the bus at start-up instead -- see automations/triggers.ts.
*/
import { pluginEvents, TOPICS } from "../plugins/events.js";
export interface InternalEventPayload {
event: string;
userId: string;
hostId?: number;
details?: Record<string, unknown>;
}
export function notifyAutomationInternalEvent(
event: string,
userId: string,
hostId?: number,
details?: Record<string, unknown>,
): void {
if (!userId) return;
pluginEvents.emit(TOPICS.internalEvent, {
event,
userId,
hostId,
details,
} satisfies InternalEventPayload);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* One-line hand-off from any core feature to anything listening for a generic
* named event (host_deleted, user_login, ...). Fire-and-forget: it publishes
* onto the plugin event bus under "internal.event" and never waits on a
* listener. Plugins subscribe through ctx.events.
*/
import { pluginEvents, TOPICS } from "../plugins/events.js";
export interface InternalEventPayload {
event: string;
userId: string;
hostId?: number;
details?: Record<string, unknown>;
}
export function emitInternalEvent(
event: string,
userId: string,
hostId?: number,
details?: Record<string, unknown>,
): void {
if (!userId) return;
pluginEvents.emit(TOPICS.internalEvent, {
event,
userId,
hostId,
details,
} satisfies InternalEventPayload);
}
+150
View File
@@ -0,0 +1,150 @@
/**
* ctx.notify and ctx.fetch: core's notification channels and core's guarded
* outbound HTTP. Channels belong to a user, so notify always runs as the
* acting user and never sees a channel's config.
*/
import type {
PluginFetch,
PluginNotify,
PluginNotifyResult,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
audit: AuditFn;
}
function actingUser(): string {
const actor = getActor();
if (!actor) {
throw new Error(
"ctx.notify needs an acting user: call it inside a request or ctx.asUser",
);
}
return actor;
}
/** Runs fn behind the capability check, with an audit line either way. */
async function audited<T>(
deps: Deps,
capability: string,
action: string,
details: string,
fn: () => Promise<T>,
): Promise<T> {
try {
await assertCapability(
deps.manifest.id,
capability,
deps.manifest.capabilities,
);
const result = await fn();
await deps.audit(action, details, { success: true });
return result;
} catch (error) {
await deps.audit(action, details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
export function createPluginNotify(deps: Deps): PluginNotify {
const listOwn = async (userId: string) => {
const { createCurrentNotificationChannelRepository } =
await import("../database/repositories/factory.js");
return createCurrentNotificationChannelRepository().listNotificationChannels(
userId,
);
};
return {
channels: () =>
audited(
deps,
"notify:send",
"notify_channels",
"listed channels",
async () => {
const rows = await listOwn(actingUser());
return rows.map((row) => ({
id: row.id,
name: row.name,
type: row.type,
enabled: !!row.enabled,
}));
},
),
send: (channelIds, notification) =>
audited(
deps,
"notify:send",
"notify_send",
`${channelIds.length} channel(s)`,
async () => {
const rows = await listOwn(actingUser());
const { deliverNotification } =
await import("../utils/notification-sender.js");
const result: PluginNotifyResult = { delivered: 0, failures: [] };
for (const row of rows) {
if (!channelIds.includes(row.id) || !row.enabled) continue;
try {
await deliverNotification(row, {
title: notification.title,
body: notification.body,
severity: notification.severity ?? "warning",
context: notification.context,
});
result.delivered++;
} catch (error) {
result.failures.push({
channelId: row.id,
name: row.name,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
},
),
};
}
export function createPluginFetch(deps: Deps): PluginFetch {
return (url, init = {}) =>
audited(deps, "network:outbound", "fetch", "outbound request", async () => {
const { safeOutboundFetch } =
await import("../utils/safe-outbound-fetch.js");
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
Math.max(init.timeoutMs ?? 30_000, 1),
);
try {
return await safeOutboundFetch(
url,
{
method: init.method ?? "GET",
headers: init.headers,
body: init.body,
signal: controller.signal,
},
(init.allowPrivateHosts ?? []).map((host) => host.toLowerCase()),
);
} finally {
clearTimeout(timer);
}
});
}
+3
View File
@@ -54,6 +54,7 @@ import {
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -730,6 +731,8 @@ export function createPluginContext(
),
),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
notify: createPluginNotify({ manifest, audit: auditCall }),
fetch: createPluginFetch({ manifest, audit: auditCall }),
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
desktop: {
+6 -11
View File
@@ -1,19 +1,14 @@
/**
* The process-wide event bus behind ctx.events.
*
* This formalises the ad-hoc publish paths that already existed:
* - hosts/automation-events.ts (generic named internal events)
* - hosts/host-session-status.ts (the "host.session.status" topic)
*
* Each of those lazily imported the automations engine and swallowed errors,
* for two reasons that still apply and are preserved here:
* Core publishes here (hosts/internal-events.ts for generic named events,
* hosts/host-session-status.ts for "host.session.status", and the topics in
* TOPICS below) and plugins subscribe through ctx.events. Two rules:
*
* 1. Fire-and-forget. A failing subscriber must never disturb the caller. A
* metrics poll or a host delete does not fail because an automation threw.
* 2. No static import of the automations layer. It reads repositories, which
* several hosts modules also pull in, so a static edge would close a
* cycle. Subscribers register themselves here instead of being reached
* into.
* metrics poll or a host delete does not fail because a listener threw.
* 2. No static import of a subscriber. Subscribers register themselves here
* instead of being reached into, so core never names a plugin.
*
* Topics are dotted strings. The internal ones the server itself publishes are
* listed in TOPICS; a plugin may emit and subscribe to any topic, but only
+1 -1
View File
@@ -6,7 +6,7 @@
*
* The heavy dependencies are imported lazily inside each function rather than
* at module scope: several pull in the repository layer, and a static import
* here would close the cycle hosts/automation-events.ts documents.
* here would close an import cycle through the repositories.
*/
import { pluginLogger } from "../utils/logger.js";
+4 -1
View File
@@ -38,7 +38,10 @@ export type { PluginSettingsScope };
* to it means deciding that every plugin holding settings:read-core may see
* that value, so it is a review decision rather than a convenience.
*/
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = ["app_name"];
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
"app_name",
"notification_private_endpoint_allowlist",
];
type ChangeListener = (value: unknown) => void;
@@ -59,8 +59,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
vi.mock("../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
@@ -42,8 +42,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
vi.mock("../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
@@ -22,8 +22,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(),
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
vi.mock("../../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
vi.mock("../../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("@simplewebauthn/server", () => ({
generateAuthenticationOptions: vi
@@ -90,15 +90,15 @@ describe("declared dependencies", () => {
}
});
// plugins/ai/src/backend/tools/executor.ts imports automations' routes
// directly, so the loader has to start automations first. B18 replaces the
// import with a service call and this becomes optional.
it("records ai's dependency on automations", () => {
// ai reaches automations through the automations.access service, so it
// keeps working when automations is off.
it("keeps ai's use of automations optional", () => {
if (!ids.includes("ai")) return;
const { manifest } = parseManifest(readManifest("ai"));
expect(manifest?.dependencies?.automations).toBeTruthy();
expect(manifest?.dependencies?.automations).toBeUndefined();
expect(manifest?.optionalDependencies?.automations).toBeTruthy();
});
});
@@ -0,0 +1,184 @@
/**
* ctx.notify and ctx.fetch: each is gated on its capability and audited,
* notify only ever reaches the acting user's own channels, and fetch goes
* through the SSRF guard with only the hosts the caller listed.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
channels: new Map<
string,
Array<{
id: number;
name: string;
type: string;
config: string;
enabled: number;
}>
>(),
delivered: [] as Array<{ channelId: number; title: string }>,
failChannel: null as number | null,
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentNotificationChannelRepository: () => ({
listNotificationChannels: async (userId: string) =>
state.channels.get(userId) ?? [],
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/notification-sender.js", () => ({
deliverNotification: async (
channel: { id: number },
notification: { title: string },
) => {
if (channel.id === state.failChannel) throw new Error("HTTP 500");
state.delivered.push({ channelId: channel.id, title: notification.title });
},
}));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: async (
url: string,
_init: RequestInit,
allowlist: readonly string[],
) => {
state.fetchCalls.push({ url, allowlist });
return new Response("ok", { status: 200 });
},
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
grants.set("demo", capabilities);
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
return createPluginContext(manifest, handle);
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.channels.clear();
state.delivered.length = 0;
state.failChannel = null;
state.fetchCalls.length = 0;
state.channels.set("alice", [
{ id: 1, name: "ops", type: "webhook", config: "{}", enabled: 1 },
{ id: 2, name: "muted", type: "ntfy", config: "{}", enabled: 0 },
{ id: 3, name: "pager", type: "discord", config: "{}", enabled: 1 },
]);
state.channels.set("bob", [
{ id: 9, name: "bob's", type: "webhook", config: "{}", enabled: 1 },
]);
});
describe("ctx.notify", () => {
it("refuses without notify:send and audits the refusal", async () => {
const ctx = contextFor([]);
await expect(
runAsActor("alice", "request", () => ctx.notify.channels()),
).rejects.toThrow(/notify:send/);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_notify_channels",
success: false,
});
});
it("refuses a call with no acting user", async () => {
const ctx = contextFor(["notify:send"]);
await expect(
ctx.notify.send([1], { title: "t", body: "b" }),
).rejects.toThrow(/acting user/);
});
it("lists the actor's channels without their config", async () => {
const ctx = contextFor(["notify:send"]);
const channels = await runAsActor("alice", "request", () =>
ctx.notify.channels(),
);
expect(channels).toEqual([
{ id: 1, name: "ops", type: "webhook", enabled: true },
{ id: 2, name: "muted", type: "ntfy", enabled: false },
{ id: 3, name: "pager", type: "discord", enabled: true },
]);
});
it("sends only to the actor's enabled channels and reports failures", async () => {
const ctx = contextFor(["notify:send"]);
state.failChannel = 3;
const result = await runAsActor("alice", "request", () =>
ctx.notify.send([1, 2, 3, 9], { title: "Disk", body: "full" }),
);
expect(state.delivered).toEqual([{ channelId: 1, title: "Disk" }]);
expect(result).toEqual({
delivered: 1,
failures: [{ channelId: 3, name: "pager", error: "HTTP 500" }],
});
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_notify_send",
success: true,
});
});
});
describe("ctx.fetch", () => {
it("refuses without network:outbound", async () => {
const ctx = contextFor([]);
await expect(ctx.fetch("https://example.com")).rejects.toThrow(
/network:outbound/,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_fetch",
success: false,
});
});
it("goes through the SSRF guard with the listed private hosts only", async () => {
const ctx = contextFor(["network:outbound"]);
const response = await ctx.fetch("https://ntfy.lan/topic", {
method: "POST",
body: "hi",
allowPrivateHosts: ["NTFY.LAN"],
});
expect(await response.text()).toBe("ok");
expect(state.fetchCalls).toEqual([
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
]);
});
});
+8 -8
View File
@@ -108,13 +108,13 @@ describe("hosts publishers route through the bus", () => {
pluginEvents.clear();
});
it("notifyAutomationInternalEvent emits internal.event", async () => {
const { notifyAutomationInternalEvent } =
await import("../../hosts/automation-events.js");
it("emitInternalEvent emits internal.event", async () => {
const { emitInternalEvent } =
await import("../../hosts/internal-events.js");
const listener = vi.fn();
pluginEvents.on(TOPICS.internalEvent, listener);
notifyAutomationInternalEvent("host_deleted", "user-1", 42, { a: 1 });
emitInternalEvent("host_deleted", "user-1", 42, { a: 1 });
expect(listener).toHaveBeenCalledWith({
event: "host_deleted",
@@ -124,13 +124,13 @@ describe("hosts publishers route through the bus", () => {
});
});
it("notifyAutomationInternalEvent ignores a missing userId", async () => {
const { notifyAutomationInternalEvent } =
await import("../../hosts/automation-events.js");
it("emitInternalEvent ignores a missing userId", async () => {
const { emitInternalEvent } =
await import("../../hosts/internal-events.js");
const listener = vi.fn();
pluginEvents.on(TOPICS.internalEvent, listener);
notifyAutomationInternalEvent("host_deleted", "");
emitInternalEvent("host_deleted", "");
expect(listener).not.toHaveBeenCalled();
});
+144
View File
@@ -140,3 +140,147 @@ export async function sendNotification(
});
}
}
export interface ChannelNotification {
title: string;
body: string;
severity: "info" | "warning" | "critical";
context?: {
hostId?: number;
hostName?: string;
sourceId?: number | string;
sourceName?: string;
triggerType?: string;
value?: unknown;
threshold?: unknown;
};
}
const NTFY_TAGS: Record<string, string> = {
info: "information_source",
warning: "warning",
critical: "rotating_light",
};
const DISCORD_COLORS: Record<string, number> = {
info: 3066993,
warning: 16753920,
critical: 15158332,
};
/**
* Sends one notification to one channel and throws on failure, so the caller
* can report which channel failed. Private and loopback targets need both the
* channel's allowPrivateNetwork opt-in and an exact host in the admin
* allowlist.
*/
export async function deliverNotification(
channel: { id: number; type: string; config: string },
notification: ChannelNotification,
): Promise<void> {
let config: Record<string, unknown>;
try {
config = JSON.parse(channel.config) as Record<string, unknown>;
} catch {
throw new Error("Channel configuration is not valid JSON");
}
const allowlist =
config.allowPrivateNetwork === true
? await readNotificationPrivateAllowlist()
: [];
const post = async (
url: string,
init: { method: string; headers: Record<string, string>; body: string },
) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
const response = await safeOutboundFetch(
url,
{ ...init, signal: controller.signal },
allowlist,
);
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
} finally {
clearTimeout(timer);
}
};
const url = typeof config.url === "string" ? config.url.trim() : "";
if (!url) throw new Error("Channel is missing a URL");
const context = notification.context ?? {};
switch (channel.type) {
case "webhook": {
const headers =
config.headers && typeof config.headers === "object"
? (config.headers as Record<string, string>)
: {};
return post(url, {
method: config.method === "PUT" ? "PUT" : "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({
title: notification.title,
hostName: context.hostName,
hostId: context.hostId,
ruleName: context.sourceName ?? notification.title,
ruleId: context.sourceId,
triggerType: context.triggerType,
value: context.value,
threshold: context.threshold,
message: notification.body,
severity: notification.severity,
timestamp: new Date().toISOString(),
}),
});
}
case "ntfy": {
const topic = typeof config.topic === "string" ? config.topic.trim() : "";
if (!topic) throw new Error("ntfy channel is missing a topic");
const headers: Record<string, string> = {
Title: notification.title || "Termix",
Priority: String(NTFY_PRIORITY[notification.severity] ?? 3),
Tags: NTFY_TAGS[notification.severity] ?? "information_source",
};
if (typeof config.token === "string" && config.token) {
headers.Authorization = `Bearer ${config.token}`;
}
return post(`${url.replace(/\/$/, "")}/${topic}`, {
method: "POST",
headers,
body: notification.body || notification.title,
});
}
case "discord": {
const payload: Record<string, unknown> = {
embeds: [
{
title: notification.title || "Termix",
description: notification.body || undefined,
color: DISCORD_COLORS[notification.severity] ?? 3447003,
timestamp: new Date().toISOString(),
},
],
};
if (typeof config.username === "string" && config.username) {
payload.username = config.username;
}
if (typeof config.avatar_url === "string" && config.avatar_url) {
payload.avatar_url = config.avatar_url;
}
return post(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
default:
throw new Error(`Unsupported channel type: ${channel.type}`);
}
}
+10
View File
@@ -146,3 +146,13 @@ export {
parseCustomKeybindings,
} from "@/api/open-tabs-api";
export { setHostAutoTmux } from "@/api/host-terminal-config-api";
// Notification channels are core (ctx.notify on the backend). A plugin that
// lets users pick or manage them shows core's own dialog.
export { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
export {
getNotificationChannels,
deleteNotificationChannel,
testNotificationChannel,
type NotificationChannel,
} from "@/api/notification-channels-api";