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; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; 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 X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -323,6 +323,12 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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 # Every plugin's HTTP routes. One block, no per-plugin entries: a
# plugin serves at /plugin-api/<id>/ on the main backend and never # 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 # 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; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; 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 X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -303,6 +303,12 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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 # Every plugin's HTTP routes. One block, no per-plugin entries: a
# plugin serves at /plugin-api/<id>/ on the main backend and never # 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 # 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, "when": 1790268466430,
"tag": "0045_flowery_the_hood", "tag": "0045_flowery_the_hood",
"breakpoints": true "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, "when": 1790268465907,
"tag": "0046_small_white_tiger", "tag": "0046_small_white_tiger",
"breakpoints": true "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, "when": 1790268465325,
"tag": "0042_many_old_lace", "tag": "0042_many_old_lace",
"breakpoints": true "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. status for a host, from the shell's own polling, or null outside the shell.
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the **B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
`ConnectionStage` type, for remote desktop's display socket and its remote `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 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. `.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 `TabOptions`/`TabHandle` are gone. Core's active connections list gets the
sessions shared with a user from the `sessions.sharedWithMe` action. 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 #### Host status is core
The status dot in the host list, the dashboard and the homepage widget is 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.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | | `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | | `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.auth.*` | `auth:provide` | **A8** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** | | `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** | | `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** | | `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** | | `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** | | `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 **B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other 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 the debounced save write it, for frequent low-value writes such as metrics
samples; the default still saves before returning. 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 `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 `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 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 ## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9), snippets 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 nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin, path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle `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 | - | | Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - | | A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - | | The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 70 files | D1 | | A plugin frontend importing core through `@/` | Warning | 64 files | D1 |
| A plugin importing core by relative path | Warning | 24 files | D1 | | A plugin importing core by relative path | Warning | 12 files | D1 |
| A plugin importing another plugin's source | Warning | 1 file | B18 | | A plugin importing another plugin's source | Warning | 0 files | - |
A warning does not fail a build, so the counts are held by 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 `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: Known specifics:
- **TODO(B18):** `plugins/ai/src/backend/tools/executor.ts` imports - **B17** removed the last plugin-to-plugin import: ai's proposal executor
`automations`' `routes.js` directly. It is declared as a hard `dependencies` and automation read tools call automations through the `automations.access`
entry so the loader starts automations first and marks `ai` blocked if service, and automations is an optional dependency of ai rather than a hard
automations is disabled. In B18 `ai` calls automations through an one.
`automations` service as an `optionalDependency` and the direct import goes
away.
- **B16** moved host-metrics onto the SDK: it imports nothing from core or - **B16** moved host-metrics onto the SDK: it imports nothing from core or
automations. Automations reaches it through the `host-metrics.viewers` automations. Automations reaches it through the `host-metrics.viewers`
service and its `plugin.host-metrics.*` events, and core reaches it through 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 `@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
plugins through `tsconfig.plugins*.json` and passes. Owner: D0. 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 - **B16 (host-metrics):** Termix-Mobile read `statsConfig` on each host
(`statusCheckEnabled`, `enabledWidgets`) and the host status from the (`statusCheckEnabled`, `enabledWidgets`) and the host status from the
metrics API. The host payload now carries `statusCheckEnabled` and 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 `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 deactivate and per host, so nothing leaks, but they are still module state
rather than per-activation. Owner: D1. 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` - **B15 (docker), for D2:** the console socket `/plugin-ws/docker/console`
is a public route with `optionalAuth`. The handler refuses a socket with no 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 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 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 the column, so it always reads null. Read `pluginSettings.docker` or ask the
`docker.containers` service instead. Owner: B18. `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 - **B17 (automations), for B20:** the wake-on-lan step calls the optional
import; only the Docker side is on the SDK (`docker.containers` for the `wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
step, `docker.events` for the trigger). Owner: B17. 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 ## 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; with no stored password asks for credentials; the homepage Docker widget;
an automation Docker step and a `docker_event` trigger; disable Docker with an automation Docker step and a `docker_event` trigger; disable Docker with
a console open, then enable it and open another. 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>; }) => 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 * 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 * ctx method to wrap it: a plugin bundling its own native dependency to touch
@@ -1325,6 +1399,10 @@ export interface PluginContext {
readonly audit: PluginAudit; readonly audit: PluginAudit;
/** Timers that stop on deactivate. */ /** Timers that stop on deactivate. */
readonly schedule: PluginSchedule; 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 * Runs `fn` with `userId` as the acting user, for background work that has
+66 -1
View File
@@ -56,6 +56,9 @@ import type {
PluginNativeRdpRequest, PluginNativeRdpRequest,
PluginProtocolTarget, PluginProtocolTarget,
PluginHostStatusEntry, PluginHostStatusEntry,
PluginNotification,
PluginNotificationChannel,
PluginFetchInit,
} from "./backend.js"; } from "./backend.js";
/** A timer registered through ctx.schedule, run by hand with runScheduled. */ /** A timer registered through ctx.schedule, run by hand with runScheduled. */
@@ -108,6 +111,13 @@ export interface FakeContextOptions {
desktopAvailable?: boolean; desktopAvailable?: boolean;
/** What ctx.hosts.status.get and check answer, by host id. */ /** What ctx.hosts.status.get and check answer, by host id. */
hostStatuses?: Record<number, PluginHostStatusEntry>; 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 { export interface FakeAuthRegistrations {
@@ -169,6 +179,14 @@ export interface FakePluginContext {
nativeRdpLaunches: PluginNativeRdpRequest[]; nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */ /** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>; 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. */ /** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>; audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */ /** Every ctx.hosts.recordActivity call, in order. */
@@ -270,6 +288,8 @@ export function createFakeContext(
const desktopWindows: FakePluginContext["desktopWindows"] = []; const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = []; const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const credentialReads: FakePluginContext["credentialReads"] = []; const credentialReads: FakePluginContext["credentialReads"] = [];
const notifications: FakePluginContext["notifications"] = [];
const fetches: FakePluginContext["fetches"] = [];
const audits: FakePluginContext["audits"] = []; const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = []; const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = []; 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) => { asUser: async (userId, fn) => {
const previous = actor; const previous = actor;
actor = userId; actor = userId;
@@ -797,6 +834,8 @@ export function createFakeContext(
desktopWindows, desktopWindows,
nativeRdpLaunches, nativeRdpLaunches,
credentialReads, credentialReads,
notifications,
fetches,
audits, audits,
activities, activities,
trackedSessions, trackedSessions,
@@ -852,6 +891,13 @@ export interface MockContextOptions {
desktopAvailable?: boolean; desktopAvailable?: boolean;
/** What ctx.hosts.status answers. See FakeContextOptions. */ /** What ctx.hosts.status answers. See FakeContextOptions. */
hostStatuses?: Record<number, PluginHostStatusEntry>; 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 { 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 * 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 * 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 * 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 * 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. * 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, protocolTargets: options.protocolTargets,
desktopAvailable: options.desktopAvailable, desktopAvailable: options.desktopAvailable,
hostStatuses: options.hostStatuses, hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels,
fetch: options.fetch,
manifest: { manifest: {
capabilities: options.capabilities ?? [], capabilities: options.capabilities ?? [],
...options.manifest, ...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: { capabilities: {
has: async (capability) => granted.has(capability), has: async (capability) => granted.has(capability),
require: async (capability) => { require: async (capability) => {
+7 -4
View File
@@ -26,15 +26,13 @@
"network:serve", "network:serve",
"ui:surface" "ui:surface"
], ],
"dependencies": {
"automations": "^1.0.0"
},
"optionalDependencies": { "optionalDependencies": {
"workspaces": "^1.0.0", "workspaces": "^1.0.0",
"network-topology": "^1.0.0", "network-topology": "^1.0.0",
"snippets": "^1.0.0", "snippets": "^1.0.0",
"fleets": "^1.0.0", "fleets": "^1.0.0",
"ssh-terminal": "^1.0.0" "ssh-terminal": "^1.0.0",
"automations": "^1.0.0"
}, },
"requires": [ "requires": [
{ {
@@ -61,6 +59,11 @@
"service": "terminal.history", "service": "terminal.history",
"versionRange": "^1.0.0", "versionRange": "^1.0.0",
"optional": true "optional": true
},
{
"service": "automations.access",
"versionRange": "^1.0.0",
"optional": true
} }
], ],
"providesSecret": [ "providesSecret": [
+81
View File
@@ -248,3 +248,84 @@ export async function listCommandHistory(
return null; 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 { import { createCurrentHostRepository } from "../../../../../src/backend/database/repositories/factory.js";
createCurrentAutomationRepository,
createCurrentHostRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js"; import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import { execCommand } from "@termix/plugin-sdk/host-commands"; import { execCommand } from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "../ssh.js"; import { withSshConnection } from "../ssh.js";
@@ -14,6 +10,7 @@ import {
getSnippet, getSnippet,
createFleet, createFleet,
addFleetMember, addFleetMember,
createAutomation,
} from "../services.js"; } from "../services.js";
/** Approved commands get a bounded window rather than hanging the request. */ /** Approved commands get a bounded window rather than hanging the request. */
@@ -204,20 +201,12 @@ export async function applyProposal(
} }
case "propose_create_automation": { case "propose_create_automation": {
// Reuses the same validator the automations route runs, so an // The automations plugin runs the same validator its own route does,
// LLM-authored definition is held to exactly the human standard. // so an LLM-authored definition is held to exactly the human standard.
const validation = validateDefinition(payload.definition); const created = await createAutomation(userId, {
if (!validation.ok || !validation.definition) {
throw new Error(
validation.error ?? "The automation definition is invalid",
);
}
const created = await createCurrentAutomationRepository().create({
userId,
name: requireString(payload.name, "name"), name: requireString(payload.name, "name"),
description: optionalString(payload.description), description: optionalString(payload.description),
definition: JSON.stringify(validation.definition), definition: payload.definition,
// Starts disabled: an automation the user has not watched run once // Starts disabled: an automation the user has not watched run once
// should not begin firing against their servers on approval. // should not begin firing against their servers on approval.
enabled: false, enabled: false,
@@ -225,7 +214,7 @@ export async function applyProposal(
return { return {
ok: true, 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 { import {
createCurrentNotificationChannelRepository, createCurrentNotificationChannelRepository,
createCurrentAutomationRepository,
createCurrentHomepageItemRepository, createCurrentHomepageItemRepository,
createCurrentHostRepository, createCurrentHostRepository,
} from "../../../../../src/backend/database/repositories/factory.js"; } from "../../../../../src/backend/database/repositories/factory.js";
@@ -11,6 +10,8 @@ import {
listSnippets, listSnippets,
listFleets, listFleets,
listCommandHistory, listCommandHistory,
listAutomations,
getAutomation,
} from "../services.js"; } from "../services.js";
/** /**
@@ -128,17 +129,18 @@ export const readTools: AiTool[] = [
category: "read", category: "read",
parameters: objectSchema({}), parameters: objectSchema({}),
handler: async (_args, context) => { handler: async (_args, context) => {
const automations = await createCurrentAutomationRepository().list( const automations = await listAutomations(context.userId);
context.userId, if (!automations) return { automations: [], unavailable: true };
);
return { return {
automations: automations.map((automation: any) => ({ automations: automations.map((automation) => ({
id: automation.id, id: automation.id,
name: automation.name, name: automation.name,
description: automation.description ?? null, description: automation.description,
enabled: automation.enabled, enabled: automation.enabled,
lastRunAt: automation.lastRunAt ?? null, triggerKind: automation.triggerKind,
lastRunStatus: automation.lastRunStatus ?? null, lastRunAt: automation.lastRunAt,
lastRunStatus: automation.lastRunStatus,
needsPlugins: automation.missingPlugins,
})), })),
}; };
}, },
@@ -155,17 +157,18 @@ export const readTools: AiTool[] = [
["automationId"], ["automationId"],
), ),
handler: async (args, context) => { handler: async (args, context) => {
const automation = await createCurrentAutomationRepository().findForUser( const automation = await getAutomation(
Number(args.automationId),
context.userId, context.userId,
Number(args.automationId),
); );
if (automation === undefined) return { unavailable: true };
if (!automation) return { error: "Automation not found" }; if (!automation) return { error: "Automation not found" };
return { return {
automation: { automation: {
id: (automation as any).id, id: automation.id,
name: (automation as any).name, name: automation.name,
enabled: (automation as any).enabled, enabled: automation.enabled,
definition: (automation as any).definition, definition: automation.definition,
}, },
}; };
}, },
@@ -12,13 +12,12 @@ const snippetRepository = {
updateSnippet: vi.fn(), updateSnippet: vi.fn(),
deleteSnippet: vi.fn(), deleteSnippet: vi.fn(),
}; };
const automationRepository = { create: vi.fn() }; const automationsAccess = { create: vi.fn() };
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() }; const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({ vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentHostRepository: () => hostRepository, createCurrentHostRepository: () => hostRepository,
createCurrentSnippetRepository: () => snippetRepository, createCurrentSnippetRepository: () => snippetRepository,
createCurrentAutomationRepository: () => automationRepository,
})); }));
const resolveHostById = vi.fn(); const resolveHostById = vi.fn();
@@ -57,9 +56,24 @@ describe("applyProposal", () => {
).rejects.toThrow("Unknown proposal kind"); ).rejects.toThrow("Unknown proposal kind");
}); });
it("validates an automation definition before creating it", async () => { function withAutomations() {
// Reuses the automations route's own validator, so a definition the model 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. // 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( await expect(
applyProposal( applyProposal(
"propose_create_automation", "propose_create_automation",
@@ -69,12 +83,22 @@ describe("applyProposal", () => {
}, },
"user-1", "user-1",
), ),
).rejects.toThrow(); ).rejects.toThrow("Unknown or missing trigger kind");
expect(automationRepository.create).not.toHaveBeenCalled(); });
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 () => { 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( const result = await applyProposal(
"propose_create_automation", "propose_create_automation",
@@ -89,8 +113,8 @@ describe("applyProposal", () => {
); );
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(automationRepository.create).toHaveBeenCalledWith( expect(automationsAccess.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", enabled: false }), expect.objectContaining({ name: "nightly", enabled: false }),
); );
}); });
+10
View File
@@ -157,6 +157,16 @@
"onError": { "onError": {
"stop": "Stop the run", "stop": "Stop the run",
"continue": "Carry on" "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": [ "capabilities": [
"hosts:read", "hosts:read",
"hosts:write",
"credentials:use", "credentials:use",
"ssh:connect", "ssh:connect",
"db:own", "db:own",
@@ -24,19 +23,20 @@
"notify:send", "notify:send",
"network:outbound", "network:outbound",
"network:serve", "network:serve",
"settings:read-core",
"ui:surface" "ui:surface"
], ],
"optionalDependencies": { "optionalDependencies": {
"snippets": "^1.0.0",
"fleets": "^1.0.0", "fleets": "^1.0.0",
"tunnels": "^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": [ "requires": [
{ {
"service": "snippets.access", "service": "snippets.access",
"versionRange": "^1.0.0", "versionRange": "^1.0.0"
"optional": true
}, },
{ {
"service": "fleets.access", "service": "fleets.access",
@@ -57,6 +57,23 @@
"service": "docker.events", "service": "docker.events",
"versionRange": "^1.0.0", "versionRange": "^1.0.0",
"optional": true "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": { "contributes": {
@@ -102,5 +119,8 @@
] ]
}, },
"locales": "locales", "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 type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js"; import type { HostSelector } from "../../types.js";
import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js"; import type { StepExecutionContext, StepRuntime } from "./types.js";
import { fleetMemberIds } from "../snippets.js";
import type { StepExecutionContext } 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 { export interface ResolvedTarget {
id: number; id: number;
name: string; name: string;
host: NonNullable<Awaited<ReturnType<typeof resolveHostById>>>; host: PluginSshHost;
} }
/** /**
* Turns a selector into the hosts a step may actually act on. * Turns a selector into the hosts a step may actually act on.
* *
* Access is checked here, at execution time rather than when the automation * Access is checked at execution time rather than when the automation was
* was saved, so a permission revoked after the fact takes effect on the next * saved, so a permission revoked after the fact takes effect on the next run.
* run. resolveHostById performs its own connect-level check and returns null * resolveHost runs as the automation's owner and answers null when they can
* when the owner can no longer reach the host. * no longer reach the host.
*/ */
export async function resolveTargets( export async function resolveTargets(
selector: HostSelector, selector: HostSelector,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<{ targets: ResolvedTarget[]; skipped: number[] }> { ): Promise<{ targets: ResolvedTarget[]; skipped: number[] }> {
const ids = await selectorHostIds(selector, context); const ids = await selectorHostIds(selector, context, runtime);
const targets: ResolvedTarget[] = []; const targets: ResolvedTarget[] = [];
const skipped: number[] = []; const skipped: number[] = [];
for (const id of ids) { 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) { if (!host) {
skipped.push(id); skipped.push(id);
continue; 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 }; return { targets, skipped };
@@ -42,6 +48,7 @@ export async function resolveTargets(
async function selectorHostIds( async function selectorHostIds(
selector: HostSelector, selector: HostSelector,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<number[]> { ): Promise<number[]> {
switch (selector.kind) { switch (selector.kind) {
case "host": case "host":
@@ -50,35 +57,29 @@ async function selectorHostIds(
return selector.hostIds; return selector.hostIds;
case "trigger": case "trigger":
return context.triggerHostId ? [context.triggerHostId] : []; return context.triggerHostId ? [context.triggerHostId] : [];
case "fleet": case "fleet": {
return fleetHostIds(selector.fleetId, context.userId); const fleets = runtime.deps.fleets();
case "all": if (typeof fleets.members !== "function") return [];
return allAccessibleHostIds(context.userId); 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: default:
return []; 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 { import type { Client } from "ssh2";
DEFAULT_STEP_TIMEOUT_MS, import { DEFAULT_STEP_TIMEOUT_MS, type Step } from "../../types.js";
type Step,
} from "../../../../../src/types/automations.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands"; 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 { renderRecord, renderTemplate } from "../template.js";
import { resolveTargets, type ResolvedTarget } from "./host-targets.js"; import { resolveTargets, type ResolvedTarget } from "./host-targets.js";
import { import {
@@ -18,8 +9,14 @@ import {
stepTimeout, stepTimeout,
type StepExecutionContext, type StepExecutionContext,
type StepResult, type StepResult,
type StepRuntime,
} from "./types.js"; } 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. * One executor per step type.
* *
@@ -30,22 +27,23 @@ import {
export async function executeStep( export async function executeStep(
step: Step, step: Step,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> { ): Promise<StepResult> {
switch (step.type) { switch (step.type) {
case "notify": case "notify":
return runNotify(step, context); return runNotify(step, context, runtime);
case "http": case "http":
return runHttp(step, context); return runHttp(step, context, runtime);
case "run_command": case "run_command":
return runCommand(step, context); return runCommand(step, context, runtime);
case "run_snippet": case "run_snippet":
return runSnippet(step, context); return runSnippet(step, context, runtime);
case "docker": case "docker":
return runDocker(step, context); return runDocker(step, context, runtime);
case "tunnel": case "tunnel":
return runTunnel(step, context); return runTunnel(step, context, runtime);
case "wol": case "wol":
return runWol(step, context); return runWol(step, context, runtime);
case "wait": case "wait":
return runWait(step, context); return runWait(step, context);
case "set_var": case "set_var":
@@ -64,6 +62,7 @@ export async function executeStep(
async function runNotify( async function runNotify(
step: Extract<Step, { type: "notify" }>, step: Extract<Step, { type: "notify" }>,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> { ): Promise<StepResult> {
const title = renderTemplate(step.title ?? "", context.template); const title = renderTemplate(step.title ?? "", context.template);
const body = renderTemplate(step.body ?? "", context.template); const body = renderTemplate(step.body ?? "", context.template);
@@ -77,49 +76,45 @@ async function runNotify(
); );
} }
const repository = createCurrentNotificationChannelRepository(); const trigger = context.template.trigger ?? {};
const channels = await repository.listNotificationChannels(context.userId); let result;
const selected = channels.filter((channel) => try {
step.channelIds.includes(channel.id), result = await runtime.ctx.notify.send(step.channelIds, {
); title: title || "Termix automation",
body,
if (selected.length === 0) { severity: step.severity ?? "warning",
return fail("Selected notification channels no longer exist"); 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 = result.failures.map((f) => `${f.name}: ${f.error}`);
const errors: string[] = []; if (result.delivered === 0) {
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) {
return fail(errors.join("; ") || "No enabled channels to notify"); return fail(errors.join("; ") || "No enabled channels to notify");
} }
return ok( return ok(
`Notified ${delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`, `Notified ${result.delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
); );
} }
async function runHttp( async function runHttp(
step: Extract<Step, { type: "http" }>, step: Extract<Step, { type: "http" }>,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> { ): Promise<StepResult> {
const url = renderTemplate(step.url, context.template); const url = renderTemplate(step.url, context.template);
const headers = renderRecord(step.headers, context.template); const headers = renderRecord(step.headers, context.template);
@@ -132,11 +127,20 @@ async function runHttp(
} }
try { 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, method: step.method,
headers, headers: body
? { "Content-Type": "application/json", ...(headers ?? {}) }
: headers,
body, body,
allowPrivateNetwork: step.allowPrivateNetwork, allowPrivateHosts,
timeoutMs: stepTimeout(context, step.timeoutMs, DEFAULT_STEP_TIMEOUT_MS), 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}`; const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
return response.ok ? ok(summary) : fail(`HTTP ${response.status}`, summary); return response.ok ? ok(summary) : fail(`HTTP ${response.status}`, summary);
} catch (error) { } 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( async function runCommand(
step: Extract<Step, { type: "run_command" }>, step: Extract<Step, { type: "run_command" }>,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> { ): Promise<StepResult> {
const command = renderTemplate(step.command, context.template); 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) { if (context.dryRun) {
return { output: `Would run on ${target.name}: ${command}` }; return { output: `Would run on ${target.name}: ${command}` };
} }
@@ -162,6 +181,7 @@ async function runCommand(
command, command,
step.elevated, step.elevated,
context, context,
runtime,
step.timeoutMs, step.timeoutMs,
); );
}); });
@@ -170,10 +190,20 @@ async function runCommand(
async function runSnippet( async function runSnippet(
step: Extract<Step, { type: "run_snippet" }>, step: Extract<Step, { type: "run_snippet" }>,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> { ): 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"); if (snippet.isNote) return fail("Notes cannot be executed on a host");
// Template values only ever reach the snippet through inputValues, never by // Template values only ever reach the snippet through inputValues, never by
@@ -181,18 +211,17 @@ async function runSnippet(
// syntax of their own. // syntax of their own.
const inputValues = renderRecord(step.inputValues, context.template) ?? {}; const inputValues = renderRecord(step.inputValues, context.template) ?? {};
return runOnTargets(step.hostSelector, context, async (target) => { return runOnTargets(step.hostSelector, context, runtime, async (target) => {
const command = await resolveSnippetCommandFor( const command = await resolveCommand(
context.userId,
step.snippetId, step.snippetId,
{ {
ip: target.host.ip, ip: target.host.ip,
username: target.host.username, username: target.host.username,
port: target.host.port, port: target.host.port,
name: target.host.name, name: target.name,
}, },
inputValues, inputValues,
); ).catch(() => null);
if (!command) { if (!command) {
return { output: "", error: "Snippet could not be resolved" }; return { output: "", error: "Snippet could not be resolved" };
} }
@@ -205,6 +234,7 @@ async function runSnippet(
command, command,
step.elevated, step.elevated,
context, context,
runtime,
step.timeoutMs, step.timeoutMs,
); );
}); });
@@ -213,40 +243,51 @@ async function runSnippet(
async function runDocker( async function runDocker(
step: Extract<Step, { type: "docker" }>, step: Extract<Step, { type: "docker" }>,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> { ): Promise<StepResult> {
const container = renderTemplate(step.container, context.template); const container = renderTemplate(step.container, context.template);
if (!container) return fail("Container name is required"); 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) { if (context.dryRun) {
return { return {
output: `Would ${step.action} container ${container} on ${target.name}`, output: `Would ${step.action} container ${container} on ${target.name}`,
}; };
} }
const result = await runDockerAction( try {
context.userId, await action(target.id, container, step.action);
target.id, return { output: `Container ${container}: ${step.action} done` };
container, } catch (error) {
step.action, return { output: "", error: errorText(error) };
); }
return result.ok
? { output: `Container ${container}: ${step.action} done` }
: { output: "", error: result.error };
}); });
} }
async function runTunnel( async function runTunnel(
step: Extract<Step, { type: "tunnel" }>, step: Extract<Step, { type: "tunnel" }>,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> { ): Promise<StepResult> {
const name = renderTemplate(step.tunnelName, context.template); const name = renderTemplate(step.tunnelName, context.template);
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`); 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 // A disconnect goes through the tunnels plugin's manual stop, which also
// holds off its retries, so the tunnel stays down. // holds off its retries, so the tunnel stays down.
const result = await runTunnelAction(context.userId, step.action, name); try {
if (!result.ok) return fail(result.error); await act(name);
} catch (error) {
return fail(errorText(error));
}
return ok( return ok(
step.action === "connect" step.action === "connect"
? `Tunnel ${name} connected` ? `Tunnel ${name} connected`
@@ -257,26 +298,23 @@ async function runTunnel(
async function runWol( async function runWol(
step: Extract<Step, { type: "wol" }>, step: Extract<Step, { type: "wol" }>,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> { ): Promise<StepResult> {
const host = await resolveTargets( const host = await runtime.ctx.hosts.get(step.hostId).catch(() => null);
{ kind: "host", hostId: step.hostId }, if (!host) return fail("Host not found or not accessible");
context, const name = host.name || host.ip;
);
const target = host.targets[0];
if (!target?.host) return fail("Host not found or not accessible");
const mac = (target.host as { macAddress?: string }).macAddress; const wake = runtime.deps.wakeOnLan().wake;
if (!mac) return fail("Host has no MAC address configured"); if (typeof wake !== "function") {
if (context.dryRun) return ok(`Would wake ${target.name} (${mac})`); return fail("The wake-on-lan plugin is not available");
}
if (context.dryRun) return ok(`Would wake ${name}`);
try { try {
const { sendWakeOnLan, isValidMac } = await wake(step.hostId);
await import("../../../../../src/backend/utils/wake-on-lan.js"); return ok(`Sent magic packet to ${name}`);
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
await sendWakeOnLan(mac);
return ok(`Sent magic packet to ${target.name}`);
} catch (error) { } 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( async function runOnTargets(
selector: Extract<Step, { type: "run_command" }>["hostSelector"], selector: Extract<Step, { type: "run_command" }>["hostSelector"],
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>, run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>,
): Promise<StepResult> { ): Promise<StepResult> {
const { targets, skipped } = await resolveTargets(selector, context); const { targets, skipped } = await resolveTargets(selector, context, runtime);
if (targets.length === 0) { if (targets.length === 0) {
return fail( return fail(
@@ -363,25 +402,27 @@ async function execOnHost(
command: string, command: string,
elevated: boolean | undefined, elevated: boolean | undefined,
context: StepExecutionContext, context: StepExecutionContext,
runtime: StepRuntime,
timeoutMs: number | undefined, timeoutMs: number | undefined,
): Promise<{ output?: string; error?: string }> { ): Promise<{ output?: string; error?: string }> {
const sshHost = host as ResolvedTarget["host"] & { sudoPassword?: string };
const timeout = stepTimeout(context, timeoutMs, DEFAULT_STEP_TIMEOUT_MS); const timeout = stepTimeout(context, timeoutMs, DEFAULT_STEP_TIMEOUT_MS);
if (timeout <= 0) return { error: "Run deadline exceeded" }; if (timeout <= 0) return { error: "Run deadline exceeded" };
try { try {
const result = await withSshConnection( const result = await runtime.ctx.ssh.withConnection<
sshHost as never, { stdout: string; stderr: string; code: number | null },
{ pool: "fleet", purpose: "fleet" }, Client
async (client) => { >(host, { pool: "automations", purpose: "fleet" }, async (client) => {
if (elevated) { if (elevated) {
return execElevated(client, command, sshHost.sudoPassword, { return execElevated(
timeoutMs: timeout, client,
}); command,
} host.sudoPassword as string | undefined,
return execCommand(client, command, timeout); { timeoutMs: timeout },
}, );
); }
return execCommand(client, command, timeout);
});
const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.code === 0 || result.code === null) { if (result.code === 0 || result.code === null) {
@@ -389,6 +430,6 @@ async function execOnHost(
} }
return { error: `Exited with code ${result.code}`, output }; return { error: `Exited with code ${result.code}`, output };
} catch (error) { } 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"; import type { TemplateContext } from "../template.js";
/** What every executor returns. `output` is what later steps can read. */ /** What every executor returns. `output` is what later steps can read. */
@@ -12,6 +14,12 @@ export interface StepResult {
halt?: { status: "success" | "failed" }; halt?: { status: "success" | "failed" };
} }
/** What the executors reach the rest of Termix through. */
export interface StepRuntime {
ctx: PluginContext;
deps: Deps;
}
export interface StepExecutionContext { export interface StepExecutionContext {
userId: string; userId: string;
automationId: number; automationId: number;
@@ -1,7 +1,4 @@
import type { import type { MetricPath, Operator } from "../types.js";
MetricPath,
Operator,
} from "../../../../src/types/automations.js";
/** /**
* Operator evaluation and metric extraction. * 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 type { PluginContext } from "@termix/plugin-sdk/backend";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js"; import type { Deps, DockerEvent } from "./deps.js";
import { subscribeDockerEvents, type DockerEvent } from "./docker.js"; import type { DockerEvent as TriggerDockerEvent } from "./triggers.js";
import { onDockerEvent } from "./triggers.js";
/** /**
* Keeps a docker.events subscription for every host a docker_event trigger * Keeps a docker.events subscription for every host a docker_event trigger
@@ -21,95 +20,71 @@ interface Watch {
subscribedAt: number; 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}`; return {
/** Brings the subscriptions in line with the watched hosts. */
/** Hosts named by an enabled docker_event trigger, with the owning user. */ async reconcile(now: number = Date.now()): Promise<void> {
export async function listDockerWatchedHosts(): Promise<Map<number, string>> { const wanted = await watchedHosts();
const watched = new Map<number, string>(); const wantedKeys = new Set(
[...wanted].map(([hostId, userId]) => keyOf(hostId, userId)),
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,
); );
watch.subscribedAt = watch.unsubscribe ? now : 0;
} catch {
watch.subscribedAt = 0;
}
}
}
/** Drops every subscription, for shutdown and tests. */ for (const [key, watch] of watches) {
export function resetDockerWatcher(): void { if (wantedKeys.has(key)) continue;
for (const watch of watches.values()) watch.unsubscribe?.(); watch.unsubscribe?.();
watches.clear(); 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 { import type { PluginContext } from "@termix/plugin-sdk/backend";
AutomationDefinition, import type { AutomationDefinition, RunStatus, Step } from "../types.js";
RunStatus,
Step,
} from "../../../../src/types/automations.js";
import { import {
DEFAULT_MAX_RUN_SECONDS, DEFAULT_MAX_RUN_SECONDS,
MAX_AUTOMATION_DEPTH, MAX_AUTOMATION_DEPTH,
MAX_STEP_OUTPUT_BYTES, MAX_STEP_OUTPUT_BYTES,
} from "../../../../src/types/automations.js"; } from "../types.js";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js";
import { executeStep } from "./actions/index.js"; import { executeStep } from "./actions/index.js";
import type { StepExecutionContext, StepResult } from "./actions/types.js"; import type { StepExecutionContext, StepResult } from "./actions/types.js";
import { compare } from "./conditions.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"; 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 { export interface RunRequest {
automationId: number; automationId: number;
triggerType: string; triggerType: string;
@@ -36,38 +38,53 @@ export interface RunOutcome {
} }
/** /**
* Executes automations. * Executes automations. One per activation. State that has to survive a
* * restart (cooldowns, dwell windows) lives in the database; the only thing
* Both HTTP handlers and the metrics hooks live in this same process, so this * held in memory is the set of runs in flight, which is meaningless after a
* is a plain singleton rather than anything cross-process. State that has to * restart anyway.
* 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.
*/ */
export class AutomationEngine { export class AutomationEngine {
private static instance: AutomationEngine;
private readonly running = new Set<number>(); private readonly running = new Set<number>();
private readonly queued = new Map<number, number>(); private readonly queued = new Map<number, number>();
static getInstance(): AutomationEngine { constructor(
if (!AutomationEngine.instance) { private readonly ctx: PluginContext,
AutomationEngine.instance = new AutomationEngine(); private readonly repository: AutomationRepository,
} private readonly deps: Deps,
return AutomationEngine.instance; ) {}
}
isRunning(automationId: number): boolean { isRunning(automationId: number): boolean {
return this.running.has(automationId); return this.running.has(automationId);
} }
/**
* Runs an automation as its owner. Never throws: a failure is the outcome.
*/
async run(request: RunRequest): Promise<RunOutcome> { async run(request: RunRequest): Promise<RunOutcome> {
const repository = createCurrentAutomationRepository(); let automation: AutomationEngineRow | null;
const automation = await repository.findById(request.automationId); try {
automation = await this.repository.findById(request.automationId);
} catch (err) {
return { runId: null, status: "failed", error: errorText(err) };
}
if (!automation) { if (!automation) {
return { runId: null, status: "failed", error: "Automation not found" }; 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 depth = request.depth ?? 0;
const ancestry = request.ancestry ?? []; 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 // A second trigger while a run is in flight is recorded as skipped rather
// than dropped silently, so the history explains what happened. // than dropped silently, so the history explains what happened.
// //
@@ -182,10 +223,7 @@ export class AutomationEngine {
let host: TemplateContext["host"]; let host: TemplateContext["host"];
if (request.triggerHostId) { if (request.triggerHostId) {
try { try {
const resolved = await resolveHostById( const resolved = await this.ctx.hosts.get(request.triggerHostId);
request.triggerHostId,
automation.userId,
);
if (resolved) { if (resolved) {
host = { host = {
id: request.triggerHostId, id: request.triggerHostId,
@@ -245,7 +283,7 @@ export class AutomationEngine {
} }
} catch (err) { } catch (err) {
status = "failed"; status = "failed";
error = err instanceof Error ? err.message : String(err); error = errorText(err);
} finally { } finally {
this.running.delete(automation.id); this.running.delete(automation.id);
} }
@@ -257,31 +295,21 @@ export class AutomationEngine {
}); });
if (status === "failed") { if (status === "failed") {
statsLogger.warn(`Automation "${automation.name}" failed`, { this.ctx.log.warn(
operation: "automation_run_failed", `Automation "${automation.name}" (${automation.id}) failed in run ${run.id}: ${error ?? "unknown error"}`,
automationId: automation.id, );
runId: run.id,
error,
});
// An automation_failed handler that itself fails must not re-announce // 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 // its own failure, so the event is not emitted for runs that this event
// already started. // already started.
if (request.triggerType !== "internal_event") { if (request.triggerType !== "internal_event") {
// Emitted onto the bus rather than imported from hosts/: the bus is this.ctx.events.emit(TOPIC_AUTOMATION_FAILED, {
// fire-and-forget, so this no longer needs the lazy-import dance that userId: automation.userId,
// was working around the repositories cycle. automationId: automation.id,
notifyAutomationInternalEvent( automationName: automation.name,
"automation_failed", runId: run.id,
automation.userId, error: error ?? null,
undefined, });
{
automationId: automation.id,
automationName: automation.name,
runId: run.id,
error: error ?? null,
},
);
} }
} }
@@ -301,7 +329,7 @@ export class AutomationEngine {
error?: string; error?: string;
halted?: { status: "success" | "failed" }; halted?: { status: "success" | "failed" };
}> { }> {
const repository = createCurrentAutomationRepository(); const repository = this.repository;
for (const step of steps) { for (const step of steps) {
if (step.enabled === false) continue; if (step.enabled === false) continue;
@@ -382,12 +410,12 @@ export class AutomationEngine {
let result: StepResult; let result: StepResult;
try { try {
result = await executeStep(step, context); result = await executeStep(step, context, {
ctx: this.ctx,
deps: this.deps,
});
} catch (err) { } catch (err) {
result = { result = { success: false, error: errorText(err) };
success: false,
error: err instanceof Error ? err.message : String(err),
};
} }
const { text, truncated } = truncate(result.output); 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. */ /** Keeps a single step's output from bloating the database. */
function truncate(output: string | undefined): { function truncate(output: string | undefined): {
text: string | null; text: string | null;
@@ -1,105 +1,101 @@
import { statsLogger } from "../../../../src/backend/utils/logger.js"; import type { PluginContext } from "@termix/plugin-sdk/backend";
import { listAutomationWatchedHosts } from "./triggers.js"; import type { Deps } from "./deps.js";
/** /**
* Keeps metric collection running for hosts an automation watches. * Keeps metric collection running for hosts an automation watches.
* *
* Heavy metric collection is normally started by a UI viewer and stops when * Host metrics only polls hosts somebody is looking at. Automations register
* the last one leaves, which means threshold rules only ever evaluated while * as a viewer through the host-metrics.viewers service, as the automation's
* somebody had the host open. Automations register a synthetic viewer instead * owner, and heartbeat every tick so the viewer is not reaped. A heartbeat
* of bypassing that mechanism, so a real viewer arriving or leaving still * that comes back false means host-metrics restarted and forgot the viewer,
* behaves exactly as before. * so the next tick registers it again.
*
* 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.
*/ */
export interface ViewerRegistry { interface Registered {
registerViewer(hostId: number, sessionId: string, userId: string): void; userId: string;
unregisterViewer(hostId: number, sessionId: string): void; viewerSessionId: string;
updateHeartbeat(sessionId: string): boolean;
} }
const SESSION_PREFIX = "automation:"; export function createHeadlessViewers(
ctx: Pick<PluginContext, "asUser" | "log">,
let registry: ViewerRegistry | null = null; deps: Pick<Deps, "viewers">,
const registered = new Map<number, string>(); watchedHosts: () => Promise<Map<number, string>>,
) {
export function setViewerRegistry(next: ViewerRegistry | null): void { const registered = new Map<number, Registered>();
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;
}
async function release(hostId: number, entry: Registered): Promise<void> {
registered.delete(hostId);
const unregister = deps.viewers().unregister;
if (typeof unregister !== "function") return;
try { try {
registry.registerViewer(hostId, sessionId, userId); await ctx.asUser(entry.userId, () =>
registered.set(hostId, userId); unregister(hostId, entry.viewerSessionId),
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));
} catch { } catch {
// Already gone; drop it either way. // Already gone; drop it either way.
} }
registered.delete(hostId);
removed++;
} }
return { added, removed, active: registered.size }; return {
} /** Brings the viewers in line with what the automations watch. */
async reconcile(): Promise<{ added: number; removed: number }> {
/** Drops every synthetic viewer, for shutdown and tests. */ const viewers = deps.viewers();
export function releaseHeadlessViewers(): void { if (typeof viewers.register !== "function") {
if (registry) { // host-metrics is off: its viewers went with it.
for (const hostId of registered.keys()) { registered.clear();
try { return { added: 0, removed: 0 };
registry.unregisterViewer(hostId, automationSessionId(hostId));
} catch {
// Nothing useful to do during teardown.
} }
}
} const watched = await watchedHosts();
registered.clear(); 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 type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js"; import { createDeps } from "./deps.js";
import { setPluginServices } from "./snippets.js"; import { createDockerWatcher } from "./docker-watcher.js";
import { setTunnelServices } from "./tunnels.js"; import { AutomationEngine } from "./engine.js";
import { setDockerServices } from "./docker.js"; import { createHeadlessViewers } from "./headless-viewer.js";
import { startAutomationsService, stopAutomationsService } from "./routes.js"; import { createAutomationRepository } from "./repository.js";
import { import { registerRoutes } from "./routes.js";
startAutomationScheduler, import { createScheduler, STARTUP_DELAY_MS, TICK_MS } from "./scheduler.js";
stopAutomationScheduler, import { createAutomationsService } from "./service.js";
} from "./scheduler.js"; import { createTriggers } from "./triggers.js";
import { onInternalEvent } from "./triggers.js";
/** What the tunnels plugin emits when a tunnel drops without being asked to. */ export type { AutomationsAccessV1, AutomationSummary } from "./service.js";
interface TunnelDisconnectedEvent {
userId?: string;
hostId?: number;
tunnelName?: string;
}
export async function activate(ctx: PluginContext) { export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh); const repository = await createAutomationRepository(ctx.db, async () => {
ctx.disposables.add(() => setPluginSsh(null)); const channels = await ctx.notify.channels();
setPluginServices(ctx.services); return channels.map((channel) => channel.id);
ctx.disposables.add(() => setPluginServices(null)); });
setTunnelServices(ctx.services); const deps = createDeps(ctx.services);
ctx.disposables.add(() => setTunnelServices(null)); const engine = new AutomationEngine(ctx, repository, deps);
setDockerServices(ctx.services); const triggers = createTriggers(repository, engine, ctx.log);
ctx.disposables.add(() => setDockerServices(null)); triggers.subscribe(ctx.events);
// Nothing arrives here while the tunnels plugin is off, which is the whole const viewers = createHeadlessViewers(ctx, deps, () =>
// of the optional dependency. triggers.watchedHosts("metric_threshold"),
ctx.events.on("plugin.tunnels.tunnel_disconnected", (payload) => { );
const event = payload as TunnelDisconnectedEvent; const docker = createDockerWatcher(
if (!event?.userId) return; ctx,
void onInternalEvent({ deps,
event: "tunnel_disconnected", () => triggers.watchedHosts("docker_event"),
userId: event.userId, triggers.onDockerEvent,
hostId: event.hostId, );
details: { tunnelName: event.tunnelName }, ctx.disposables.add(() => {
}).catch((error: unknown) => { docker.reset();
ctx.log.warn( void viewers.releaseAll();
`Tunnel disconnect trigger failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}); });
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 // The webhook route authenticates on its own per-automation token rather
// than a session, which is the point of an inbound webhook. // 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( ctx.services.provide(
"Automations routes mounted at /plugin-api/automations; scheduler started", "automations.access",
createAutomationsService(ctx, repository, engine, deps),
); );
} }
export async function deactivate() { export async function deactivate() {}
stopAutomationScheduler();
stopAutomationsService();
}
-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 type { PluginLogger } from "@termix/plugin-sdk/backend";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js"; import type { AutomationDefinition } from "../types.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { computeNextDueAt } from "./cron.js"; import { computeNextDueAt } from "./cron.js";
import { hasDwelled, isCoolingDown } from "./conditions.js"; import { hasDwelled, isCoolingDown } from "./conditions.js";
import { reconcileDockerWatch, resetDockerWatcher } from "./docker-watcher.js"; import type { AutomationEngine } from "./engine.js";
import { AutomationEngine } from "./engine.js"; import type { AutomationRepository } from "./repository.js";
import { reconcileHeadlessViewers } from "./headless-viewer.js";
import {
subscribeAutomationTriggers,
unsubscribeAutomationTriggers,
} from "./triggers.js";
/** /**
* The one timer the automations feature owns. * The one tick the automations plugin runs. It handles due schedules, dwell
* * windows that need re-checking without a fresh sample, the watchers that
* Every other periodic job in the backend is its own module-level setInterval; * keep metrics and Docker events flowing for watched hosts, and history
* this deliberately is not one per automation. A single tick handles due * pruning. activate() drives it through ctx.schedule.
* schedules, dwell windows that need re-checking without a fresh sample, the
* synthetic viewer heartbeat, and history pruning.
*/ */
const TICK_MS = 15_000; export const TICK_MS = 15_000;
const STARTUP_DELAY_MS = 30_000; export const STARTUP_DELAY_MS = 30_000;
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000; const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const RUN_RETENTION_DAYS = 30; const RUN_RETENTION_DAYS = 30;
/** A "running" row older than this belongs to a process that is gone. */ /** A "running" row older than this belongs to a process that is gone. */
const STALE_RUN_MS = 6 * 60 * 60 * 1000; const STALE_RUN_MS = 6 * 60 * 60 * 1000;
let tickTimer: NodeJS.Timeout | null = null; export interface SchedulerDeps {
let startupTimer: NodeJS.Timeout | null = null; repository: AutomationRepository;
let lastPruneAt = 0; engine: Pick<AutomationEngine, "run">;
let ticking = false; log: PluginLogger;
/** Watchers brought in line with the enabled automations every tick. */
export function startAutomationScheduler(): void { reconcile: Array<(now: number) => Promise<unknown>>;
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 function stopAutomationScheduler(): void { export function createScheduler(deps: SchedulerDeps) {
if (tickTimer) clearInterval(tickTimer); const { repository, engine, log } = deps;
if (startupTimer) clearTimeout(startupTimer); let lastPruneAt = 0;
tickTimer = null; let ticking = false;
startupTimer = null;
unsubscribeAutomationTriggers();
resetDockerWatcher();
}
/** Exposed for tests; the interval calls this. */ const errorText = (error: unknown) =>
export async function tick(now: Date = new Date()): Promise<void> { error instanceof Error ? error.message : String(error);
// A slow tick must not overlap the next one.
if (ticking) return;
ticking = true;
try { async function runDueSchedules(now: Date): Promise<void> {
await reconcileHeadlessViewers().catch(() => undefined); const due = await repository.listDueSchedules(now.toISOString());
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> { for (const schedule of due) {
const repository = createCurrentAutomationRepository(); const nextDueAt = computeNextDueAt(
const due = await repository.listDueSchedules(now.toISOString()); {
cron: schedule.cron,
for (const schedule of due) { intervalSeconds: schedule.intervalSeconds,
const automation = await repository.findById(schedule.automationId); timezone: schedule.timezone,
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,
}, },
triggerHostId: Number.isFinite(hostId) ? hostId : undefined, now,
}) );
.catch(() => undefined); await repository.markScheduleTicked(
} schedule.automationId,
} nextDueAt,
now.toISOString(),
);
async function pruneIfDue(now: Date): Promise<void> { void engine
if (now.getTime() - lastPruneAt < PRUNE_INTERVAL_MS) return; .run({
lastPruneAt = now.getTime(); automationId: schedule.automationId,
triggerType: "schedule",
const repository = createCurrentAutomationRepository(); triggerContext: { scheduledFor: now.toISOString() },
try { })
await repository.failStaleRunningRuns( .catch((error: unknown) =>
new Date(now.getTime() - STALE_RUN_MS).toISOString(), log.warn(
); `Scheduled automation ${schedule.automationId} failed to start: ${errorText(error)}`,
const deleted = await repository.pruneRunsOlderThan(RUN_RETENTION_DAYS); ),
if (deleted > 0) { );
statsLogger.info(`Pruned ${deleted} old automation run(s)`, {
operation: "automation_run_prune",
deleted,
});
} }
} 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 { * Fires sustained breaches whose window has elapsed. Without this a dwell
return DataCrypto.canUserAccessData(userId); * window only completes when another sample happens to arrive.
} catch { */
return false; 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 { import type {
AutomationDefinition, AutomationEngineRow,
HostSelector, AutomationRepository,
Trigger, } from "./repository.js";
} from "../../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import type { AutomationEngineRow } from "../../../../src/backend/database/repositories/automation-repository.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { import {
compare, compare,
extractMetricValue, extractMetricValue,
@@ -15,11 +13,7 @@ import {
severityForValue, severityForValue,
type MetricsSnapshot, type MetricsSnapshot,
} from "./conditions.js"; } from "./conditions.js";
import { AutomationEngine } from "./engine.js"; import { TOPIC_AUTOMATION_FAILED, type AutomationEngine } from "./engine.js";
import {
pluginEvents,
TOPICS,
} from "../../../../src/backend/plugins/events.js";
/** /**
* Matches events against automation triggers and decides what fires. * Matches events against automation triggers and decides what fires.
@@ -68,377 +62,404 @@ interface LoadedAutomation {
definition: AutomationDefinition; definition: AutomationDefinition;
} }
async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> { export type Triggers = ReturnType<typeof createTriggers>;
try {
const rows = /** Built once per activation. */
await createCurrentAutomationRepository().listEnabledForUser(userId); export function createTriggers(
const loaded: LoadedAutomation[] = []; repository: AutomationRepository,
for (const row of rows) { engine: Pick<AutomationEngine, "run">,
try { log: PluginLogger,
loaded.push({ ) {
row, async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> {
definition: JSON.parse(row.definition) as AutomationDefinition, try {
}); const rows = await repository.listEnabledForUser(userId);
} catch { const loaded: LoadedAutomation[] = [];
// A malformed definition should not stop the others from evaluating. 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. */ /** Whether a selector covers a host. Ownership is checked by the caller. */
function selectorCoversHost( function selectorCoversHost(
selector: HostSelector | undefined, selector: HostSelector | undefined,
hostId: number, hostId: number,
): boolean { ): boolean {
if (!selector) return true; if (!selector) return true;
switch (selector.kind) { switch (selector.kind) {
case "all": case "all":
case "trigger": case "trigger":
return true; return true;
case "host": case "host":
return selector.hostId === hostId; return selector.hostId === hostId;
case "hosts": case "hosts":
return selector.hostIds.includes(hostId); return selector.hostIds.includes(hostId);
case "fleet": case "fleet":
// Fleet membership is resolved at execution time; evaluate optimistically // Fleet membership is resolved at execution time; evaluate optimistically
// so a fleet-scoped trigger still reaches the engine. // so a fleet-scoped trigger still reaches the engine.
return true; return true;
default: default:
return false; return false;
}
} }
}
async function fire( async function fire(
automation: AutomationEngineRow, automation: AutomationEngineRow,
stateKey: string, stateKey: string,
triggerType: string, triggerType: string,
triggerContext: Record<string, unknown>, triggerContext: Record<string, unknown>,
hostId?: number, hostId?: number,
): Promise<void> { ): Promise<void> {
const repository = createCurrentAutomationRepository(); await repository.upsertTriggerState({
await repository.upsertTriggerState({
automationId: automation.id,
stateKey,
lastFiredAt: new Date().toISOString(),
});
AutomationEngine.getInstance()
.run({
automationId: automation.id, automationId: automation.id,
triggerType, stateKey,
triggerContext, lastFiredAt: new Date().toISOString(),
triggerHostId: hostId, });
})
.catch((error) => { engine
statsLogger.warn("Automation run failed to start", { .run({
operation: "automation_trigger_error",
automationId: automation.id, 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> { * Metric samples. Called for every polled host, including hosts polled only
const automations = await loadEnabledFor(event.ownerUserId); * because an automation asked for them.
const repository = createCurrentAutomationRepository(); */
const now = Date.now(); async function onMetrics(event: MetricEvent): Promise<void> {
const observed = event.online ? "online" : "offline"; const automations = await loadEnabledFor(event.ownerUserId);
const now = Date.now();
for (const { row, definition } of automations) { for (const { row, definition } of automations) {
const trigger = definition.trigger; const trigger = definition.trigger;
if (trigger?.kind !== "host_status") continue; if (trigger?.kind !== "metric_threshold") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue; if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
const stateKey = String(event.hostId); const value = extractMetricValue(event.metrics, trigger.metric);
const state = await repository.getTriggerState(row.id, stateKey); 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({ if (!breaching) {
automationId: row.id, if (state?.breachStartedAt) {
stateKey, await repository.clearBreach(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 {
continue; continue;
} }
const trigger: Trigger | undefined = definition.trigger; // Open the dwell window on the first breaching sample.
// Only metric thresholds need heavy collection; status triggers are if (!state?.breachStartedAt) {
// already served by the cheap reachability probe. await repository.upsertTriggerState({
if (trigger?.kind !== "metric_threshold") continue; automationId: row.id,
stateKey,
const selector = trigger.hostSelector; breachStartedAt: new Date(now).toISOString(),
if (selector?.kind === "host") { lastValue: value,
watched.set(selector.hostId, row.userId); });
} else if (selector?.kind === "hosts") { if (trigger.forSeconds) continue;
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
} }
// 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;
} }
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 { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -12,30 +11,22 @@ import {
Trash2, Trash2,
Workflow, Workflow,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/button";
import { Badge } from "@/components/badge";
import { import {
createAutomation, Badge,
deleteAutomation, Button,
listAutomationRunSteps, NotificationChannelDialog,
listAutomationRuns, deleteNotificationChannel,
listAutomations, getBasePath,
runAutomation, getNotificationChannels,
updateAutomation, testNotificationChannel,
type NotificationChannel,
} from "@termix/plugin-sdk/ui";
import {
useAutomationsApi,
type AutomationRow, type AutomationRow,
type AutomationRunRow, type AutomationRunRow,
type AutomationRunStepRow, type AutomationRunStepRow,
} from "./automations-api"; } 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 { import {
AutomationEditor, AutomationEditor,
@@ -47,6 +38,10 @@ import {
type AutomationEditorOptions, type AutomationEditorOptions,
} from "./automations/editor-types"; } from "./automations/editor-types";
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
type PanelTab = "automations" | "runs" | "channels"; type PanelTab = "automations" | "runs" | "channels";
const STATUS_CLASS: Record<string, string> = { const STATUS_CLASS: Record<string, string> = {
@@ -58,25 +53,20 @@ const STATUS_CLASS: Record<string, string> = {
cancelled: "text-muted-foreground", 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 ""; if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime(); const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return ""; if (!Number.isFinite(ms)) return "";
const base = "newUi.sidebar.automations.ago";
const sec = Math.floor(ms / 1000); 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); 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); const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`; if (hr < 24) return t(`${base}.hours`, { count: hr });
return `${Math.floor(hr / 24)}d ago`; return t(`${base}.days`, { count: Math.floor(hr / 24) });
}
/** 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 }[])
: [];
} }
export function AutomationsPanel({ export function AutomationsPanel({
@@ -87,6 +77,7 @@ export function AutomationsPanel({
onEditingChange?: (editing: boolean) => void; onEditingChange?: (editing: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const api = useAutomationsApi();
const base = "newUi.sidebar.automations"; const base = "newUi.sidebar.automations";
const [tab, setTab] = useState<PanelTab>("automations"); const [tab, setTab] = useState<PanelTab>("automations");
@@ -113,60 +104,34 @@ export function AutomationsPanel({
const loadAutomations = useCallback(async () => { const loadAutomations = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
setAutomations(await listAutomations()); setAutomations(await api.list());
} catch { } catch {
// A failed load leaves the previous list on screen. // A failed load leaves the previous list on screen.
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [api]);
const loadRuns = useCallback(async () => { const loadRuns = useCallback(async () => {
try { try {
setRuns(await listAutomationRuns({ limit: 100 })); setRuns(await api.runs({ limit: 100 }));
} catch { } catch {
// Non-fatal. // Non-fatal.
} }
}, []); }, [api]);
const loadOptions = useCallback(async () => { const loadOptions = useCallback(async () => {
const [hosts, snippets, channelsResult, fleets] = await Promise.allSettled([ const [editorOptions, channelsResult] = await Promise.allSettled([
getSSHHosts({ includeStatus: false }), api.editorOptions(),
getSnippets(),
getNotificationChannels(), getNotificationChannels(),
listFleets(),
]); ]);
if (channelsResult.status === "fulfilled") { if (channelsResult.status === "fulfilled") {
setChannels(channelsResult.value); setChannels(channelsResult.value);
} }
if (editorOptions.status === "fulfilled") {
setOptions({ setOptions(editorOptions.value);
hosts: }
hosts.status === "fulfilled" }, [api]);
? 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 }))
: [],
});
}, []);
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
@@ -222,7 +187,7 @@ export function AutomationsPanel({
setSaving(true); setSaving(true);
try { try {
if (editingId === null) { if (editingId === null) {
const created = await createAutomation({ const created = await api.create({
name: draft.name.trim(), name: draft.name.trim(),
description: draft.description || null, description: draft.description || null,
enabled: draft.enabled, enabled: draft.enabled,
@@ -236,7 +201,7 @@ export function AutomationsPanel({
setEditorOpen(false); setEditorOpen(false);
} }
} else { } else {
await updateAutomation(editingId, { await api.update(editingId, {
name: draft.name.trim(), name: draft.name.trim(),
description: draft.description || null, description: draft.description || null,
enabled: draft.enabled, enabled: draft.enabled,
@@ -257,7 +222,7 @@ export function AutomationsPanel({
async function remove(row: AutomationRow) { async function remove(row: AutomationRow) {
if (!confirm(t(`${base}.deleteConfirm`))) return; if (!confirm(t(`${base}.deleteConfirm`))) return;
try { try {
await deleteAutomation(row.id); await api.remove(row.id);
toast.success(t(`${base}.deleted`)); toast.success(t(`${base}.deleted`));
await loadAutomations(); await loadAutomations();
} catch (error) { } catch (error) {
@@ -267,7 +232,7 @@ export function AutomationsPanel({
async function run(row: AutomationRow, dryRun: boolean) { async function run(row: AutomationRow, dryRun: boolean) {
try { try {
const outcome = await runAutomation(row.id, { dryRun }); const outcome = await api.run(row.id, { dryRun });
toast.success(t(`${base}.runFinished`, { status: outcome.status })); toast.success(t(`${base}.runFinished`, { status: outcome.status }));
await loadAutomations(); await loadAutomations();
if (tab === "runs") await loadRuns(); if (tab === "runs") await loadRuns();
@@ -304,7 +269,7 @@ export function AutomationsPanel({
} }
setExpandedRun(runId); setExpandedRun(runId);
try { try {
setRunSteps(await listAutomationRunSteps(runId)); setRunSteps(await api.runSteps(runId));
} catch { } catch {
setRunSteps([]); setRunSteps([]);
} }
@@ -314,7 +279,7 @@ export function AutomationsPanel({
// host manager does, rather than opening a dialog over the app. // host manager does, rather than opening a dialog over the app.
if (editorOpen) { if (editorOpen) {
const webhookUrl = webhookToken const webhookUrl = webhookToken
? `${window.location.origin}/automations/webhook/${webhookToken}` ? `${window.location.origin}${getBasePath()}/plugin-api/automations/webhook/${webhookToken}`
: ""; : "";
return ( return (
@@ -432,7 +397,7 @@ export function AutomationsPanel({
rel="noreferrer" rel="noreferrer"
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline" className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
> >
{t("hosts.docsLink")} {t(`${base}.docsLink`)}
</a> </a>
</div> </div>
@@ -475,9 +440,20 @@ export function AutomationsPanel({
> >
{row.name} {row.name}
</button> </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 && ( {!row.enabled && (
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
off {t(`${base}.offBadge`)}
</Badge> </Badge>
)} )}
</div> </div>
@@ -490,7 +466,7 @@ export function AutomationsPanel({
) )
: ""} : ""}
{row.last_run_status {row.last_run_status
? ` · ${timeAgo(row.last_run_at)}` ? ` · ${timeAgo(row.last_run_at, t)}`
: ""} : ""}
</span> </span>
<Button <Button
@@ -555,7 +531,7 @@ export function AutomationsPanel({
</span> </span>
</div> </div>
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
{timeAgo(entry.started_at)} {timeAgo(entry.started_at, t)}
{entry.duration_ms !== null {entry.duration_ms !== null
? ` · ${Math.round(entry.duration_ms / 100) / 10}s` ? ` · ${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 { import type {
AutomationDefinition, AutomationDefinition,
ConcurrencyPolicy, ConcurrencyPolicy,
@@ -6,7 +10,7 @@ import type {
StepStatus, StepStatus,
StepType, StepType,
TriggerKind, TriggerKind,
} from "@/types/automations"; } from "../types";
export interface AutomationRow { export interface AutomationRow {
id: number; id: number;
@@ -24,6 +28,8 @@ export interface AutomationRow {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
channels: number[]; channels: number[];
/** Plugins this automation needs that are not running. */
missingPlugins?: string[];
/** Returned once, only when a webhook automation is created. */ /** Returned once, only when a webhook automation is created. */
webhookToken?: string; webhookToken?: string;
} }
@@ -73,76 +79,82 @@ export interface AutomationInput {
channels?: number[]; 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 { try {
return (await authApi.get("/plugin-api/automations")).data; return (await request()).data;
} catch (error) { } catch (error) {
throw handleApiError(error, "fetch automations"); throw apiError(error, action);
} }
} }
export async function createAutomation( export function createAutomationsApi(api: PluginApiClient) {
input: AutomationInput, return {
): Promise<AutomationRow> { list: () => call<AutomationRow[]>("fetch automations", () => api.get("/")),
try { editorOptions: () =>
return (await authApi.post("/plugin-api/automations", input)).data; call<AutomationEditorOptionsResponse>("fetch editor options", () =>
} catch (error) { api.get("/editor-options"),
throw handleApiError(error, "create automation"); ),
} 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( export type AutomationsApi = ReturnType<typeof createAutomationsApi>;
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 async function deleteAutomation(id: number): Promise<void> { export function useAutomationsApi(): AutomationsApi {
try { const api = usePluginApi();
await authApi.delete(`/plugin-api/automations/${id}`); return useMemo(() => createAutomationsApi(api), [api]);
} 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");
}
} }
@@ -1,24 +1,23 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useState } from "react"; 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 { import {
Button,
Input,
Label,
Switch,
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/select"; } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Plus } from "lucide-react";
import { import {
AUTOMATION_DEFINITION_VERSION, AUTOMATION_DEFINITION_VERSION,
type AutomationDefinition, type AutomationDefinition,
type ConcurrencyPolicy, type ConcurrencyPolicy,
type Step, type Step,
} from "@/types/automations"; } from "../../types";
import { StepBlock } from "./StepBlock"; import { StepBlock } from "./StepBlock";
import { TriggerCard, defaultTrigger } from "./TriggerCard"; import { TriggerCard, defaultTrigger } from "./TriggerCard";
import { newStepId, type AutomationEditorOptions } from "./editor-types"; import { newStepId, type AutomationEditorOptions } from "./editor-types";
@@ -246,7 +245,9 @@ export function AutomationEditor({
setDefinition(parsed as AutomationDefinition); setDefinition(parsed as AutomationDefinition);
setJsonError(null); setJsonError(null);
} catch (error) { } 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 { useTranslation } from "@termix/plugin-sdk/frontend";
import { Label } from "@/components/label";
import { import {
Label,
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/select"; } from "@termix/plugin-sdk/ui";
import type { HostSelector } from "@/types/automations"; import type { HostSelector } from "../../types";
import type { AutomationEditorOptions } from "./editor-types"; import type { AutomationEditorOptions } from "./editor-types";
/** /**
@@ -56,9 +56,11 @@ export function HostSelectorField({
<SelectItem value="host"> <SelectItem value="host">
{t(`${base}.hostSelector.host`)} {t(`${base}.hostSelector.host`)}
</SelectItem> </SelectItem>
<SelectItem value="fleet"> {(options.providers.fleets || kind === "fleet") && (
{t(`${base}.hostSelector.fleet`)} <SelectItem value="fleet">
</SelectItem> {t(`${base}.hostSelector.fleet`)}
</SelectItem>
)}
<SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem> <SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1,20 +1,23 @@
import { useTranslation } from "@termix/plugin-sdk/frontend"; 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 { import {
Button,
Input,
Label,
Textarea,
Switch,
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/select"; } from "@termix/plugin-sdk/ui";
import type { Step, StepType } from "@/types/automations"; import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import type { Step, StepType } from "../../types";
import { HostSelectorField } from "./HostSelectorField"; import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types"; import {
missingStepProvider,
type AutomationEditorOptions,
} from "./editor-types";
const STEP_TYPES: StepType[] = [ const STEP_TYPES: StepType[] = [
"notify", "notify",
@@ -86,11 +89,20 @@ export function StepBlock({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{STEP_TYPES.map((type) => ( {STEP_TYPES.map((type) => {
<SelectItem key={type} value={type}> const needs = missingStepProvider(type, options.providers);
{t(`${base}.stepTypes.${type}`)} // A type whose plugin is off is only listed when this step
</SelectItem> // 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> </SelectContent>
</Select> </Select>
@@ -1,16 +1,19 @@
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import { import {
Input,
Label,
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/select"; } from "@termix/plugin-sdk/ui";
import type { Trigger, TriggerKind } from "@/types/automations"; import type { Trigger, TriggerKind } from "../../types";
import { HostSelectorField } from "./HostSelectorField"; import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types"; import {
missingTriggerProvider,
type AutomationEditorOptions,
} from "./editor-types";
const TRIGGER_KINDS: TriggerKind[] = [ const TRIGGER_KINDS: TriggerKind[] = [
"metric_threshold", "metric_threshold",
@@ -98,11 +101,20 @@ export function TriggerCard({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{TRIGGER_KINDS.map((kind) => ( {TRIGGER_KINDS.map((kind) => {
<SelectItem key={kind} value={kind}> const needs = missingTriggerProvider(kind, options.providers);
{t(`${base}.triggerKinds.${kind}`)} // A kind whose plugin is off is only listed when this trigger
</SelectItem> // 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> </SelectContent>
</Select> </Select>
</div> </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. */ /** Lookup data the editor needs to turn ids into names. */
export interface AutomationEditorOptions { export interface AutomationEditorOptions {
hosts: Array<{ id: number; name: string }>; hosts: Array<{ id: number; name: string }>;
snippets: Array<{ id: number; name: string }>; snippets: Array<{ id: number; name: string }>;
channels: Array<{ id: number; name: string }>; channels: Array<{ id: number; name: string }>;
fleets: Array<{ id: number; name: string }>; fleets: Array<{ id: number; name: string }>;
/** Which optional plugins are running. */
providers: AutomationProviders;
} }
export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = { export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
@@ -11,9 +16,57 @@ export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
snippets: [], snippets: [],
channels: [], channels: [],
fleets: [], 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. */ /** Short unique id for a new step, stable for the life of the automation. */
export function newStepId(): string { export function newStepId(): string {
return `s${Math.random().toString(36).slice(2, 8)}`; 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, TermixApp,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
import { AutomationsPanel } from "./AutomationsPanel"; import { AutomationsPanel } from "./AutomationsPanel";
import { listAutomations } from "./automations-api"; import { createAutomationsApi } from "./automations-api";
const VIEW_ID = "automations"; const VIEW_ID = "automations";
@@ -40,7 +40,8 @@ export function activate(app: TermixApp): void {
}); });
// The assistant's @-mentions list automations through this. // 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", { app.registerSlotContribution("onboarding.features", {
actionId: "automations.feature", 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 { beforeEach, describe, expect, it, vi } from "vitest";
import type { import { createFakeContext } from "@termix/plugin-sdk/testing";
AutomationDefinition, import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
Step, import type { AutomationDefinition, Step } from "../../src/types.js";
} from "../../../../src/types/automations.js";
/** /**
* The engine reaches the database through the repository factory and the * The engine reaches the database through its repository and the outside
* outside world through the step executors, so both are mocked here. What is * world through the step executors, so both are faked here. What is under
* under test is the run loop itself: ordering, branching, error policy, * test is the run loop itself: ordering, branching, error policy,
* concurrency, recursion and dry-run. * concurrency, recursion and dry-run.
*/ */
@@ -50,15 +49,7 @@ const repository = {
}), }),
}; };
const resolveHostById = vi.fn(); let hosts: PluginHostSummary[] = [];
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
const executeStep = vi.fn(); const executeStep = vi.fn();
vi.mock("../../src/backend/actions/index.js", () => ({ 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"); 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( function defineAutomation(
steps: Step[], steps: Step[],
overrides: Partial<FakeAutomation> = {}, overrides: Partial<FakeAutomation> = {},
@@ -102,23 +105,30 @@ beforeEach(() => {
nextRunId = 1; nextRunId = 1;
nextStepRowId = 1; nextStepRowId = 1;
vi.clearAllMocks(); vi.clearAllMocks();
resolveHostById.mockResolvedValue(null); hosts = [];
executeStep.mockResolvedValue({ success: true, output: "ok" }); executeStep.mockResolvedValue({ success: true, output: "ok" });
// The singleton carries in-flight state between tests. freshEngine();
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
}); });
describe("AutomationEngine.run", () => { describe("AutomationEngine.run", () => {
it("adds the trigger host name to the template context", async () => { it("adds the trigger host name to the template context", async () => {
defineAutomation([step({ id: "notify", type: "notify" })]); defineAutomation([step({ id: "notify", type: "notify" })]);
resolveHostById.mockResolvedValue({ hosts = [
name: "Proxmox Node", {
ip: "10.0.0.11", id: 11,
username: "root", userId: "user-1",
port: 22, 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, automationId: 1,
triggerType: "metric_threshold", triggerType: "metric_threshold",
triggerHostId: 11, triggerHostId: 11,
@@ -143,7 +153,7 @@ describe("AutomationEngine.run", () => {
step({ id: "b", type: "http" }), step({ id: "b", type: "http" }),
]); ]);
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -161,7 +171,7 @@ describe("AutomationEngine.run", () => {
]); ]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" }); executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -178,7 +188,7 @@ describe("AutomationEngine.run", () => {
]); ]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" }); executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -193,7 +203,7 @@ describe("AutomationEngine.run", () => {
step({ id: "b", type: "http" }), step({ id: "b", type: "http" }),
]); ]);
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -209,7 +219,7 @@ describe("AutomationEngine.run", () => {
]); ]);
executeStep.mockResolvedValueOnce({ success: true, output: "hello" }); executeStep.mockResolvedValueOnce({ success: true, output: "hello" });
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -231,7 +241,7 @@ describe("AutomationEngine.run", () => {
vars: { x: "1" }, vars: { x: "1" },
}); });
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -254,7 +264,7 @@ describe("AutomationEngine.run", () => {
}), }),
]); ]);
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -274,7 +284,7 @@ describe("AutomationEngine.run", () => {
}), }),
]); ]);
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -296,7 +306,7 @@ describe("AutomationEngine.run", () => {
}), }),
]); ]);
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "metric_threshold", triggerType: "metric_threshold",
triggerContext: { value: 95 }, triggerContext: { value: 95 },
@@ -316,7 +326,7 @@ describe("AutomationEngine.run", () => {
step({ id: "after", type: "http" }), step({ id: "after", type: "http" }),
]); ]);
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -335,7 +345,7 @@ describe("AutomationEngine.run", () => {
halt: { status: "success" }, halt: { status: "success" },
}); });
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -351,7 +361,7 @@ describe("AutomationEngine.run", () => {
halt: { status: "failed" }, halt: { status: "failed" },
}); });
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -365,7 +375,7 @@ describe("AutomationEngine.run", () => {
step({ id: "nested", type: "run_automation", automationId: 1 }), step({ id: "nested", type: "run_automation", automationId: 1 }),
]); ]);
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -389,7 +399,7 @@ describe("AutomationEngine.run", () => {
{ id: 2 }, { id: 2 },
); );
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -402,7 +412,7 @@ describe("AutomationEngine.run", () => {
it("refuses to nest deeper than the maximum depth", async () => { it("refuses to nest deeper than the maximum depth", async () => {
defineAutomation([step({ id: "a", type: "http" })]); defineAutomation([step({ id: "a", type: "http" })]);
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
depth: 99, depth: 99,
@@ -428,7 +438,7 @@ describe("AutomationEngine.run", () => {
}), }),
); );
const engine = AutomationEngine.getInstance(); const engine = getEngine();
const first = engine.run({ automationId: 1, triggerType: "manual" }); const first = engine.run({ automationId: 1, triggerType: "manual" });
// Let the first run register itself as in flight. // Let the first run register itself as in flight.
await new Promise((resolve) => setTimeout(resolve, 10)); 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 // No gap between the two: the in-flight slot used to be claimed several
// awaits after it was checked, so both runs got through. // awaits after it was checked, so both runs got through.
const engine = AutomationEngine.getInstance(); const engine = getEngine();
const [first, second] = await Promise.all([ const [first, second] = await Promise.all([
engine.run({ automationId: 1, triggerType: "manual" }), engine.run({ automationId: 1, triggerType: "manual" }),
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" })]); defineAutomation([step({ id: "a", type: "run_command" })]);
repository.createRun.mockRejectedValueOnce(new Error("db down")); repository.createRun.mockRejectedValueOnce(new Error("db down"));
const engine = AutomationEngine.getInstance(); const engine = getEngine();
const failed = await engine.run({ const failed = await engine.run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
@@ -497,7 +507,7 @@ describe("AutomationEngine.run", () => {
it("propagates the dry-run flag to executors", async () => { it("propagates the dry-run flag to executors", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: true }); defineAutomation([step({ id: "a", type: "http" })], { dryRun: true });
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -509,7 +519,7 @@ describe("AutomationEngine.run", () => {
it("lets a caller force a dry run on a live automation", async () => { it("lets a caller force a dry run on a live automation", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: false }); defineAutomation([step({ id: "a", type: "http" })], { dryRun: false });
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
dryRun: true, dryRun: true,
@@ -520,7 +530,7 @@ describe("AutomationEngine.run", () => {
}); });
it("fails cleanly when the automation is missing", async () => { it("fails cleanly when the automation is missing", async () => {
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 404, automationId: 404,
triggerType: "manual", triggerType: "manual",
}); });
@@ -539,7 +549,7 @@ describe("AutomationEngine.run", () => {
dryRun: false, dryRun: false,
}); });
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -551,7 +561,7 @@ describe("AutomationEngine.run", () => {
defineAutomation([step({ id: "a", type: "run_command" })]); defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockRejectedValueOnce(new Error("connection reset")); executeStep.mockRejectedValueOnce(new Error("connection reset"));
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -568,7 +578,7 @@ describe("AutomationEngine.run", () => {
output: "x".repeat(40_000), output: "x".repeat(40_000),
}); });
await AutomationEngine.getInstance().run({ await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", triggerType: "manual",
}); });
@@ -591,7 +601,7 @@ describe("AutomationEngine.run", () => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
try { try {
const outcome = await AutomationEngine.getInstance().run({ const outcome = await getEngine().run({
automationId: 1, automationId: 1,
triggerType: "manual", 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 crypto from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import type { Request, Response, Router } from "express"; import { definition, startServer, type TestServer } from "./helpers";
import type { AutomationDefinition } from "../../../../src/types/automations.js";
/** let server: TestServer | null = null;
* 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.
*/
const state = vi.hoisted(() => ({ afterEach(async () => {
currentUserId: "user-1", await server?.close();
rows: [] as Array<Record<string, unknown>>, server = null;
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" });
}); });
describe("POST /", () => { const schedule = definition({ kind: "schedule", intervalSeconds: 300 });
it("creates an automation from a valid definition", async () => {
const res = await invoke("post", "/", {
body: { name: "Disk watch", definition: definition() },
});
expect(res.statusCode).toBe(201); describe("automation routes", () => {
expect(res.jsonBody?.name).toBe("Disk watch"); 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 () => { it("never shows one user's automations to another", async () => {
const res = await invoke("post", "/", { server = await startServer();
body: { definition: definition() }, 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 () => { it("rejects an invalid definition", async () => {
const res = await invoke("post", "/", { server = await startServer();
const response = await server.request("POST", "/", {
body: { body: {
name: "Bad", name: "Bad",
definition: { version: 1, trigger: { kind: "nope" }, steps: [] }, definition: {
},
});
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({
trigger: { kind: "schedule", intervalSeconds: 5 }, trigger: { kind: "schedule", intervalSeconds: 5 },
} as Partial<AutomationDefinition>), steps: [],
},
}, },
}); });
expect(res.statusCode).toBe(400); expect(response.status).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/60 seconds/i); expect(response.body.error).toMatch(/60 seconds/);
}); });
it("registers a schedule for a schedule trigger", async () => { it("marks an automation that needs a plugin that is off", async () => {
await invoke("post", "/", { server = await startServer();
const created = await server.request("POST", "/", {
body: { body: {
name: "Nightly", name: "Restart app",
definition: definition({ definition: definition({ kind: "schedule", intervalSeconds: 300 }, [
trigger: { kind: "schedule", cron: "0 2 * * *" }, {
} as Partial<AutomationDefinition>), 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 () => { it("offers editor options and the running providers", async () => {
const res = await invoke("post", "/", { server = await startServer({
body: { services: {
name: "Hooked", "fleets.access": { list: async () => [{ id: 3, name: "web" }] },
definition: definition({
trigger: { kind: "webhook", tokenHash: "" },
} as Partial<AutomationDefinition>),
}, },
}); });
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); it("returns a webhook token once and runs the automation from it", async () => {
const token = res.jsonBody?.webhookToken as string; server = await startServer();
expect(token).toMatch(/^[a-f0-9]{64}$/); 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); const stored = server.db.sqlite
expect(stored.trigger.tokenHash).not.toBe(token); .prepare("SELECT definition FROM p_automations_automations WHERE id = ?")
expect(stored.trigger.tokenHash).toBe( .get(created.body.id) as { definition: string };
expect(JSON.parse(stored.definition).trigger.tokenHash).toBe(
crypto.createHash("sha256").update(token).digest("hex"), crypto.createHash("sha256").update(token).digest("hex"),
); );
// The hash is never echoed back to the client. const fired = await server.request("POST", `/webhook/${token}`, {
const body = res.jsonBody as { definition: AutomationDefinition }; user: null,
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 },
body: { hello: "world" }, body: { hello: "world" },
}); });
expect(fired.status).toBe(202);
expect(fired.body.status).toBe("success");
expect(res.statusCode).toBe(202); const wrong = await server.request("POST", `/webhook/${"0".repeat(64)}`, {
expect(run).toHaveBeenCalledWith( user: null,
expect.objectContaining({ automationId: 1, triggerType: "webhook" }), body: {},
); });
expect(wrong.status).toBe(404);
}); });
it("rejects a token that does not match", async () => { it("runs now, records the steps and lists the run", async () => {
seedWebhook("a".repeat(64)); server = await startServer();
const created = await server.request("POST", "/", {
const res = await invoke("post", "/webhook/:token", { body: { name: "Now", definition: schedule },
params: { token: "b".repeat(64) },
}); });
const outcome = await server.request("POST", `/${created.body.id}/run`, {
expect(res.statusCode).toBe(404); body: { dryRun: true },
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" },
}); });
expect(outcome.body.status).toBe("success");
expect(res.statusCode).toBe(404); const runs = await server.request("GET", "/runs/history");
expect(run).not.toHaveBeenCalled(); 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 { beforeEach, describe, expect, it, vi } from "vitest";
import type { import type { AutomationDefinition, Trigger } from "../../src/types.js";
AutomationDefinition, import { createTriggers } from "../../src/backend/triggers.js";
Trigger,
} from "../../../../src/types/automations.js";
/** /**
* Trigger matching, dwell windows and cooldowns. The repository and the engine * 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 })); const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
vi.mock("../../src/backend/engine.js", () => ({ const log = {
AutomationEngine: { getInstance: () => ({ run }) }, debug: () => {},
})); info: () => {},
warn: () => {},
const triggers = await import("../../src/backend/triggers.js"); error: () => {},
};
const triggers = createTriggers(repository as never, { run }, log);
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) { function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
const definition: AutomationDefinition = { version: 1, trigger, steps: [] }; 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 () => { it("collects hosts from metric triggers so they can be polled headlessly", async () => {
addAutomation({ addAutomation({
kind: "metric_threshold", kind: "metric_threshold",
@@ -426,7 +422,7 @@ describe("listAutomationWatchedHosts", () => {
cooldownMinutes: 15, cooldownMinutes: 15,
}); });
const watched = await triggers.listAutomationWatchedHosts(); const watched = await triggers.watchedHosts("metric_threshold");
expect([...watched.keys()].sort()).toEqual([3, 4]); expect([...watched.keys()].sort()).toEqual([3, 4]);
}); });
@@ -439,6 +435,6 @@ describe("listAutomationWatchedHosts", () => {
cooldownMinutes: 0, 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 { defaultTrigger } from "../../src/frontend/automations/TriggerCard";
import { emptyDraft } from "../../src/frontend/automations/AutomationEditor"; import { emptyDraft } from "../../src/frontend/automations/AutomationEditor";
import { newStepId } from "../../src/frontend/automations/editor-types"; import { newStepId } from "../../src/frontend/automations/editor-types";
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations"; import { AUTOMATION_DEFINITION_VERSION } from "../../src/types";
describe("emptyDraft", () => { describe("emptyDraft", () => {
it("starts on a metric threshold with no steps", () => { 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/terminal/TerminalProposalCard.tsx",
"plugins/ai/src/frontend/use-ai-stream.ts", "plugins/ai/src/frontend/use-ai-stream.ts",
"plugins/ai/src/frontend/useMentions.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/FileManager.tsx",
"plugins/file-manager/src/frontend/FileManagerContextMenu.tsx", "plugins/file-manager/src/frontend/FileManagerContextMenu.tsx",
"plugins/file-manager/src/frontend/FileManagerDialogs.tsx", "plugins/file-manager/src/frontend/FileManagerDialogs.tsx",
@@ -81,22 +75,10 @@
"plugins/ai/src/backend/routes.ts", "plugins/ai/src/backend/routes.ts",
"plugins/ai/src/backend/tools/executor.ts", "plugins/ai/src/backend/tools/executor.ts",
"plugins/ai/src/backend/tools/read-tools.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/index.ts",
"plugins/file-manager/src/backend/operation-routes.ts", "plugins/file-manager/src/backend/operation-routes.ts",
"plugins/proxmox/src/backend/routes.ts", "plugins/proxmox/src/backend/routes.ts",
"plugins/tailscale/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, * 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. * 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 { loginRateLimiter } from "../utils/login-rate-limiter.js";
import { logAudit, getRequestMeta } from "../utils/audit-logger.js"; import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
import { parseUserAgent } from "../utils/user-agent-parser.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 { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import type { UserRecord } from "../database/repositories/user-repository.js"; import type { UserRecord } from "../database/repositories/user-repository.js";
@@ -112,7 +112,7 @@ export async function issueSession(
userAgent, userAgent,
success: true, success: true,
}); });
notifyAutomationInternalEvent("user_login", user.id, undefined, { emitInternalEvent("user_login", user.id, undefined, {
username: user.username, username: user.username,
ipAddress, ipAddress,
}); });
-152
View File
@@ -2030,158 +2030,6 @@ const migrateSchema = () => {
} }
// --- alerts end --- // --- 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 --- // --- homepage begin ---
try { 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. // AI assistant.
{ {
name: "idx_ai_providers_user_label", name: "idx_ai_providers_user_label",
-159
View File
@@ -13,7 +13,6 @@ import {
varchar, varchar,
int, int,
boolean, boolean,
double,
index, index,
uniqueIndex, uniqueIndex,
foreignKey, foreignKey,
@@ -1032,164 +1031,6 @@ export const notificationChannels = mysqlTable("notification_channels", {
}); });
// --- alerts end --- // --- 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 --- // --- homepage begin ---
export const homepageItems = mysqlTable( export const homepageItems = mysqlTable(
-159
View File
@@ -14,7 +14,6 @@ import {
integer, integer,
serial, serial,
boolean, boolean,
doublePrecision,
index, index,
uniqueIndex, uniqueIndex,
foreignKey, foreignKey,
@@ -1033,164 +1032,6 @@ export const notificationChannels = pgTable("notification_channels", {
}); });
// --- alerts end --- // --- 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 --- // --- homepage begin ---
export const homepageItems = pgTable( export const homepageItems = pgTable(
-159
View File
@@ -2,7 +2,6 @@ import {
sqliteTable, sqliteTable,
text, text,
integer, integer,
real,
index, index,
uniqueIndex, uniqueIndex,
foreignKey, foreignKey,
@@ -1029,164 +1028,6 @@ export const notificationChannels = sqliteTable("notification_channels", {
}); });
// --- alerts end --- // --- 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 --- // --- homepage begin ---
export const homepageItems = sqliteTable( 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 { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
import { AiRepository } from "./ai-repository.js"; import { AiRepository } from "./ai-repository.js";
import { NotificationChannelRepository } from "./notification-channel-repository.js"; import { NotificationChannelRepository } from "./notification-channel-repository.js";
import { AutomationRepository } from "./automation-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js"; import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js"; import { AuditLogRepository } from "./audit-log-repository.js";
import { CredentialRepository } from "./credential-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 { export function createCurrentApiKeyRepository(): ApiKeyRepository {
return new ApiKeyRepository( return new ApiKeyRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
+5 -8
View File
@@ -14,7 +14,7 @@ import {
pickResolvedPassword, pickResolvedPassword,
pickResolvedUsername, pickResolvedUsername,
} from "../../hosts/credential-username.js"; } from "../../hosts/credential-username.js";
import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js"; import { emitInternalEvent } from "../../hosts/internal-events.js";
import { import {
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentOpksshTokenRepository, createCurrentOpksshTokenRepository,
@@ -534,12 +534,9 @@ router.post(
success: true, success: true,
}); });
notifyAutomationInternalEvent( emitInternalEvent("host_added", userId, createdHost.id as number, {
"host_added", name: String(name ?? ip),
userId, });
createdHost.id as number,
{ name: String(name ?? ip) },
);
res.json(stripSensitiveFields(resolvedHost)); res.json(stripSensitiveFields(resolvedHost));
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create"); notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
@@ -2483,7 +2480,7 @@ router.delete(
success: true, success: true,
}); });
notifyAutomationInternalEvent("host_deleted", userId, numericHostId, { emitInternalEvent("host_deleted", userId, numericHostId, {
name: hostToDelete.name ?? hostToDelete.ip, 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 { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js"; import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js"; import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginCredentials } from "./ctx-credentials.js"; import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js"; import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -730,6 +731,8 @@ export function createPluginContext(
), ),
), ),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), 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 }), auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
desktop: { desktop: {
+6 -11
View File
@@ -1,19 +1,14 @@
/** /**
* The process-wide event bus behind ctx.events. * The process-wide event bus behind ctx.events.
* *
* This formalises the ad-hoc publish paths that already existed: * Core publishes here (hosts/internal-events.ts for generic named events,
* - hosts/automation-events.ts (generic named internal events) * hosts/host-session-status.ts for "host.session.status", and the topics in
* - hosts/host-session-status.ts (the "host.session.status" topic) * TOPICS below) and plugins subscribe through ctx.events. Two rules:
*
* Each of those lazily imported the automations engine and swallowed errors,
* for two reasons that still apply and are preserved here:
* *
* 1. Fire-and-forget. A failing subscriber must never disturb the caller. A * 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. * metrics poll or a host delete does not fail because a listener threw.
* 2. No static import of the automations layer. It reads repositories, which * 2. No static import of a subscriber. Subscribers register themselves here
* several hosts modules also pull in, so a static edge would close a * instead of being reached into, so core never names a plugin.
* cycle. Subscribers register themselves here instead of being reached
* into.
* *
* Topics are dotted strings. The internal ones the server itself publishes are * 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 * 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 * The heavy dependencies are imported lazily inside each function rather than
* at module scope: several pull in the repository layer, and a static import * 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"; 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 * 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. * 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; type ChangeListener = (value: unknown) => void;
@@ -59,8 +59,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
}, },
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }), getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
})); }));
vi.mock("../../hosts/automation-events.js", () => ({ vi.mock("../../hosts/internal-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(), emitInternalEvent: vi.fn(),
})); }));
vi.mock("../../utils/logger.js", () => { vi.mock("../../utils/logger.js", () => {
const log = { const log = {
@@ -42,8 +42,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
}, },
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }), getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
})); }));
vi.mock("../../hosts/automation-events.js", () => ({ vi.mock("../../hosts/internal-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(), emitInternalEvent: vi.fn(),
})); }));
vi.mock("../../utils/logger.js", () => { vi.mock("../../utils/logger.js", () => {
const log = { const log = {
@@ -22,8 +22,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(), logAudit: vi.fn(),
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }), getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
})); }));
vi.mock("../../../hosts/automation-events.js", () => ({ vi.mock("../../../hosts/internal-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(), emitInternalEvent: vi.fn(),
})); }));
vi.mock("@simplewebauthn/server", () => ({ vi.mock("@simplewebauthn/server", () => ({
generateAuthenticationOptions: vi generateAuthenticationOptions: vi
@@ -90,15 +90,15 @@ describe("declared dependencies", () => {
} }
}); });
// plugins/ai/src/backend/tools/executor.ts imports automations' routes // ai reaches automations through the automations.access service, so it
// directly, so the loader has to start automations first. B18 replaces the // keeps working when automations is off.
// import with a service call and this becomes optional. it("keeps ai's use of automations optional", () => {
it("records ai's dependency on automations", () => {
if (!ids.includes("ai")) return; if (!ids.includes("ai")) return;
const { manifest } = parseManifest(readManifest("ai")); 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(); pluginEvents.clear();
}); });
it("notifyAutomationInternalEvent emits internal.event", async () => { it("emitInternalEvent emits internal.event", async () => {
const { notifyAutomationInternalEvent } = const { emitInternalEvent } =
await import("../../hosts/automation-events.js"); await import("../../hosts/internal-events.js");
const listener = vi.fn(); const listener = vi.fn();
pluginEvents.on(TOPICS.internalEvent, listener); pluginEvents.on(TOPICS.internalEvent, listener);
notifyAutomationInternalEvent("host_deleted", "user-1", 42, { a: 1 }); emitInternalEvent("host_deleted", "user-1", 42, { a: 1 });
expect(listener).toHaveBeenCalledWith({ expect(listener).toHaveBeenCalledWith({
event: "host_deleted", event: "host_deleted",
@@ -124,13 +124,13 @@ describe("hosts publishers route through the bus", () => {
}); });
}); });
it("notifyAutomationInternalEvent ignores a missing userId", async () => { it("emitInternalEvent ignores a missing userId", async () => {
const { notifyAutomationInternalEvent } = const { emitInternalEvent } =
await import("../../hosts/automation-events.js"); await import("../../hosts/internal-events.js");
const listener = vi.fn(); const listener = vi.fn();
pluginEvents.on(TOPICS.internalEvent, listener); pluginEvents.on(TOPICS.internalEvent, listener);
notifyAutomationInternalEvent("host_deleted", ""); emitInternalEvent("host_deleted", "");
expect(listener).not.toHaveBeenCalled(); 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, parseCustomKeybindings,
} from "@/api/open-tabs-api"; } from "@/api/open-tabs-api";
export { setHostAutoTmux } from "@/api/host-terminal-config-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";