mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: finish the automations plugin (B17)
This commit is contained in:
1 parent
993a91c899
commit
6b4d1432ab
96 files changed
+25106
-5077
No files matched your search
@@ -161,7 +161,7 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gjgrKvv0zkJ6QUgUJBcK4CPIIa2JbLMmHecileCoA9A='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-u6nDWD7Mm1U/e9z2vyubUQB8Wk00VJsojS2q1tGdZwg='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
@@ -323,6 +323,12 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
# Automation webhooks lived at /automations/webhook/<token> before
|
||||
# 2.9.0 and external systems still call that URL.
|
||||
location ^~ /automations/webhook/ {
|
||||
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
|
||||
}
|
||||
|
||||
# Every plugin's HTTP routes. One block, no per-plugin entries: a
|
||||
# plugin serves at /plugin-api/<id>/ on the main backend and never
|
||||
# gets a port or a block of its own. Buffering is off so SSE streams
|
||||
|
||||
+7
-1
@@ -142,7 +142,7 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gjgrKvv0zkJ6QUgUJBcK4CPIIa2JbLMmHecileCoA9A='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-u6nDWD7Mm1U/e9z2vyubUQB8Wk00VJsojS2q1tGdZwg='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
@@ -303,6 +303,12 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
# Automation webhooks lived at /automations/webhook/<token> before
|
||||
# 2.9.0 and external systems still call that URL.
|
||||
location ^~ /automations/webhook/ {
|
||||
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
|
||||
}
|
||||
|
||||
# Every plugin's HTTP routes. One block, no per-plugin entries: a
|
||||
# plugin serves at /plugin-api/<id>/ on the main backend and never
|
||||
# gets a port or a block of its own. Buffering is off so SSE streams
|
||||
|
||||
@@ -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
@@ -323,6 +323,13 @@
|
||||
"when": 1790268466430,
|
||||
"tag": "0045_flowery_the_hood",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "5",
|
||||
"when": 1790272734438,
|
||||
"tag": "0046_rich_logan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- The automation tables moved to the automations plugin, which renames them
|
||||
-- into its own namespace at activation. That runs after this migration, so a
|
||||
-- real DROP TABLE here would delete every automation first.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -330,6 +330,13 @@
|
||||
"when": 1790268465907,
|
||||
"tag": "0046_small_white_tiger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "7",
|
||||
"when": 1790272733784,
|
||||
"tag": "0047_mighty_butterfly",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -302,6 +302,13 @@
|
||||
"when": 1790268465325,
|
||||
"tag": "0042_many_old_lace",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "6",
|
||||
"when": 1790272733108,
|
||||
"tag": "0043_curvy_red_shift",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -598,7 +598,10 @@ The `docker` and `hostMetrics` areas are still declared in core's
|
||||
status for a host, from the shell's own polling, or null outside the shell.
|
||||
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
|
||||
`ConnectionStage` type, for remote desktop's display socket and its remote
|
||||
host lookup. `useConnectionDefaults` now carries terminal defaults only; the
|
||||
host lookup. **B17** added `NotificationChannelDialog` and the channel API
|
||||
(`getNotificationChannels`, `deleteNotificationChannel`,
|
||||
`testNotificationChannel`, the `NotificationChannel` type): channels are core,
|
||||
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the
|
||||
RDP half became remote desktop's user settings. Publishing its
|
||||
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
|
||||
|
||||
@@ -732,6 +735,38 @@ the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
|
||||
`TabOptions`/`TabHandle` are gone. Core's active connections list gets the
|
||||
sessions shared with a user from the `sessions.sharedWithMe` action.
|
||||
|
||||
**Automations (B17)** imports nothing from core. It reaches snippets
|
||||
(`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels
|
||||
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
|
||||
(`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1,
|
||||
`wake(hostId)`, which B20 provides) as services, notification channels through
|
||||
`ctx.notify`, the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and
|
||||
`ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run
|
||||
executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's.
|
||||
The scheduler tick is `ctx.schedule.every`, and triggers listen through
|
||||
`ctx.events` to `host.status`, `host.login`, `internal.event`,
|
||||
`plugin.host-metrics.snapshot`, `plugin.host-metrics.health-check`,
|
||||
`plugin.tunnels.tunnel_disconnected` and its own
|
||||
`plugin.automations.automation_failed`, which replaced the core
|
||||
`automation_failed` internal event.
|
||||
|
||||
A definition's trigger and steps say which of those optional plugins it needs
|
||||
(`requirements.ts`). An automation whose provider is not running is skipped:
|
||||
the run is recorded as `skipped` with "Needs the <plugin> plugin", the reason
|
||||
is logged, and the engine carries on with the next one. The editor asks
|
||||
`GET /editor-options` for the running providers and only offers the triggers
|
||||
and steps they allow, and the list marks such an automation "Needs <plugin>".
|
||||
It provides `automations.access` v1 (`list`, `get`, `create`, `run`, gated
|
||||
on `automations.view`, with `create` and `run` also checking their own
|
||||
permissions) for the AI assistant, which lists automations as an optional
|
||||
dependency. It adopts `automations` and the five `automation_*` tables; the
|
||||
migrations keep the legacy links between them and to `notification_channels`
|
||||
by hand. Core's `hosts/automation-events.ts` became `hosts/internal-events.ts`
|
||||
(`emitInternalEvent`), since it never was specific to automations. Webhooks
|
||||
live at `/plugin-api/automations/webhook/<token>`; the 2.8 path
|
||||
`/automations/webhook/<token>` is rewritten to it in both nginx configs,
|
||||
because external systems hold that URL.
|
||||
|
||||
#### Host status is core
|
||||
|
||||
The status dot in the host list, the dashboard and the homepage widget is
|
||||
@@ -1358,14 +1393,14 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
|
||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
|
||||
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
||||
| `ctx.notify.*` | `notify:send` | A6 |
|
||||
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
|
||||
| `ctx.auth.*` | `auth:provide` | **A8** |
|
||||
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
|
||||
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
|
||||
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
|
||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
|
||||
| `ctx.schedule.every` / `.after` | none | **B16** |
|
||||
| `ctx.fetch` | `network:outbound` | B |
|
||||
| `ctx.fetch` | `network:outbound` | **B17** |
|
||||
|
||||
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
|
||||
generic check for a capability no other ctx member wraps. Unlike every other
|
||||
@@ -1555,6 +1590,25 @@ per-host timers spreads out. Needs no capability.
|
||||
the debounced save write it, for frequent low-value writes such as metrics
|
||||
samples; the default still saves before returning.
|
||||
|
||||
**B17** added `ctx.notify` and `ctx.fetch`:
|
||||
|
||||
- `ctx.notify.channels()` lists the acting user's notification channels
|
||||
(id, name, type, enabled, never the config), and `send(channelIds,
|
||||
notification)` delivers to the ones among them that are theirs and
|
||||
enabled, returning `{ delivered, failures }`. Both need `notify:send` and
|
||||
an actor, and are audited (`plugin_notify_channels`, `plugin_notify_send`).
|
||||
Channels stay core (`notification_channels`, its routes and
|
||||
`NotificationChannelDialog`); delivery is `deliverNotification` in
|
||||
`utils/notification-sender.ts`, which keeps the channel's private-network
|
||||
opt-in plus the admin allowlist.
|
||||
- `ctx.fetch(url, { method, headers, body, timeoutMs, allowPrivateHosts })`
|
||||
goes through `safeOutboundFetch`: http and https only, no embedded
|
||||
credentials, DNS pinned, no redirects, and private or loopback addresses
|
||||
refused unless the exact host is in `allowPrivateHosts`. Needs
|
||||
`network:outbound`, audited as `plugin_fetch`. Automations' HTTP step
|
||||
passes the admin allowlist it reads through `ctx.settings.readCore`
|
||||
(`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`).
|
||||
|
||||
`ctx.hosts.create` (**B14**) hands the created row to every plugin's
|
||||
`hostImportNormalizer`, the same as a bulk import row, so a plugin creating a
|
||||
host can set another plugin's host settings (proxmox marks an imported
|
||||
@@ -2085,7 +2139,8 @@ Then, with the app running:
|
||||
## Legacy core imports: the debt D1 removes
|
||||
|
||||
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
|
||||
(B2), remote-desktop (B14), docker (B15) and host-metrics (B16), which import
|
||||
(B2), remote-desktop (B14), docker (B15), host-metrics (B16) and automations
|
||||
(B17), which import
|
||||
nothing from core. The others still reach core by relative
|
||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
||||
@@ -2113,9 +2168,9 @@ What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
| Core importing a plugin backend | **Error** | 0 | - |
|
||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||
| The shell importing plugin code | **Error** | 0 | - |
|
||||
| A plugin frontend importing core through `@/` | Warning | 70 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 24 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 1 file | B18 |
|
||||
| A plugin frontend importing core through `@/` | Warning | 64 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 12 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 0 files | - |
|
||||
|
||||
A warning does not fail a build, so the counts are held by
|
||||
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
|
||||
@@ -2131,12 +2186,10 @@ rule cannot carry both severities on the same files.
|
||||
|
||||
Known specifics:
|
||||
|
||||
- **TODO(B18):** `plugins/ai/src/backend/tools/executor.ts` imports
|
||||
`automations`' `routes.js` directly. It is declared as a hard `dependencies`
|
||||
entry so the loader starts automations first and marks `ai` blocked if
|
||||
automations is disabled. In B18 `ai` calls automations through an
|
||||
`automations` service as an `optionalDependency` and the direct import goes
|
||||
away.
|
||||
- **B17** removed the last plugin-to-plugin import: ai's proposal executor
|
||||
and automation read tools call automations through the `automations.access`
|
||||
service, and automations is an optional dependency of ai rather than a hard
|
||||
one.
|
||||
- **B16** moved host-metrics onto the SDK: it imports nothing from core or
|
||||
automations. Automations reaches it through the `host-metrics.viewers`
|
||||
service and its `plugin.host-metrics.*` events, and core reaches it through
|
||||
|
||||
@@ -334,16 +334,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
`@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
|
||||
plugins through `tsconfig.plugins*.json` and passes. Owner: D0.
|
||||
|
||||
- **B16 (host-metrics):** automations still sets up its headless viewers
|
||||
through `setViewerRegistry` in `plugins/automations/src/backend/headless-viewer.ts`,
|
||||
which nothing calls any more, so hosts an automation watches are only
|
||||
sampled while somebody has them open. Switch it to the
|
||||
`host-metrics.viewers` service (`register(hostId)` as the automation's
|
||||
owner, `heartbeat`, `unregister`) and make host-metrics an optional
|
||||
dependency again. Its triggers already listen to the final topics
|
||||
(`plugin.host-metrics.snapshot`, `plugin.host-metrics.health-check`, core
|
||||
`host.status`, `host.login`), but through core's bus by relative import
|
||||
instead of `ctx.events`. Owner: B17.
|
||||
- **B16 (host-metrics):** Termix-Mobile read `statsConfig` on each host
|
||||
(`statusCheckEnabled`, `enabledWidgets`) and the host status from the
|
||||
metrics API. The host payload now carries `statusCheckEnabled` and
|
||||
@@ -367,12 +357,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
`widgets/network-collector.ts`) to compute rates. The poller clears them on
|
||||
deactivate and per host, so nothing leaks, but they are still module state
|
||||
rather than per-activation. Owner: D1.
|
||||
- **Pre-existing, found in B16:** automations
|
||||
`tests/backend/tunnels.test.ts` fails "fails the step cleanly while the
|
||||
tunnels plugin is off": it expects "tunnels plugin is not available" and
|
||||
gets "tunnels.start is not a function". Same cause as the tunnels line
|
||||
above: since B12 `ctx.services.get` hands back an empty handle instead of
|
||||
throwing, so the step has to check `typeof start`. Owner: B17 or D0.
|
||||
- **B15 (docker), for D2:** the console socket `/plugin-ws/docker/console`
|
||||
is a public route with `optionalAuth`. The handler refuses a socket with no
|
||||
signed-in user or without `docker.use` and checks the data key per connect
|
||||
@@ -394,10 +378,39 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
tools still report `enableDocker` from the host row, which no longer has
|
||||
the column, so it always reads null. Read `pluginSettings.docker` or ask the
|
||||
`docker.containers` service instead. Owner: B18.
|
||||
- **B15 (docker), for B17:** automations' `docker-watcher.ts` still reads its
|
||||
own rules through core's automation repository and types by relative
|
||||
import; only the Docker side is on the SDK (`docker.containers` for the
|
||||
step, `docker.events` for the trigger). Owner: B17.
|
||||
|
||||
- **B17 (automations), for B20:** the wake-on-lan step calls the optional
|
||||
`wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
|
||||
automation's owner; the provider resolves the host's MAC and checks access
|
||||
itself. Until B20 provides it, the step is offered nowhere and an existing
|
||||
one is skipped with "Needs the wake-on-lan plugin". Owner: B20.
|
||||
- **B17 (automations), for D2:** the ctx table says `ctx.events.on` needs
|
||||
`events:core` for core topics, but the runtime only checks `emit`.
|
||||
docker, file-manager, host-metrics, session-recording and snippets
|
||||
subscribe to core topics (`host.*`, `user.*`) without declaring it.
|
||||
Either gate `on` and add `events:core` to those five, or change the doc to
|
||||
say subscribing is ungated. Owner: D2.
|
||||
- **B17 (automations):** the 2.8 webhook URL `/automations/webhook/<token>`
|
||||
is kept only by a rewrite in `docker/nginx.conf` and
|
||||
`docker/nginx-https.conf`. An install that serves the backend without
|
||||
nginx (the desktop app's embedded backend, a hand-rolled proxy) answers
|
||||
404 there. Decide whether that matters enough for a generic legacy-path
|
||||
alias in the plugin HTTP layer. Owner: D0.
|
||||
- **B17 (automations):** there are two notification senders now:
|
||||
`deliverNotification` (ctx.notify, per-channel private opt-in, throws) and
|
||||
the older `sendWebhook`/`sendNtfy`/`sendDiscord` that the channel "test"
|
||||
route in `notification-channels-routes.ts` still uses (always applies the
|
||||
allowlist, retries once). Point the test route at `deliverNotification`
|
||||
and delete the old ones. Owner: D1.
|
||||
- **B17 (automations):** the scheduler no longer skips a user whose data key
|
||||
cannot be resolved (`DataCrypto.canUserAccessData` has no SDK equivalent).
|
||||
With system-wrapped DEKs that check was effectively always true; if it
|
||||
ever is not, the run now fails on its first SSH step instead of being
|
||||
skipped silently. Owner: none unless a background "is this user unlocked"
|
||||
check is added to the SDK.
|
||||
- **B17 (automations):** AI's `list_notification_channels` read tool still reads
|
||||
notification channels through core's repository by relative import; it
|
||||
can use `ctx.notify.channels()` now. Owner: B18.
|
||||
|
||||
## Manual checks after 2.9.0
|
||||
|
||||
@@ -444,3 +457,14 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
with no stored password asks for credentials; the homepage Docker widget;
|
||||
an automation Docker step and a `docker_event` trigger; disable Docker with
|
||||
a console open, then enable it and open another.
|
||||
- Automations: on a 2.8 database the automations, their run history and
|
||||
their channel links are there; a schedule fires; a webhook fires on both
|
||||
`/plugin-api/automations/webhook/<token>` and the old
|
||||
`/automations/webhook/<token>`; a metric threshold on a host nobody has
|
||||
open still fires (headless viewers); a `docker_event` trigger and a Docker
|
||||
step; a notify step to webhook, ntfy and Discord channels, and to a LAN
|
||||
ntfy with the private opt-in plus the admin allowlist; disable Docker and
|
||||
the automation shows "Needs docker", its scheduled runs are recorded as
|
||||
skipped and the others keep running, then enable it again; the AI
|
||||
assistant lists and creates automations, and says they are unavailable
|
||||
with automations off.
|
||||
@@ -1255,6 +1255,80 @@ export interface PluginAudit {
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/** A notification channel the acting user set up. Its config never leaves core. */
|
||||
export interface PluginNotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
/** "webhook", "ntfy" or "discord". */
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PluginNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
severity?: "info" | "warning" | "critical";
|
||||
/**
|
||||
* Extra fields a webhook receiver gets alongside the message. `sourceId`
|
||||
* and `sourceName` name what sent it (an automation, a rule).
|
||||
*/
|
||||
context?: {
|
||||
hostId?: number;
|
||||
hostName?: string;
|
||||
sourceId?: number | string;
|
||||
sourceName?: string;
|
||||
triggerType?: string;
|
||||
value?: unknown;
|
||||
threshold?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginNotifyResult {
|
||||
delivered: number;
|
||||
failures: Array<{ channelId: number; name: string; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's notification channels, as the acting user. Needs notify:send, and
|
||||
* refuses a call with no actor: channels belong to a user.
|
||||
*/
|
||||
export interface PluginNotify {
|
||||
/** The acting user's channels, without their config. */
|
||||
channels: () => Promise<PluginNotificationChannel[]>;
|
||||
/**
|
||||
* Sends to the listed channels the acting user owns. Disabled channels and
|
||||
* ids the user does not own are skipped. One channel failing does not stop
|
||||
* the others.
|
||||
*/
|
||||
send: (
|
||||
channelIds: number[],
|
||||
notification: PluginNotification,
|
||||
) => Promise<PluginNotifyResult>;
|
||||
}
|
||||
|
||||
export interface PluginFetchInit {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
/** Aborts the request after this long. Defaults to 30 seconds. */
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Exact private or loopback hosts this request may reach. Everything else
|
||||
* private is refused, and redirects are never followed.
|
||||
*/
|
||||
allowPrivateHosts?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound HTTP through core's SSRF guard: http and https only, no embedded
|
||||
* credentials, DNS pinned, private addresses refused unless listed. Needs
|
||||
* network:outbound.
|
||||
*/
|
||||
export type PluginFetch = (
|
||||
url: string,
|
||||
init?: PluginFetchInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* A generic capability check, for a privileged action that has no dedicated
|
||||
* ctx method to wrap it: a plugin bundling its own native dependency to touch
|
||||
@@ -1325,6 +1399,10 @@ export interface PluginContext {
|
||||
readonly audit: PluginAudit;
|
||||
/** Timers that stop on deactivate. */
|
||||
readonly schedule: PluginSchedule;
|
||||
/** Core's notification channels. Needs notify:send. */
|
||||
readonly notify: PluginNotify;
|
||||
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
|
||||
readonly fetch: PluginFetch;
|
||||
|
||||
/**
|
||||
* Runs `fn` with `userId` as the acting user, for background work that has
|
||||
|
||||
@@ -56,6 +56,9 @@ import type {
|
||||
PluginNativeRdpRequest,
|
||||
PluginProtocolTarget,
|
||||
PluginHostStatusEntry,
|
||||
PluginNotification,
|
||||
PluginNotificationChannel,
|
||||
PluginFetchInit,
|
||||
} from "./backend.js";
|
||||
|
||||
/** A timer registered through ctx.schedule, run by hand with runScheduled. */
|
||||
@@ -108,6 +111,13 @@ export interface FakeContextOptions {
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.hosts.status.get and check answer, by host id. */
|
||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||
/** The acting user's channels, what ctx.notify.channels answers. */
|
||||
notificationChannels?: PluginNotificationChannel[];
|
||||
/**
|
||||
* Answers ctx.fetch. Without it every fetch rejects, so a test never
|
||||
* reaches the network by accident.
|
||||
*/
|
||||
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
export interface FakeAuthRegistrations {
|
||||
@@ -169,6 +179,14 @@ export interface FakePluginContext {
|
||||
nativeRdpLaunches: PluginNativeRdpRequest[];
|
||||
/** Every ctx.credentials.resolveHostProtocol call, in order. */
|
||||
credentialReads: Array<{ hostId: number; protocol: string }>;
|
||||
/** Every ctx.notify.send call, with the actor it ran as. */
|
||||
notifications: Array<{
|
||||
actor: string | undefined;
|
||||
channelIds: number[];
|
||||
notification: PluginNotification;
|
||||
}>;
|
||||
/** Every ctx.fetch call, in order. */
|
||||
fetches: Array<{ url: string; init?: PluginFetchInit }>;
|
||||
/** Every ctx.audit.record entry, in order. */
|
||||
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
|
||||
/** Every ctx.hosts.recordActivity call, in order. */
|
||||
@@ -270,6 +288,8 @@ export function createFakeContext(
|
||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
|
||||
const credentialReads: FakePluginContext["credentialReads"] = [];
|
||||
const notifications: FakePluginContext["notifications"] = [];
|
||||
const fetches: FakePluginContext["fetches"] = [];
|
||||
const audits: FakePluginContext["audits"] = [];
|
||||
const activities: FakePluginContext["activities"] = [];
|
||||
const trackedSessions: number[] = [];
|
||||
@@ -766,6 +786,23 @@ export function createFakeContext(
|
||||
},
|
||||
},
|
||||
|
||||
notify: {
|
||||
channels: async () => options.notificationChannels ?? [],
|
||||
send: async (channelIds, notification) => {
|
||||
notifications.push({ actor, channelIds, notification });
|
||||
const selected = (options.notificationChannels ?? []).filter(
|
||||
(channel) => channelIds.includes(channel.id) && channel.enabled,
|
||||
);
|
||||
return { delivered: selected.length, failures: [] };
|
||||
},
|
||||
},
|
||||
|
||||
fetch: async (url, init) => {
|
||||
fetches.push({ url, init });
|
||||
if (!options.fetch) throw new Error("ctx.fetch is not stubbed");
|
||||
return options.fetch(url, init);
|
||||
},
|
||||
|
||||
asUser: async (userId, fn) => {
|
||||
const previous = actor;
|
||||
actor = userId;
|
||||
@@ -797,6 +834,8 @@ export function createFakeContext(
|
||||
desktopWindows,
|
||||
nativeRdpLaunches,
|
||||
credentialReads,
|
||||
notifications,
|
||||
fetches,
|
||||
audits,
|
||||
activities,
|
||||
trackedSessions,
|
||||
@@ -852,6 +891,13 @@ export interface MockContextOptions {
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.hosts.status answers. See FakeContextOptions. */
|
||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||
/** The acting user's channels, what ctx.notify.channels answers. */
|
||||
notificationChannels?: PluginNotificationChannel[];
|
||||
/**
|
||||
* Answers ctx.fetch. Without it every fetch rejects, so a test never
|
||||
* reaches the network by accident.
|
||||
*/
|
||||
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
@@ -866,7 +912,8 @@ export interface MockPluginContext extends FakePluginContext {
|
||||
* The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
|
||||
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
|
||||
* topic outside the plugin's own namespace, settings:read-core on
|
||||
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, and
|
||||
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
|
||||
* notify:send on ctx.notify, network:outbound on ctx.fetch, and
|
||||
* auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||
*/
|
||||
@@ -891,6 +938,8 @@ export function createMockCtx(
|
||||
protocolTargets: options.protocolTargets,
|
||||
desktopAvailable: options.desktopAvailable,
|
||||
hostStatuses: options.hostStatuses,
|
||||
notificationChannels: options.notificationChannels,
|
||||
fetch: options.fetch,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
@@ -1155,6 +1204,22 @@ export function createMockCtx(
|
||||
},
|
||||
},
|
||||
|
||||
notify: {
|
||||
channels: async () => {
|
||||
require("notify:send");
|
||||
return ctx.notify.channels();
|
||||
},
|
||||
send: async (channelIds, notification) => {
|
||||
require("notify:send");
|
||||
return ctx.notify.send(channelIds, notification);
|
||||
},
|
||||
},
|
||||
|
||||
fetch: async (url, init) => {
|
||||
require("network:outbound");
|
||||
return ctx.fetch(url, init);
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
has: async (capability) => granted.has(capability),
|
||||
require: async (capability) => {
|
||||
|
||||
@@ -26,15 +26,13 @@
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
],
|
||||
"dependencies": {
|
||||
"automations": "^1.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"workspaces": "^1.0.0",
|
||||
"network-topology": "^1.0.0",
|
||||
"snippets": "^1.0.0",
|
||||
"fleets": "^1.0.0",
|
||||
"ssh-terminal": "^1.0.0"
|
||||
"ssh-terminal": "^1.0.0",
|
||||
"automations": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
@@ -61,6 +59,11 @@
|
||||
"service": "terminal.history",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "automations.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"providesSecret": [
|
||||
|
||||
@@ -248,3 +248,84 @@ export async function listCommandHistory(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The automations plugin, as ctx.services.get("automations.access") returns it. */
|
||||
interface AutomationsAccess {
|
||||
list: () => Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
triggerKind: string | null;
|
||||
lastRunAt: string | null;
|
||||
lastRunStatus: string | null;
|
||||
missingPlugins: string[];
|
||||
}>
|
||||
>;
|
||||
get: (id: number) => Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
definition: unknown;
|
||||
} | null>;
|
||||
create: (input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
definition: unknown;
|
||||
enabled?: boolean;
|
||||
}) => Promise<{ id: number; name: string }>;
|
||||
}
|
||||
|
||||
function automations(userId: string): Partial<AutomationsAccess> {
|
||||
if (!current) return {};
|
||||
try {
|
||||
return current.get<AutomationsAccess>("automations.access", { userId });
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's automations, or null when the automations plugin is off or the
|
||||
* user may not use it.
|
||||
*/
|
||||
export async function listAutomations(
|
||||
userId: string,
|
||||
): Promise<Awaited<ReturnType<AutomationsAccess["list"]>> | null> {
|
||||
const list = automations(userId).list;
|
||||
if (typeof list !== "function") return null;
|
||||
try {
|
||||
return await list();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAutomation(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<Awaited<ReturnType<AutomationsAccess["get"]>> | undefined> {
|
||||
const get = automations(userId).get;
|
||||
if (typeof get !== "function") return undefined;
|
||||
try {
|
||||
return await get(id);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an automation through the plugin, which validates it exactly as
|
||||
* its own route does. Throws when the plugin is off.
|
||||
*/
|
||||
export async function createAutomation(
|
||||
userId: string,
|
||||
input: Parameters<AutomationsAccess["create"]>[0],
|
||||
): Promise<{ id: number; name: string }> {
|
||||
const create = automations(userId).create;
|
||||
if (typeof create !== "function") {
|
||||
throw new Error("The automations plugin is not available");
|
||||
}
|
||||
return create(input);
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentHostRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||
import { createCurrentHostRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
@@ -14,6 +10,7 @@ import {
|
||||
getSnippet,
|
||||
createFleet,
|
||||
addFleetMember,
|
||||
createAutomation,
|
||||
} from "../services.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
@@ -204,20 +201,12 @@ export async function applyProposal(
|
||||
}
|
||||
|
||||
case "propose_create_automation": {
|
||||
// Reuses the same validator the automations route runs, so an
|
||||
// LLM-authored definition is held to exactly the human standard.
|
||||
const validation = validateDefinition(payload.definition);
|
||||
if (!validation.ok || !validation.definition) {
|
||||
throw new Error(
|
||||
validation.error ?? "The automation definition is invalid",
|
||||
);
|
||||
}
|
||||
|
||||
const created = await createCurrentAutomationRepository().create({
|
||||
userId,
|
||||
// The automations plugin runs the same validator its own route does,
|
||||
// so an LLM-authored definition is held to exactly the human standard.
|
||||
const created = await createAutomation(userId, {
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
definition: JSON.stringify(validation.definition),
|
||||
definition: payload.definition,
|
||||
// Starts disabled: an automation the user has not watched run once
|
||||
// should not begin firing against their servers on approval.
|
||||
enabled: false,
|
||||
@@ -225,7 +214,7 @@ export async function applyProposal(
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created automation ${(created as any).name} (disabled until you enable it)`,
|
||||
summary: `Created automation ${created.name} (disabled until you enable it)`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHostRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
@@ -11,6 +10,8 @@ import {
|
||||
listSnippets,
|
||||
listFleets,
|
||||
listCommandHistory,
|
||||
listAutomations,
|
||||
getAutomation,
|
||||
} from "../services.js";
|
||||
|
||||
/**
|
||||
@@ -128,17 +129,18 @@ export const readTools: AiTool[] = [
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const automations = await createCurrentAutomationRepository().list(
|
||||
context.userId,
|
||||
);
|
||||
const automations = await listAutomations(context.userId);
|
||||
if (!automations) return { automations: [], unavailable: true };
|
||||
return {
|
||||
automations: automations.map((automation: any) => ({
|
||||
automations: automations.map((automation) => ({
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description ?? null,
|
||||
description: automation.description,
|
||||
enabled: automation.enabled,
|
||||
lastRunAt: automation.lastRunAt ?? null,
|
||||
lastRunStatus: automation.lastRunStatus ?? null,
|
||||
triggerKind: automation.triggerKind,
|
||||
lastRunAt: automation.lastRunAt,
|
||||
lastRunStatus: automation.lastRunStatus,
|
||||
needsPlugins: automation.missingPlugins,
|
||||
})),
|
||||
};
|
||||
},
|
||||
@@ -155,17 +157,18 @@ export const readTools: AiTool[] = [
|
||||
["automationId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const automation = await createCurrentAutomationRepository().findForUser(
|
||||
Number(args.automationId),
|
||||
const automation = await getAutomation(
|
||||
context.userId,
|
||||
Number(args.automationId),
|
||||
);
|
||||
if (automation === undefined) return { unavailable: true };
|
||||
if (!automation) return { error: "Automation not found" };
|
||||
return {
|
||||
automation: {
|
||||
id: (automation as any).id,
|
||||
name: (automation as any).name,
|
||||
enabled: (automation as any).enabled,
|
||||
definition: (automation as any).definition,
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
enabled: automation.enabled,
|
||||
definition: automation.definition,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -12,13 +12,12 @@ const snippetRepository = {
|
||||
updateSnippet: vi.fn(),
|
||||
deleteSnippet: vi.fn(),
|
||||
};
|
||||
const automationRepository = { create: vi.fn() };
|
||||
const automationsAccess = { create: vi.fn() };
|
||||
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
|
||||
|
||||
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentAutomationRepository: () => automationRepository,
|
||||
}));
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
@@ -57,9 +56,24 @@ describe("applyProposal", () => {
|
||||
).rejects.toThrow("Unknown proposal kind");
|
||||
});
|
||||
|
||||
it("validates an automation definition before creating it", async () => {
|
||||
// Reuses the automations route's own validator, so a definition the model
|
||||
function withAutomations() {
|
||||
setPluginServices({
|
||||
get: (service: string) => {
|
||||
if (service !== "automations.access")
|
||||
throw new Error(`unexpected ${service}`);
|
||||
return automationsAccess as never;
|
||||
},
|
||||
provide: vi.fn(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("hands an automation to the automations plugin, which validates it", async () => {
|
||||
// The plugin runs its own route's validator, so a definition the model
|
||||
// invented is held to the same standard as a hand-written one.
|
||||
withAutomations();
|
||||
automationsAccess.create.mockRejectedValue(
|
||||
new Error("Unknown or missing trigger kind"),
|
||||
);
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_create_automation",
|
||||
@@ -69,12 +83,22 @@ describe("applyProposal", () => {
|
||||
},
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(automationRepository.create).not.toHaveBeenCalled();
|
||||
).rejects.toThrow("Unknown or missing trigger kind");
|
||||
});
|
||||
|
||||
it("refuses an automation while the automations plugin is off", async () => {
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_create_automation",
|
||||
{ name: "x", definition: {} },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("The automations plugin is not available");
|
||||
});
|
||||
|
||||
it("creates a valid automation disabled so it cannot fire unwatched", async () => {
|
||||
automationRepository.create.mockResolvedValue({ id: 5, name: "nightly" });
|
||||
withAutomations();
|
||||
automationsAccess.create.mockResolvedValue({ id: 5, name: "nightly" });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_automation",
|
||||
@@ -89,8 +113,8 @@ describe("applyProposal", () => {
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(automationRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", enabled: false }),
|
||||
expect(automationsAccess.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "nightly", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -157,6 +157,16 @@
|
||||
"onError": {
|
||||
"stop": "Stop the run",
|
||||
"continue": "Carry on"
|
||||
},
|
||||
"needsPlugin": "Needs {{plugin}}",
|
||||
"needsPluginHint": "This automation is skipped until the plugin it needs is turned on again.",
|
||||
"offBadge": "Off",
|
||||
"docsLink": "View docs",
|
||||
"ago": {
|
||||
"seconds": "{{count}}s ago",
|
||||
"minutes": "{{count}}m ago",
|
||||
"hours": "{{count}}h ago",
|
||||
"days": "{{count}}d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
},
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"hosts:write",
|
||||
"credentials:use",
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
@@ -24,19 +23,20 @@
|
||||
"notify:send",
|
||||
"network:outbound",
|
||||
"network:serve",
|
||||
"settings:read-core",
|
||||
"ui:surface"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"snippets": "^1.0.0",
|
||||
"fleets": "^1.0.0",
|
||||
"tunnels": "^1.0.0",
|
||||
"docker": "^1.0.0"
|
||||
"docker": "^1.0.0",
|
||||
"host-metrics": "^1.0.0",
|
||||
"wake-on-lan": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
"service": "snippets.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
"versionRange": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"service": "fleets.access",
|
||||
@@ -57,6 +57,23 @@
|
||||
"service": "docker.events",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "host-metrics.viewers",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "wake-on-lan.send",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"service": "automations.access",
|
||||
"version": "1.0.0",
|
||||
"permission": "automations.view"
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
@@ -102,5 +119,8 @@
|
||||
]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
"dependencies": {
|
||||
"snippets": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
-- automations 0001: adopt_automations
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
|
||||
-- (links between these tables, and a channel link to notification_channels).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `automations` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`enabled` boolean NOT NULL DEFAULT true,
|
||||
`definition` text NOT NULL,
|
||||
`definition_version` int NOT NULL DEFAULT 1,
|
||||
`concurrency_policy` text NOT NULL DEFAULT ('skip'),
|
||||
`max_run_seconds` int NOT NULL DEFAULT 300,
|
||||
`dry_run` boolean NOT NULL DEFAULT false,
|
||||
`last_run_at` text,
|
||||
`last_run_status` text,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `automations` TO `p_automations_automations`;
|
||||
CREATE TABLE IF NOT EXISTS `automation_trigger_state` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`automation_id` int NOT NULL,
|
||||
`state_key` varchar(255) NOT NULL,
|
||||
`breach_started_at` text,
|
||||
`last_fired_at` text,
|
||||
`last_value` double,
|
||||
`last_observed_state` text,
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `automation_trigger_state` TO `p_automations_trigger_state`;
|
||||
CREATE TABLE IF NOT EXISTS `automation_schedules` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`automation_id` int NOT NULL,
|
||||
`cron` text,
|
||||
`interval_seconds` int,
|
||||
`timezone` text,
|
||||
`next_due_at` varchar(255),
|
||||
`last_tick_at` text,
|
||||
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `automation_schedules` TO `p_automations_schedules`;
|
||||
CREATE TABLE IF NOT EXISTS `automation_runs` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`automation_id` int NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`trigger_type` text NOT NULL,
|
||||
`trigger_context` text,
|
||||
`status` text NOT NULL,
|
||||
`started_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`finished_at` text,
|
||||
`duration_ms` int,
|
||||
`error` text,
|
||||
`dry_run` boolean NOT NULL DEFAULT false,
|
||||
`parent_run_id` int,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `automation_runs` TO `p_automations_runs`;
|
||||
CREATE TABLE IF NOT EXISTS `automation_run_steps` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`run_id` int NOT NULL,
|
||||
`step_index` int NOT NULL,
|
||||
`step_id` text NOT NULL,
|
||||
`step_type` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`started_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`finished_at` text,
|
||||
`output` text,
|
||||
`error` text,
|
||||
`truncated` boolean NOT NULL DEFAULT false,
|
||||
FOREIGN KEY (`run_id`) REFERENCES `p_automations_runs` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `automation_run_steps` TO `p_automations_run_steps`;
|
||||
CREATE TABLE IF NOT EXISTS `automation_channels` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`automation_id` int NOT NULL,
|
||||
`channel_id` int NOT NULL,
|
||||
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`channel_id`) REFERENCES `notification_channels` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `automation_channels` TO `p_automations_channels`;
|
||||
@@ -0,0 +1,94 @@
|
||||
-- automations 0001: adopt_automations
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
|
||||
-- (links between these tables, and a channel link to notification_channels).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "automations" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"definition" text NOT NULL,
|
||||
"definition_version" integer NOT NULL DEFAULT 1,
|
||||
"concurrency_policy" text NOT NULL DEFAULT 'skip',
|
||||
"max_run_seconds" integer NOT NULL DEFAULT 300,
|
||||
"dry_run" boolean NOT NULL DEFAULT false,
|
||||
"last_run_at" text,
|
||||
"last_run_status" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_automations_user" ON "automations" ("user_id", "enabled");
|
||||
ALTER TABLE "automations" RENAME TO "p_automations_automations";
|
||||
CREATE TABLE IF NOT EXISTS "automation_trigger_state" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"automation_id" integer NOT NULL,
|
||||
"state_key" varchar(255) NOT NULL,
|
||||
"breach_started_at" text,
|
||||
"last_fired_at" text,
|
||||
"last_value" double precision,
|
||||
"last_observed_state" text,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_trigger_state_key" ON "automation_trigger_state" ("automation_id", "state_key");
|
||||
ALTER TABLE "automation_trigger_state" RENAME TO "p_automations_trigger_state";
|
||||
CREATE TABLE IF NOT EXISTS "automation_schedules" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"automation_id" integer NOT NULL,
|
||||
"cron" text,
|
||||
"interval_seconds" integer,
|
||||
"timezone" text,
|
||||
"next_due_at" varchar(255),
|
||||
"last_tick_at" text,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_schedules_automation" ON "automation_schedules" ("automation_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_automation_schedules_due" ON "automation_schedules" ("next_due_at");
|
||||
ALTER TABLE "automation_schedules" RENAME TO "p_automations_schedules";
|
||||
CREATE TABLE IF NOT EXISTS "automation_runs" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"automation_id" integer NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"trigger_type" text NOT NULL,
|
||||
"trigger_context" text,
|
||||
"status" text NOT NULL,
|
||||
"started_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" text,
|
||||
"duration_ms" integer,
|
||||
"error" text,
|
||||
"dry_run" boolean NOT NULL DEFAULT false,
|
||||
"parent_run_id" integer,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_automation_runs_automation" ON "automation_runs" ("automation_id", "started_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_automation_runs_user" ON "automation_runs" ("user_id", "started_at");
|
||||
ALTER TABLE "automation_runs" RENAME TO "p_automations_runs";
|
||||
CREATE TABLE IF NOT EXISTS "automation_run_steps" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"run_id" integer NOT NULL,
|
||||
"step_index" integer NOT NULL,
|
||||
"step_id" text NOT NULL,
|
||||
"step_type" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" text,
|
||||
"output" text,
|
||||
"error" text,
|
||||
"truncated" boolean NOT NULL DEFAULT false,
|
||||
FOREIGN KEY ("run_id") REFERENCES "p_automations_runs" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_automation_run_steps_run" ON "automation_run_steps" ("run_id", "step_index");
|
||||
ALTER TABLE "automation_run_steps" RENAME TO "p_automations_run_steps";
|
||||
CREATE TABLE IF NOT EXISTS "automation_channels" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"automation_id" integer NOT NULL,
|
||||
"channel_id" integer NOT NULL,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("channel_id") REFERENCES "notification_channels" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
|
||||
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
|
||||
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"automations": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": true
|
||||
},
|
||||
"definition": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"definitionVersion": {
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": 1
|
||||
},
|
||||
"concurrencyPolicy": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "skip"
|
||||
},
|
||||
"maxRunSeconds": {
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": 300
|
||||
},
|
||||
"dryRun": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"lastRunAt": {
|
||||
"type": "text"
|
||||
},
|
||||
"lastRunStatus": {
|
||||
"type": "text"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_automations_user",
|
||||
"columns": ["userId", "enabled"]
|
||||
}
|
||||
],
|
||||
"adopts": "automations"
|
||||
},
|
||||
"trigger_state": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"automationId": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"stateKey": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"breachStartedAt": {
|
||||
"type": "text"
|
||||
},
|
||||
"lastFiredAt": {
|
||||
"type": "text"
|
||||
},
|
||||
"lastValue": {
|
||||
"type": "real"
|
||||
},
|
||||
"lastObservedState": {
|
||||
"type": "text"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_automation_trigger_state_key",
|
||||
"columns": ["automationId", "stateKey"],
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"adopts": "automation_trigger_state"
|
||||
},
|
||||
"schedules": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"automationId": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"cron": {
|
||||
"type": "text"
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "text"
|
||||
},
|
||||
"nextDueAt": {
|
||||
"type": "timestamp"
|
||||
},
|
||||
"lastTickAt": {
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_automation_schedules_automation",
|
||||
"columns": ["automationId"],
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"name": "idx_automation_schedules_due",
|
||||
"columns": ["nextDueAt"]
|
||||
}
|
||||
],
|
||||
"adopts": "automation_schedules"
|
||||
},
|
||||
"runs": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"automationId": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"triggerType": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"triggerContext": {
|
||||
"type": "text"
|
||||
},
|
||||
"status": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"startedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"finishedAt": {
|
||||
"type": "text"
|
||||
},
|
||||
"durationMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"error": {
|
||||
"type": "text"
|
||||
},
|
||||
"dryRun": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"parentRunId": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_automation_runs_automation",
|
||||
"columns": ["automationId", "startedAt"]
|
||||
},
|
||||
{
|
||||
"name": "idx_automation_runs_user",
|
||||
"columns": ["userId", "startedAt"]
|
||||
}
|
||||
],
|
||||
"adopts": "automation_runs"
|
||||
},
|
||||
"run_steps": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"runId": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"stepIndex": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"stepId": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"stepType": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"startedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"finishedAt": {
|
||||
"type": "text"
|
||||
},
|
||||
"output": {
|
||||
"type": "text"
|
||||
},
|
||||
"error": {
|
||||
"type": "text"
|
||||
},
|
||||
"truncated": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_automation_run_steps_run",
|
||||
"columns": ["runId", "stepIndex"]
|
||||
}
|
||||
],
|
||||
"adopts": "automation_run_steps"
|
||||
},
|
||||
"channels": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"automationId": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"channelId": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_automation_channels_pair",
|
||||
"columns": ["automationId", "channelId"],
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"adopts": "automation_channels"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
-- automations 0001: adopt_automations
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
|
||||
-- (links between these tables, and a channel link to notification_channels).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "automations" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"enabled" integer NOT NULL DEFAULT 1,
|
||||
"definition" text NOT NULL,
|
||||
"definition_version" integer NOT NULL DEFAULT 1,
|
||||
"concurrency_policy" text NOT NULL DEFAULT 'skip',
|
||||
"max_run_seconds" integer NOT NULL DEFAULT 300,
|
||||
"dry_run" integer NOT NULL DEFAULT 0,
|
||||
"last_run_at" text,
|
||||
"last_run_status" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_automations_user" ON "automations" ("user_id", "enabled");
|
||||
ALTER TABLE "automations" RENAME TO "p_automations_automations";
|
||||
CREATE TABLE IF NOT EXISTS "automation_trigger_state" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"automation_id" integer NOT NULL,
|
||||
"state_key" text NOT NULL,
|
||||
"breach_started_at" text,
|
||||
"last_fired_at" text,
|
||||
"last_value" real,
|
||||
"last_observed_state" text,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_trigger_state_key" ON "automation_trigger_state" ("automation_id", "state_key");
|
||||
ALTER TABLE "automation_trigger_state" RENAME TO "p_automations_trigger_state";
|
||||
CREATE TABLE IF NOT EXISTS "automation_schedules" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"automation_id" integer NOT NULL,
|
||||
"cron" text,
|
||||
"interval_seconds" integer,
|
||||
"timezone" text,
|
||||
"next_due_at" text,
|
||||
"last_tick_at" text,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_schedules_automation" ON "automation_schedules" ("automation_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_automation_schedules_due" ON "automation_schedules" ("next_due_at");
|
||||
ALTER TABLE "automation_schedules" RENAME TO "p_automations_schedules";
|
||||
CREATE TABLE IF NOT EXISTS "automation_runs" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"automation_id" integer NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"trigger_type" text NOT NULL,
|
||||
"trigger_context" text,
|
||||
"status" text NOT NULL,
|
||||
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" text,
|
||||
"duration_ms" integer,
|
||||
"error" text,
|
||||
"dry_run" integer NOT NULL DEFAULT 0,
|
||||
"parent_run_id" integer,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_automation_runs_automation" ON "automation_runs" ("automation_id", "started_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_automation_runs_user" ON "automation_runs" ("user_id", "started_at");
|
||||
ALTER TABLE "automation_runs" RENAME TO "p_automations_runs";
|
||||
CREATE TABLE IF NOT EXISTS "automation_run_steps" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"run_id" integer NOT NULL,
|
||||
"step_index" integer NOT NULL,
|
||||
"step_id" text NOT NULL,
|
||||
"step_type" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" text,
|
||||
"output" text,
|
||||
"error" text,
|
||||
"truncated" integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY ("run_id") REFERENCES "p_automations_runs" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_automation_run_steps_run" ON "automation_run_steps" ("run_id", "step_index");
|
||||
ALTER TABLE "automation_run_steps" RENAME TO "p_automations_run_steps";
|
||||
CREATE TABLE IF NOT EXISTS "automation_channels" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"automation_id" integer NOT NULL,
|
||||
"channel_id" integer NOT NULL,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("channel_id") REFERENCES "notification_channels" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
|
||||
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
|
||||
@@ -1,39 +1,45 @@
|
||||
import type { HostSelector } from "../../../../../src/types/automations.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js";
|
||||
import { fleetMemberIds } from "../snippets.js";
|
||||
import type { StepExecutionContext } from "./types.js";
|
||||
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import type { HostSelector } from "../../types.js";
|
||||
import type { StepExecutionContext, StepRuntime } from "./types.js";
|
||||
|
||||
/** A host the caller is allowed to act on. `host` is always resolved. */
|
||||
/** A host the owner may act on, resolved through core's connect pipeline. */
|
||||
export interface ResolvedTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
host: NonNullable<Awaited<ReturnType<typeof resolveHostById>>>;
|
||||
host: PluginSshHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a selector into the hosts a step may actually act on.
|
||||
*
|
||||
* Access is checked here, at execution time rather than when the automation
|
||||
* was saved, so a permission revoked after the fact takes effect on the next
|
||||
* run. resolveHostById performs its own connect-level check and returns null
|
||||
* when the owner can no longer reach the host.
|
||||
* Access is checked at execution time rather than when the automation was
|
||||
* saved, so a permission revoked after the fact takes effect on the next run.
|
||||
* resolveHost runs as the automation's owner and answers null when they can
|
||||
* no longer reach the host.
|
||||
*/
|
||||
export async function resolveTargets(
|
||||
selector: HostSelector,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<{ targets: ResolvedTarget[]; skipped: number[] }> {
|
||||
const ids = await selectorHostIds(selector, context);
|
||||
const ids = await selectorHostIds(selector, context, runtime);
|
||||
const targets: ResolvedTarget[] = [];
|
||||
const skipped: number[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const host = await resolveHostById(id, context.userId);
|
||||
let host: PluginSshHost | null = null;
|
||||
try {
|
||||
host = await runtime.ctx.ssh.resolveHost(id);
|
||||
} catch {
|
||||
host = null;
|
||||
}
|
||||
if (!host) {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
}
|
||||
targets.push({ id, name: host.name || host.ip, host });
|
||||
const name =
|
||||
typeof host.name === "string" && host.name ? host.name : host.ip;
|
||||
targets.push({ id, name, host });
|
||||
}
|
||||
|
||||
return { targets, skipped };
|
||||
@@ -42,6 +48,7 @@ export async function resolveTargets(
|
||||
async function selectorHostIds(
|
||||
selector: HostSelector,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<number[]> {
|
||||
switch (selector.kind) {
|
||||
case "host":
|
||||
@@ -50,35 +57,29 @@ async function selectorHostIds(
|
||||
return selector.hostIds;
|
||||
case "trigger":
|
||||
return context.triggerHostId ? [context.triggerHostId] : [];
|
||||
case "fleet":
|
||||
return fleetHostIds(selector.fleetId, context.userId);
|
||||
case "all":
|
||||
return allAccessibleHostIds(context.userId);
|
||||
case "fleet": {
|
||||
const fleets = runtime.deps.fleets();
|
||||
if (typeof fleets.members !== "function") return [];
|
||||
try {
|
||||
const members = await fleets.members(selector.fleetId);
|
||||
return members.map((member) => member.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
case "all": {
|
||||
try {
|
||||
const hosts = await runtime.ctx.hosts.list();
|
||||
// The owner's own hosts, as before: "all" never fans out onto hosts
|
||||
// someone else shared with them.
|
||||
return hosts
|
||||
.filter((host) => host.userId === context.userId)
|
||||
.map((host) => host.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fleetHostIds(
|
||||
fleetId: number,
|
||||
userId: string,
|
||||
): Promise<number[]> {
|
||||
return fleetMemberIds(userId, fleetId);
|
||||
}
|
||||
|
||||
async function allAccessibleHostIds(userId: string): Promise<number[]> {
|
||||
try {
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../../../../src/backend/database/repositories/factory.js");
|
||||
const hosts = await createCurrentHostRepository().listByUserId(userId);
|
||||
const ids = hosts.map((host) => host.id);
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
ids,
|
||||
);
|
||||
return ids.filter((id) => allowed.has(id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
import {
|
||||
DEFAULT_STEP_TIMEOUT_MS,
|
||||
type Step,
|
||||
} from "../../../../../src/types/automations.js";
|
||||
import type { Client } from "ssh2";
|
||||
import { DEFAULT_STEP_TIMEOUT_MS, type Step } from "../../types.js";
|
||||
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
|
||||
import { runDockerAction } from "../docker.js";
|
||||
import { runTunnelAction } from "../tunnels.js";
|
||||
import { sendAutomationNotification } from "../notify.js";
|
||||
import { automationFetch } from "../http.js";
|
||||
import { renderRecord, renderTemplate } from "../template.js";
|
||||
import { resolveTargets, type ResolvedTarget } from "./host-targets.js";
|
||||
import {
|
||||
@@ -18,8 +9,14 @@ import {
|
||||
stepTimeout,
|
||||
type StepExecutionContext,
|
||||
type StepResult,
|
||||
type StepRuntime,
|
||||
} from "./types.js";
|
||||
|
||||
/** The admin allowlist of private hosts an HTTP step may reach. */
|
||||
const PRIVATE_ALLOWLIST_KEY = "notification_private_endpoint_allowlist";
|
||||
const errorText = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
/**
|
||||
* One executor per step type.
|
||||
*
|
||||
@@ -30,22 +27,23 @@ import {
|
||||
export async function executeStep(
|
||||
step: Step,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<StepResult> {
|
||||
switch (step.type) {
|
||||
case "notify":
|
||||
return runNotify(step, context);
|
||||
return runNotify(step, context, runtime);
|
||||
case "http":
|
||||
return runHttp(step, context);
|
||||
return runHttp(step, context, runtime);
|
||||
case "run_command":
|
||||
return runCommand(step, context);
|
||||
return runCommand(step, context, runtime);
|
||||
case "run_snippet":
|
||||
return runSnippet(step, context);
|
||||
return runSnippet(step, context, runtime);
|
||||
case "docker":
|
||||
return runDocker(step, context);
|
||||
return runDocker(step, context, runtime);
|
||||
case "tunnel":
|
||||
return runTunnel(step, context);
|
||||
return runTunnel(step, context, runtime);
|
||||
case "wol":
|
||||
return runWol(step, context);
|
||||
return runWol(step, context, runtime);
|
||||
case "wait":
|
||||
return runWait(step, context);
|
||||
case "set_var":
|
||||
@@ -64,6 +62,7 @@ export async function executeStep(
|
||||
async function runNotify(
|
||||
step: Extract<Step, { type: "notify" }>,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<StepResult> {
|
||||
const title = renderTemplate(step.title ?? "", context.template);
|
||||
const body = renderTemplate(step.body ?? "", context.template);
|
||||
@@ -77,49 +76,45 @@ async function runNotify(
|
||||
);
|
||||
}
|
||||
|
||||
const repository = createCurrentNotificationChannelRepository();
|
||||
const channels = await repository.listNotificationChannels(context.userId);
|
||||
const selected = channels.filter((channel) =>
|
||||
step.channelIds.includes(channel.id),
|
||||
);
|
||||
|
||||
if (selected.length === 0) {
|
||||
return fail("Selected notification channels no longer exist");
|
||||
const trigger = context.template.trigger ?? {};
|
||||
let result;
|
||||
try {
|
||||
result = await runtime.ctx.notify.send(step.channelIds, {
|
||||
title: title || "Termix automation",
|
||||
body,
|
||||
severity: step.severity ?? "warning",
|
||||
context: {
|
||||
hostId:
|
||||
context.template.host?.id ??
|
||||
(typeof trigger.hostId === "number" ? trigger.hostId : undefined),
|
||||
hostName:
|
||||
context.template.host?.name ??
|
||||
(typeof trigger.hostName === "string" ? trigger.hostName : undefined),
|
||||
sourceId: context.automationId,
|
||||
sourceName: title || undefined,
|
||||
triggerType:
|
||||
typeof trigger.type === "string" ? trigger.type : undefined,
|
||||
value: trigger.value,
|
||||
threshold: trigger.threshold,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(errorText(error));
|
||||
}
|
||||
|
||||
let delivered = 0;
|
||||
const errors: string[] = [];
|
||||
for (const channel of selected) {
|
||||
if (!channel.enabled) continue;
|
||||
try {
|
||||
await sendAutomationNotification(
|
||||
{ id: channel.id, type: channel.type, config: channel.config },
|
||||
{
|
||||
title,
|
||||
body,
|
||||
severity: step.severity ?? "warning",
|
||||
context: context.template,
|
||||
},
|
||||
);
|
||||
delivered++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`${channel.name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (delivered === 0) {
|
||||
const errors = result.failures.map((f) => `${f.name}: ${f.error}`);
|
||||
if (result.delivered === 0) {
|
||||
return fail(errors.join("; ") || "No enabled channels to notify");
|
||||
}
|
||||
return ok(
|
||||
`Notified ${delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
|
||||
`Notified ${result.delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runHttp(
|
||||
step: Extract<Step, { type: "http" }>,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<StepResult> {
|
||||
const url = renderTemplate(step.url, context.template);
|
||||
const headers = renderRecord(step.headers, context.template);
|
||||
@@ -132,11 +127,20 @@ async function runHttp(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await automationFetch(url, {
|
||||
// Private delivery needs both the step's opt-in and an exact host in
|
||||
// the admin allowlist.
|
||||
const allowPrivateHosts = step.allowPrivateNetwork
|
||||
? parseAllowlist(
|
||||
await runtime.ctx.settings.readCore(PRIVATE_ALLOWLIST_KEY),
|
||||
)
|
||||
: [];
|
||||
const response = await runtime.ctx.fetch(url, {
|
||||
method: step.method,
|
||||
headers,
|
||||
headers: body
|
||||
? { "Content-Type": "application/json", ...(headers ?? {}) }
|
||||
: headers,
|
||||
body,
|
||||
allowPrivateNetwork: step.allowPrivateNetwork,
|
||||
allowPrivateHosts,
|
||||
timeoutMs: stepTimeout(context, step.timeoutMs, DEFAULT_STEP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
@@ -144,16 +148,31 @@ async function runHttp(
|
||||
const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
|
||||
return response.ok ? ok(summary) : fail(`HTTP ${response.status}`, summary);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : String(error));
|
||||
return fail(errorText(error));
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowlist(raw: unknown): string[] {
|
||||
if (typeof raw !== "string" || !raw) return [];
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
step: Extract<Step, { type: "run_command" }>,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<StepResult> {
|
||||
const command = renderTemplate(step.command, context.template);
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
return runOnTargets(step.hostSelector, context, runtime, async (target) => {
|
||||
if (context.dryRun) {
|
||||
return { output: `Would run on ${target.name}: ${command}` };
|
||||
}
|
||||
@@ -162,6 +181,7 @@ async function runCommand(
|
||||
command,
|
||||
step.elevated,
|
||||
context,
|
||||
runtime,
|
||||
step.timeoutMs,
|
||||
);
|
||||
});
|
||||
@@ -170,10 +190,20 @@ async function runCommand(
|
||||
async function runSnippet(
|
||||
step: Extract<Step, { type: "run_snippet" }>,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<StepResult> {
|
||||
const snippet = await getSnippet(context.userId, step.snippetId);
|
||||
const snippets = runtime.deps.snippets();
|
||||
const getSnippet = snippets.get;
|
||||
const resolveCommand = snippets.resolveCommand;
|
||||
if (
|
||||
typeof getSnippet !== "function" ||
|
||||
typeof resolveCommand !== "function"
|
||||
) {
|
||||
return fail("The snippets plugin is not available");
|
||||
}
|
||||
const snippet = await getSnippet(step.snippetId).catch(() => null);
|
||||
|
||||
if (!snippet) return fail("Snippet not found, or the snippets plugin is off");
|
||||
if (!snippet) return fail("Snippet not found");
|
||||
if (snippet.isNote) return fail("Notes cannot be executed on a host");
|
||||
|
||||
// Template values only ever reach the snippet through inputValues, never by
|
||||
@@ -181,18 +211,17 @@ async function runSnippet(
|
||||
// syntax of their own.
|
||||
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
|
||||
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
const command = await resolveSnippetCommandFor(
|
||||
context.userId,
|
||||
return runOnTargets(step.hostSelector, context, runtime, async (target) => {
|
||||
const command = await resolveCommand(
|
||||
step.snippetId,
|
||||
{
|
||||
ip: target.host.ip,
|
||||
username: target.host.username,
|
||||
port: target.host.port,
|
||||
name: target.host.name,
|
||||
name: target.name,
|
||||
},
|
||||
inputValues,
|
||||
);
|
||||
).catch(() => null);
|
||||
if (!command) {
|
||||
return { output: "", error: "Snippet could not be resolved" };
|
||||
}
|
||||
@@ -205,6 +234,7 @@ async function runSnippet(
|
||||
command,
|
||||
step.elevated,
|
||||
context,
|
||||
runtime,
|
||||
step.timeoutMs,
|
||||
);
|
||||
});
|
||||
@@ -213,40 +243,51 @@ async function runSnippet(
|
||||
async function runDocker(
|
||||
step: Extract<Step, { type: "docker" }>,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<StepResult> {
|
||||
const container = renderTemplate(step.container, context.template);
|
||||
if (!container) return fail("Container name is required");
|
||||
const action = runtime.deps.docker().action;
|
||||
if (typeof action !== "function") {
|
||||
return fail("The docker plugin is not available");
|
||||
}
|
||||
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
return runOnTargets(step.hostSelector, context, runtime, async (target) => {
|
||||
if (context.dryRun) {
|
||||
return {
|
||||
output: `Would ${step.action} container ${container} on ${target.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await runDockerAction(
|
||||
context.userId,
|
||||
target.id,
|
||||
container,
|
||||
step.action,
|
||||
);
|
||||
return result.ok
|
||||
? { output: `Container ${container}: ${step.action} done` }
|
||||
: { output: "", error: result.error };
|
||||
try {
|
||||
await action(target.id, container, step.action);
|
||||
return { output: `Container ${container}: ${step.action} done` };
|
||||
} catch (error) {
|
||||
return { output: "", error: errorText(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runTunnel(
|
||||
step: Extract<Step, { type: "tunnel" }>,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<StepResult> {
|
||||
const name = renderTemplate(step.tunnelName, context.template);
|
||||
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
|
||||
|
||||
const tunnels = runtime.deps.tunnels();
|
||||
const act = step.action === "connect" ? tunnels.start : tunnels.stop;
|
||||
if (typeof act !== "function") {
|
||||
return fail("The tunnels plugin is not available");
|
||||
}
|
||||
// A disconnect goes through the tunnels plugin's manual stop, which also
|
||||
// holds off its retries, so the tunnel stays down.
|
||||
const result = await runTunnelAction(context.userId, step.action, name);
|
||||
if (!result.ok) return fail(result.error);
|
||||
try {
|
||||
await act(name);
|
||||
} catch (error) {
|
||||
return fail(errorText(error));
|
||||
}
|
||||
return ok(
|
||||
step.action === "connect"
|
||||
? `Tunnel ${name} connected`
|
||||
@@ -257,26 +298,23 @@ async function runTunnel(
|
||||
async function runWol(
|
||||
step: Extract<Step, { type: "wol" }>,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
): Promise<StepResult> {
|
||||
const host = await resolveTargets(
|
||||
{ kind: "host", hostId: step.hostId },
|
||||
context,
|
||||
);
|
||||
const target = host.targets[0];
|
||||
if (!target?.host) return fail("Host not found or not accessible");
|
||||
const host = await runtime.ctx.hosts.get(step.hostId).catch(() => null);
|
||||
if (!host) return fail("Host not found or not accessible");
|
||||
const name = host.name || host.ip;
|
||||
|
||||
const mac = (target.host as { macAddress?: string }).macAddress;
|
||||
if (!mac) return fail("Host has no MAC address configured");
|
||||
if (context.dryRun) return ok(`Would wake ${target.name} (${mac})`);
|
||||
const wake = runtime.deps.wakeOnLan().wake;
|
||||
if (typeof wake !== "function") {
|
||||
return fail("The wake-on-lan plugin is not available");
|
||||
}
|
||||
if (context.dryRun) return ok(`Would wake ${name}`);
|
||||
|
||||
try {
|
||||
const { sendWakeOnLan, isValidMac } =
|
||||
await import("../../../../../src/backend/utils/wake-on-lan.js");
|
||||
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
|
||||
await sendWakeOnLan(mac);
|
||||
return ok(`Sent magic packet to ${target.name}`);
|
||||
await wake(step.hostId);
|
||||
return ok(`Sent magic packet to ${name}`);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : String(error));
|
||||
return fail(errorText(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,9 +348,10 @@ async function runSetVar(
|
||||
async function runOnTargets(
|
||||
selector: Extract<Step, { type: "run_command" }>["hostSelector"],
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>,
|
||||
): Promise<StepResult> {
|
||||
const { targets, skipped } = await resolveTargets(selector, context);
|
||||
const { targets, skipped } = await resolveTargets(selector, context, runtime);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return fail(
|
||||
@@ -363,25 +402,27 @@ async function execOnHost(
|
||||
command: string,
|
||||
elevated: boolean | undefined,
|
||||
context: StepExecutionContext,
|
||||
runtime: StepRuntime,
|
||||
timeoutMs: number | undefined,
|
||||
): Promise<{ output?: string; error?: string }> {
|
||||
const sshHost = host as ResolvedTarget["host"] & { sudoPassword?: string };
|
||||
const timeout = stepTimeout(context, timeoutMs, DEFAULT_STEP_TIMEOUT_MS);
|
||||
if (timeout <= 0) return { error: "Run deadline exceeded" };
|
||||
|
||||
try {
|
||||
const result = await withSshConnection(
|
||||
sshHost as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
async (client) => {
|
||||
if (elevated) {
|
||||
return execElevated(client, command, sshHost.sudoPassword, {
|
||||
timeoutMs: timeout,
|
||||
});
|
||||
}
|
||||
return execCommand(client, command, timeout);
|
||||
},
|
||||
);
|
||||
const result = await runtime.ctx.ssh.withConnection<
|
||||
{ stdout: string; stderr: string; code: number | null },
|
||||
Client
|
||||
>(host, { pool: "automations", purpose: "fleet" }, async (client) => {
|
||||
if (elevated) {
|
||||
return execElevated(
|
||||
client,
|
||||
command,
|
||||
host.sudoPassword as string | undefined,
|
||||
{ timeoutMs: timeout },
|
||||
);
|
||||
}
|
||||
return execCommand(client, command, timeout);
|
||||
});
|
||||
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
if (result.code === 0 || result.code === null) {
|
||||
@@ -389,6 +430,6 @@ async function execOnHost(
|
||||
}
|
||||
return { error: `Exited with code ${result.code}`, output };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
return { error: errorText(error) };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Step } from "../../../../../src/types/automations.js";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { Step } from "../../types.js";
|
||||
import type { Deps } from "../deps.js";
|
||||
import type { TemplateContext } from "../template.js";
|
||||
|
||||
/** What every executor returns. `output` is what later steps can read. */
|
||||
@@ -12,6 +14,12 @@ export interface StepResult {
|
||||
halt?: { status: "success" | "failed" };
|
||||
}
|
||||
|
||||
/** What the executors reach the rest of Termix through. */
|
||||
export interface StepRuntime {
|
||||
ctx: PluginContext;
|
||||
deps: Deps;
|
||||
}
|
||||
|
||||
export interface StepExecutionContext {
|
||||
userId: string;
|
||||
automationId: number;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
MetricPath,
|
||||
Operator,
|
||||
} from "../../../../src/types/automations.js";
|
||||
import type { MetricPath, Operator } from "../types.js";
|
||||
|
||||
/**
|
||||
* Operator evaluation and metric extraction.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
import type { DockerEventKind } from "../types.js";
|
||||
|
||||
/*
|
||||
* Other plugins, as automations reaches them. Every call runs as the current
|
||||
* actor (the automation's owner, set by the engine with ctx.asUser). A
|
||||
* missing provider answers an empty handle, so each method is probed before
|
||||
* it is called.
|
||||
*/
|
||||
|
||||
export interface SnippetsAccessV1 {
|
||||
list?: () => Promise<Array<{ id: number; name: string; isNote?: boolean }>>;
|
||||
get: (id: number) => Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
isNote: boolean;
|
||||
} | null>;
|
||||
resolveCommand: (
|
||||
id: number,
|
||||
vars: { ip?: string; username?: string; port?: number; name?: string },
|
||||
inputValues?: Record<string, string>,
|
||||
) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface FleetsAccessV1 {
|
||||
list: () => Promise<Array<{ id: number; name: string }>>;
|
||||
members: (
|
||||
fleetId: number,
|
||||
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
|
||||
}
|
||||
|
||||
export interface TunnelsAccessV1 {
|
||||
start: (name: string) => Promise<void>;
|
||||
stop: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DockerContainersV1 {
|
||||
action: (
|
||||
hostId: number,
|
||||
container: string,
|
||||
action: "start" | "stop" | "restart",
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DockerEvent {
|
||||
hostId: number;
|
||||
container: string;
|
||||
event: DockerEventKind;
|
||||
}
|
||||
|
||||
export interface DockerEventsV1 {
|
||||
subscribe: (
|
||||
hostId: number,
|
||||
listener: (event: DockerEvent) => void,
|
||||
) => Promise<() => void>;
|
||||
}
|
||||
|
||||
export interface MetricsViewersV1 {
|
||||
register: (
|
||||
hostId: number,
|
||||
) => Promise<{ viewerSessionId: string } | { skipped: true; reason: string }>;
|
||||
heartbeat: (viewerSessionId: string) => boolean;
|
||||
unregister: (hostId: number, viewerSessionId: string) => void;
|
||||
}
|
||||
|
||||
/** Provided by the wake-on-lan plugin. It resolves the host's MAC itself. */
|
||||
export interface WakeOnLanV1 {
|
||||
wake: (hostId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/** The plugin id behind each service, for "needs <plugin>" messages. */
|
||||
export const PROVIDERS = {
|
||||
snippets: "snippets.access",
|
||||
fleets: "fleets.access",
|
||||
tunnels: "tunnels.access",
|
||||
docker: "docker.containers",
|
||||
"docker-events": "docker.events",
|
||||
"host-metrics": "host-metrics.viewers",
|
||||
"wake-on-lan": "wake-on-lan.send",
|
||||
} as const;
|
||||
|
||||
export type ProviderKey = keyof typeof PROVIDERS;
|
||||
|
||||
/** The plugin a provider key belongs to. */
|
||||
export function pluginOf(key: ProviderKey): string {
|
||||
return key === "docker-events" ? "docker" : key;
|
||||
}
|
||||
|
||||
export type Deps = ReturnType<typeof createDeps>;
|
||||
|
||||
export function createDeps(services: PluginServices) {
|
||||
function handle<T extends object>(name: string): Partial<T> {
|
||||
try {
|
||||
return services.get<T>(name) as Partial<T>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Which providers are running now. */
|
||||
available(key: ProviderKey): boolean {
|
||||
return services.providers(PROVIDERS[key]).length > 0;
|
||||
},
|
||||
providers(): Record<ProviderKey, boolean> {
|
||||
const result = {} as Record<ProviderKey, boolean>;
|
||||
for (const key of Object.keys(PROVIDERS) as ProviderKey[]) {
|
||||
result[key] = services.providers(PROVIDERS[key]).length > 0;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
snippets: () => handle<SnippetsAccessV1>(PROVIDERS.snippets),
|
||||
fleets: () => handle<FleetsAccessV1>(PROVIDERS.fleets),
|
||||
tunnels: () => handle<TunnelsAccessV1>(PROVIDERS.tunnels),
|
||||
docker: () => handle<DockerContainersV1>(PROVIDERS.docker),
|
||||
dockerEvents: () => handle<DockerEventsV1>(PROVIDERS["docker-events"]),
|
||||
viewers: () => handle<MetricsViewersV1>(PROVIDERS["host-metrics"]),
|
||||
wakeOnLan: () => handle<WakeOnLanV1>(PROVIDERS["wake-on-lan"]),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AutomationDefinition } from "../../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { subscribeDockerEvents, type DockerEvent } from "./docker.js";
|
||||
import { onDockerEvent } from "./triggers.js";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { Deps, DockerEvent } from "./deps.js";
|
||||
import type { DockerEvent as TriggerDockerEvent } from "./triggers.js";
|
||||
|
||||
/**
|
||||
* Keeps a docker.events subscription for every host a docker_event trigger
|
||||
@@ -21,95 +20,71 @@ interface Watch {
|
||||
subscribedAt: number;
|
||||
}
|
||||
|
||||
const watches = new Map<string, Watch>();
|
||||
export function createDockerWatcher(
|
||||
ctx: Pick<PluginContext, "asUser">,
|
||||
deps: Pick<Deps, "dockerEvents">,
|
||||
watchedHosts: () => Promise<Map<number, string>>,
|
||||
onEvent: (event: TriggerDockerEvent) => Promise<void>,
|
||||
) {
|
||||
const watches = new Map<string, Watch>();
|
||||
const keyOf = (hostId: number, userId: string) => `${userId}:${hostId}`;
|
||||
|
||||
const keyOf = (hostId: number, userId: string) => `${userId}:${hostId}`;
|
||||
|
||||
/** Hosts named by an enabled docker_event trigger, with the owning user. */
|
||||
export async function listDockerWatchedHosts(): Promise<Map<number, string>> {
|
||||
const watched = new Map<number, string>();
|
||||
|
||||
try {
|
||||
const rows = await createCurrentAutomationRepository().listAllEnabled();
|
||||
for (const row of rows) {
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "docker_event") continue;
|
||||
|
||||
const selector = trigger.hostSelector;
|
||||
if (selector?.kind === "host") {
|
||||
watched.set(selector.hostId, row.userId);
|
||||
} else if (selector?.kind === "hosts") {
|
||||
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
|
||||
}
|
||||
// Fleet and "all" selectors are deliberately not expanded: watching
|
||||
// every host a user owns for container state is far too costly.
|
||||
}
|
||||
} catch {
|
||||
return watched;
|
||||
}
|
||||
|
||||
return watched;
|
||||
}
|
||||
|
||||
/** Brings the subscriptions in line with the watched hosts. */
|
||||
export async function reconcileDockerWatch(
|
||||
now: number = Date.now(),
|
||||
watched?: Map<number, string>,
|
||||
): Promise<void> {
|
||||
const wanted = watched ?? (await listDockerWatchedHosts());
|
||||
const wantedKeys = new Set(
|
||||
[...wanted].map(([hostId, userId]) => keyOf(hostId, userId)),
|
||||
);
|
||||
|
||||
for (const [key, watch] of watches) {
|
||||
if (wantedKeys.has(key)) continue;
|
||||
watch.unsubscribe?.();
|
||||
watches.delete(key);
|
||||
}
|
||||
|
||||
for (const [hostId, userId] of wanted) {
|
||||
const key = keyOf(hostId, userId);
|
||||
let watch = watches.get(key);
|
||||
if (!watch) {
|
||||
watch = {
|
||||
listener: (event) => {
|
||||
void onDockerEvent({
|
||||
hostId: event.hostId,
|
||||
ownerUserId: userId,
|
||||
container: event.container,
|
||||
event: event.event,
|
||||
}).catch(() => undefined);
|
||||
},
|
||||
unsubscribe: null,
|
||||
subscribedAt: 0,
|
||||
};
|
||||
watches.set(key, watch);
|
||||
}
|
||||
if (watch.subscribedAt && now - watch.subscribedAt < RESUBSCRIBE_MS) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
watch.unsubscribe = await subscribeDockerEvents(
|
||||
userId,
|
||||
hostId,
|
||||
watch.listener,
|
||||
return {
|
||||
/** Brings the subscriptions in line with the watched hosts. */
|
||||
async reconcile(now: number = Date.now()): Promise<void> {
|
||||
const wanted = await watchedHosts();
|
||||
const wantedKeys = new Set(
|
||||
[...wanted].map(([hostId, userId]) => keyOf(hostId, userId)),
|
||||
);
|
||||
watch.subscribedAt = watch.unsubscribe ? now : 0;
|
||||
} catch {
|
||||
watch.subscribedAt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops every subscription, for shutdown and tests. */
|
||||
export function resetDockerWatcher(): void {
|
||||
for (const watch of watches.values()) watch.unsubscribe?.();
|
||||
watches.clear();
|
||||
for (const [key, watch] of watches) {
|
||||
if (wantedKeys.has(key)) continue;
|
||||
watch.unsubscribe?.();
|
||||
watches.delete(key);
|
||||
}
|
||||
|
||||
for (const [hostId, userId] of wanted) {
|
||||
const key = keyOf(hostId, userId);
|
||||
let watch = watches.get(key);
|
||||
if (!watch) {
|
||||
watch = {
|
||||
listener: (event) => {
|
||||
void onEvent({
|
||||
hostId: event.hostId,
|
||||
ownerUserId: userId,
|
||||
container: event.container,
|
||||
event: event.event,
|
||||
}).catch(() => undefined);
|
||||
},
|
||||
unsubscribe: null,
|
||||
subscribedAt: 0,
|
||||
};
|
||||
watches.set(key, watch);
|
||||
}
|
||||
if (watch.subscribedAt && now - watch.subscribedAt < RESUBSCRIBE_MS) {
|
||||
continue;
|
||||
}
|
||||
const subscribe = deps.dockerEvents().subscribe;
|
||||
if (typeof subscribe !== "function") {
|
||||
watch.subscribedAt = 0;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const current = watch;
|
||||
watch.unsubscribe = await ctx.asUser(userId, () =>
|
||||
subscribe(hostId, current.listener),
|
||||
);
|
||||
watch.subscribedAt = now;
|
||||
} catch {
|
||||
watch.subscribedAt = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Drops every subscription, on deactivate. */
|
||||
reset(): void {
|
||||
for (const watch of watches.values()) watch.unsubscribe?.();
|
||||
watches.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
RunStatus,
|
||||
Step,
|
||||
} from "../../../../src/types/automations.js";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { AutomationDefinition, RunStatus, Step } from "../types.js";
|
||||
import {
|
||||
DEFAULT_MAX_RUN_SECONDS,
|
||||
MAX_AUTOMATION_DEPTH,
|
||||
MAX_STEP_OUTPUT_BYTES,
|
||||
} from "../../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js";
|
||||
} from "../types.js";
|
||||
import { executeStep } from "./actions/index.js";
|
||||
import type { StepExecutionContext, StepResult } from "./actions/types.js";
|
||||
import { compare } from "./conditions.js";
|
||||
import type { Deps } from "./deps.js";
|
||||
import type {
|
||||
AutomationEngineRow,
|
||||
AutomationRepository,
|
||||
} from "./repository.js";
|
||||
import { missingPlugins } from "./requirements.js";
|
||||
import { renderTemplate, type TemplateContext } from "./template.js";
|
||||
|
||||
/** Emitted when a run fails, so an automation_failed trigger can react. */
|
||||
export const TOPIC_AUTOMATION_FAILED = "plugin.automations.automation_failed";
|
||||
|
||||
export interface RunRequest {
|
||||
automationId: number;
|
||||
triggerType: string;
|
||||
@@ -36,38 +38,53 @@ export interface RunOutcome {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes automations.
|
||||
*
|
||||
* Both HTTP handlers and the metrics hooks live in this same process, so this
|
||||
* is a plain singleton rather than anything cross-process. State that has to
|
||||
* survive a restart (cooldowns, dwell windows) lives in the database; the only
|
||||
* thing held in memory is the set of runs currently in flight, which is
|
||||
* meaningless after a restart anyway.
|
||||
* Executes automations. One per activation. State that has to survive a
|
||||
* restart (cooldowns, dwell windows) lives in the database; the only thing
|
||||
* held in memory is the set of runs in flight, which is meaningless after a
|
||||
* restart anyway.
|
||||
*/
|
||||
export class AutomationEngine {
|
||||
private static instance: AutomationEngine;
|
||||
|
||||
private readonly running = new Set<number>();
|
||||
private readonly queued = new Map<number, number>();
|
||||
|
||||
static getInstance(): AutomationEngine {
|
||||
if (!AutomationEngine.instance) {
|
||||
AutomationEngine.instance = new AutomationEngine();
|
||||
}
|
||||
return AutomationEngine.instance;
|
||||
}
|
||||
constructor(
|
||||
private readonly ctx: PluginContext,
|
||||
private readonly repository: AutomationRepository,
|
||||
private readonly deps: Deps,
|
||||
) {}
|
||||
|
||||
isRunning(automationId: number): boolean {
|
||||
return this.running.has(automationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an automation as its owner. Never throws: a failure is the outcome.
|
||||
*/
|
||||
async run(request: RunRequest): Promise<RunOutcome> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const automation = await repository.findById(request.automationId);
|
||||
|
||||
let automation: AutomationEngineRow | null;
|
||||
try {
|
||||
automation = await this.repository.findById(request.automationId);
|
||||
} catch (err) {
|
||||
return { runId: null, status: "failed", error: errorText(err) };
|
||||
}
|
||||
if (!automation) {
|
||||
return { runId: null, status: "failed", error: "Automation not found" };
|
||||
}
|
||||
const owner = automation;
|
||||
try {
|
||||
return await this.ctx.asUser(owner.userId, () =>
|
||||
this.runAsOwner(owner, request),
|
||||
);
|
||||
} catch (err) {
|
||||
return { runId: null, status: "failed", error: errorText(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async runAsOwner(
|
||||
automation: AutomationEngineRow,
|
||||
request: RunRequest,
|
||||
): Promise<RunOutcome> {
|
||||
const repository = this.repository;
|
||||
|
||||
const depth = request.depth ?? 0;
|
||||
const ancestry = request.ancestry ?? [];
|
||||
@@ -99,6 +116,30 @@ export class AutomationEngine {
|
||||
};
|
||||
}
|
||||
|
||||
// A trigger or step whose plugin is off skips the whole run with the
|
||||
// reason on record, rather than running half of it.
|
||||
const missing = missingPlugins(definition, this.deps);
|
||||
if (missing.length > 0) {
|
||||
const reason = `Needs the ${missing.join(", ")} plugin${missing.length > 1 ? "s" : ""}`;
|
||||
this.ctx.log.info(
|
||||
`Skipping automation ${automation.id} (${request.triggerType}): ${reason}`,
|
||||
);
|
||||
const run = await repository.createRun({
|
||||
automationId: automation.id,
|
||||
userId: automation.userId,
|
||||
triggerType: request.triggerType,
|
||||
triggerContext: JSON.stringify(request.triggerContext ?? {}),
|
||||
status: "skipped",
|
||||
parentRunId: request.parentRunId ?? null,
|
||||
});
|
||||
await repository.finishRun(run.id, {
|
||||
status: "skipped",
|
||||
error: reason,
|
||||
durationMs: 0,
|
||||
});
|
||||
return { runId: run.id, status: "skipped", error: reason };
|
||||
}
|
||||
|
||||
// A second trigger while a run is in flight is recorded as skipped rather
|
||||
// than dropped silently, so the history explains what happened.
|
||||
//
|
||||
@@ -182,10 +223,7 @@ export class AutomationEngine {
|
||||
let host: TemplateContext["host"];
|
||||
if (request.triggerHostId) {
|
||||
try {
|
||||
const resolved = await resolveHostById(
|
||||
request.triggerHostId,
|
||||
automation.userId,
|
||||
);
|
||||
const resolved = await this.ctx.hosts.get(request.triggerHostId);
|
||||
if (resolved) {
|
||||
host = {
|
||||
id: request.triggerHostId,
|
||||
@@ -245,7 +283,7 @@ export class AutomationEngine {
|
||||
}
|
||||
} catch (err) {
|
||||
status = "failed";
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
error = errorText(err);
|
||||
} finally {
|
||||
this.running.delete(automation.id);
|
||||
}
|
||||
@@ -257,31 +295,21 @@ export class AutomationEngine {
|
||||
});
|
||||
|
||||
if (status === "failed") {
|
||||
statsLogger.warn(`Automation "${automation.name}" failed`, {
|
||||
operation: "automation_run_failed",
|
||||
automationId: automation.id,
|
||||
runId: run.id,
|
||||
error,
|
||||
});
|
||||
this.ctx.log.warn(
|
||||
`Automation "${automation.name}" (${automation.id}) failed in run ${run.id}: ${error ?? "unknown error"}`,
|
||||
);
|
||||
|
||||
// An automation_failed handler that itself fails must not re-announce
|
||||
// its own failure, so the event is not emitted for runs that this event
|
||||
// already started.
|
||||
if (request.triggerType !== "internal_event") {
|
||||
// Emitted onto the bus rather than imported from hosts/: the bus is
|
||||
// fire-and-forget, so this no longer needs the lazy-import dance that
|
||||
// was working around the repositories cycle.
|
||||
notifyAutomationInternalEvent(
|
||||
"automation_failed",
|
||||
automation.userId,
|
||||
undefined,
|
||||
{
|
||||
automationId: automation.id,
|
||||
automationName: automation.name,
|
||||
runId: run.id,
|
||||
error: error ?? null,
|
||||
},
|
||||
);
|
||||
this.ctx.events.emit(TOPIC_AUTOMATION_FAILED, {
|
||||
userId: automation.userId,
|
||||
automationId: automation.id,
|
||||
automationName: automation.name,
|
||||
runId: run.id,
|
||||
error: error ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +329,7 @@ export class AutomationEngine {
|
||||
error?: string;
|
||||
halted?: { status: "success" | "failed" };
|
||||
}> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const repository = this.repository;
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.enabled === false) continue;
|
||||
@@ -382,12 +410,12 @@ export class AutomationEngine {
|
||||
|
||||
let result: StepResult;
|
||||
try {
|
||||
result = await executeStep(step, context);
|
||||
result = await executeStep(step, context, {
|
||||
ctx: this.ctx,
|
||||
deps: this.deps,
|
||||
});
|
||||
} catch (err) {
|
||||
result = {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
result = { success: false, error: errorText(err) };
|
||||
}
|
||||
|
||||
const { text, truncated } = truncate(result.output);
|
||||
@@ -432,6 +460,10 @@ export class AutomationEngine {
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** Keeps a single step's output from bloating the database. */
|
||||
function truncate(output: string | undefined): {
|
||||
text: string | null;
|
||||
|
||||
@@ -1,105 +1,101 @@
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { listAutomationWatchedHosts } from "./triggers.js";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { Deps } from "./deps.js";
|
||||
|
||||
/**
|
||||
* Keeps metric collection running for hosts an automation watches.
|
||||
*
|
||||
* Heavy metric collection is normally started by a UI viewer and stops when
|
||||
* the last one leaves, which means threshold rules only ever evaluated while
|
||||
* somebody had the host open. Automations register a synthetic viewer instead
|
||||
* of bypassing that mechanism, so a real viewer arriving or leaving still
|
||||
* behaves exactly as before.
|
||||
*
|
||||
* The catch is `cleanupInactiveViewers`, which drops any viewer whose
|
||||
* heartbeat is older than 120s. Without the heartbeat below, headless polling
|
||||
* would quietly stop two minutes after it started.
|
||||
* Host metrics only polls hosts somebody is looking at. Automations register
|
||||
* as a viewer through the host-metrics.viewers service, as the automation's
|
||||
* owner, and heartbeat every tick so the viewer is not reaped. A heartbeat
|
||||
* that comes back false means host-metrics restarted and forgot the viewer,
|
||||
* so the next tick registers it again.
|
||||
*/
|
||||
|
||||
export interface ViewerRegistry {
|
||||
registerViewer(hostId: number, sessionId: string, userId: string): void;
|
||||
unregisterViewer(hostId: number, sessionId: string): void;
|
||||
updateHeartbeat(sessionId: string): boolean;
|
||||
interface Registered {
|
||||
userId: string;
|
||||
viewerSessionId: string;
|
||||
}
|
||||
|
||||
const SESSION_PREFIX = "automation:";
|
||||
|
||||
let registry: ViewerRegistry | null = null;
|
||||
const registered = new Map<number, string>();
|
||||
|
||||
export function setViewerRegistry(next: ViewerRegistry | null): void {
|
||||
registry = next;
|
||||
}
|
||||
|
||||
export function automationSessionId(hostId: number): string {
|
||||
return `${SESSION_PREFIX}${hostId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the set of synthetic viewers in line with what the enabled
|
||||
* automations currently watch, and heartbeats the ones that stay.
|
||||
*/
|
||||
export async function reconcileHeadlessViewers(): Promise<{
|
||||
added: number;
|
||||
removed: number;
|
||||
active: number;
|
||||
}> {
|
||||
if (!registry) return { added: 0, removed: 0, active: 0 };
|
||||
|
||||
let watched: Map<number, string>;
|
||||
try {
|
||||
watched = await listAutomationWatchedHosts();
|
||||
} catch {
|
||||
return { added: 0, removed: 0, active: registered.size };
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (const [hostId, userId] of watched) {
|
||||
const sessionId = automationSessionId(hostId);
|
||||
if (registered.has(hostId)) {
|
||||
// Refresh before the 120s reaper would take it.
|
||||
registry.updateHeartbeat(sessionId);
|
||||
continue;
|
||||
}
|
||||
export function createHeadlessViewers(
|
||||
ctx: Pick<PluginContext, "asUser" | "log">,
|
||||
deps: Pick<Deps, "viewers">,
|
||||
watchedHosts: () => Promise<Map<number, string>>,
|
||||
) {
|
||||
const registered = new Map<number, Registered>();
|
||||
|
||||
async function release(hostId: number, entry: Registered): Promise<void> {
|
||||
registered.delete(hostId);
|
||||
const unregister = deps.viewers().unregister;
|
||||
if (typeof unregister !== "function") return;
|
||||
try {
|
||||
registry.registerViewer(hostId, sessionId, userId);
|
||||
registered.set(hostId, userId);
|
||||
added++;
|
||||
} catch (error) {
|
||||
statsLogger.warn("Could not start headless metrics for a host", {
|
||||
operation: "automation_headless_register_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const hostId of [...registered.keys()]) {
|
||||
if (watched.has(hostId)) continue;
|
||||
try {
|
||||
registry.unregisterViewer(hostId, automationSessionId(hostId));
|
||||
await ctx.asUser(entry.userId, () =>
|
||||
unregister(hostId, entry.viewerSessionId),
|
||||
);
|
||||
} catch {
|
||||
// Already gone; drop it either way.
|
||||
}
|
||||
registered.delete(hostId);
|
||||
removed++;
|
||||
}
|
||||
|
||||
return { added, removed, active: registered.size };
|
||||
}
|
||||
|
||||
/** Drops every synthetic viewer, for shutdown and tests. */
|
||||
export function releaseHeadlessViewers(): void {
|
||||
if (registry) {
|
||||
for (const hostId of registered.keys()) {
|
||||
try {
|
||||
registry.unregisterViewer(hostId, automationSessionId(hostId));
|
||||
} catch {
|
||||
// Nothing useful to do during teardown.
|
||||
return {
|
||||
/** Brings the viewers in line with what the automations watch. */
|
||||
async reconcile(): Promise<{ added: number; removed: number }> {
|
||||
const viewers = deps.viewers();
|
||||
if (typeof viewers.register !== "function") {
|
||||
// host-metrics is off: its viewers went with it.
|
||||
registered.clear();
|
||||
return { added: 0, removed: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
registered.clear();
|
||||
|
||||
const watched = await watchedHosts();
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (const [hostId, entry] of [...registered]) {
|
||||
if (watched.get(hostId) === entry.userId) continue;
|
||||
await release(hostId, entry);
|
||||
removed++;
|
||||
}
|
||||
|
||||
for (const [hostId, userId] of watched) {
|
||||
const existing = registered.get(hostId);
|
||||
if (existing) {
|
||||
const alive = await ctx
|
||||
.asUser(userId, () => viewers.heartbeat!(existing.viewerSessionId))
|
||||
.catch(() => false);
|
||||
if (alive) continue;
|
||||
registered.delete(hostId);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.asUser(userId, () =>
|
||||
viewers.register!(hostId),
|
||||
);
|
||||
if ("viewerSessionId" in result) {
|
||||
registered.set(hostId, {
|
||||
userId,
|
||||
viewerSessionId: result.viewerSessionId,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Could not start headless metrics for host ${hostId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { added, removed };
|
||||
},
|
||||
|
||||
/** Drops every viewer, on deactivate. */
|
||||
async releaseAll(): Promise<void> {
|
||||
for (const [hostId, entry] of [...registered]) {
|
||||
await release(hostId, entry);
|
||||
}
|
||||
},
|
||||
|
||||
registeredHosts: () => [...registered.keys()],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,63 +1,61 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { setPluginServices } from "./snippets.js";
|
||||
import { setTunnelServices } from "./tunnels.js";
|
||||
import { setDockerServices } from "./docker.js";
|
||||
import { startAutomationsService, stopAutomationsService } from "./routes.js";
|
||||
import {
|
||||
startAutomationScheduler,
|
||||
stopAutomationScheduler,
|
||||
} from "./scheduler.js";
|
||||
import { onInternalEvent } from "./triggers.js";
|
||||
import { createDeps } from "./deps.js";
|
||||
import { createDockerWatcher } from "./docker-watcher.js";
|
||||
import { AutomationEngine } from "./engine.js";
|
||||
import { createHeadlessViewers } from "./headless-viewer.js";
|
||||
import { createAutomationRepository } from "./repository.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { createScheduler, STARTUP_DELAY_MS, TICK_MS } from "./scheduler.js";
|
||||
import { createAutomationsService } from "./service.js";
|
||||
import { createTriggers } from "./triggers.js";
|
||||
|
||||
/** What the tunnels plugin emits when a tunnel drops without being asked to. */
|
||||
interface TunnelDisconnectedEvent {
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
}
|
||||
export type { AutomationsAccessV1, AutomationSummary } from "./service.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
setPluginServices(ctx.services);
|
||||
ctx.disposables.add(() => setPluginServices(null));
|
||||
setTunnelServices(ctx.services);
|
||||
ctx.disposables.add(() => setTunnelServices(null));
|
||||
setDockerServices(ctx.services);
|
||||
ctx.disposables.add(() => setDockerServices(null));
|
||||
const repository = await createAutomationRepository(ctx.db, async () => {
|
||||
const channels = await ctx.notify.channels();
|
||||
return channels.map((channel) => channel.id);
|
||||
});
|
||||
const deps = createDeps(ctx.services);
|
||||
const engine = new AutomationEngine(ctx, repository, deps);
|
||||
const triggers = createTriggers(repository, engine, ctx.log);
|
||||
triggers.subscribe(ctx.events);
|
||||
|
||||
// Nothing arrives here while the tunnels plugin is off, which is the whole
|
||||
// of the optional dependency.
|
||||
ctx.events.on("plugin.tunnels.tunnel_disconnected", (payload) => {
|
||||
const event = payload as TunnelDisconnectedEvent;
|
||||
if (!event?.userId) return;
|
||||
void onInternalEvent({
|
||||
event: "tunnel_disconnected",
|
||||
userId: event.userId,
|
||||
hostId: event.hostId,
|
||||
details: { tunnelName: event.tunnelName },
|
||||
}).catch((error: unknown) => {
|
||||
ctx.log.warn(
|
||||
`Tunnel disconnect trigger failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
const viewers = createHeadlessViewers(ctx, deps, () =>
|
||||
triggers.watchedHosts("metric_threshold"),
|
||||
);
|
||||
const docker = createDockerWatcher(
|
||||
ctx,
|
||||
deps,
|
||||
() => triggers.watchedHosts("docker_event"),
|
||||
triggers.onDockerEvent,
|
||||
);
|
||||
ctx.disposables.add(() => {
|
||||
docker.reset();
|
||||
void viewers.releaseAll();
|
||||
});
|
||||
|
||||
startAutomationsService(
|
||||
const scheduler = createScheduler({
|
||||
repository,
|
||||
engine,
|
||||
log: ctx.log,
|
||||
reconcile: [() => viewers.reconcile(), (now) => docker.reconcile(now)],
|
||||
});
|
||||
ctx.schedule.after(STARTUP_DELAY_MS, () => scheduler.tick());
|
||||
ctx.schedule.every(TICK_MS, () => scheduler.tick());
|
||||
|
||||
registerRoutes(
|
||||
// The webhook route authenticates on its own per-automation token rather
|
||||
// than a session, which is the point of an inbound webhook.
|
||||
ctx.http.router({ public: ["/webhook/:token"] }),
|
||||
ctx.http.router<Router>({ public: ["/webhook/:token"] }),
|
||||
{ ctx, repository, engine, deps },
|
||||
);
|
||||
startAutomationScheduler();
|
||||
ctx.log.info(
|
||||
"Automations routes mounted at /plugin-api/automations; scheduler started",
|
||||
|
||||
ctx.services.provide(
|
||||
"automations.access",
|
||||
createAutomationsService(ctx, repository, engine, deps),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopAutomationScheduler();
|
||||
stopAutomationsService();
|
||||
}
|
||||
export async function deactivate() {}
|
||||
@@ -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
@@ -1,217 +1,158 @@
|
||||
import type { AutomationDefinition } from "../../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
import type { AutomationDefinition } from "../types.js";
|
||||
import { computeNextDueAt } from "./cron.js";
|
||||
import { hasDwelled, isCoolingDown } from "./conditions.js";
|
||||
import { reconcileDockerWatch, resetDockerWatcher } from "./docker-watcher.js";
|
||||
import { AutomationEngine } from "./engine.js";
|
||||
import { reconcileHeadlessViewers } from "./headless-viewer.js";
|
||||
import {
|
||||
subscribeAutomationTriggers,
|
||||
unsubscribeAutomationTriggers,
|
||||
} from "./triggers.js";
|
||||
import type { AutomationEngine } from "./engine.js";
|
||||
import type { AutomationRepository } from "./repository.js";
|
||||
|
||||
/**
|
||||
* The one timer the automations feature owns.
|
||||
*
|
||||
* Every other periodic job in the backend is its own module-level setInterval;
|
||||
* this deliberately is not one per automation. A single tick handles due
|
||||
* schedules, dwell windows that need re-checking without a fresh sample, the
|
||||
* synthetic viewer heartbeat, and history pruning.
|
||||
* The one tick the automations plugin runs. It handles due schedules, dwell
|
||||
* windows that need re-checking without a fresh sample, the watchers that
|
||||
* keep metrics and Docker events flowing for watched hosts, and history
|
||||
* pruning. activate() drives it through ctx.schedule.
|
||||
*/
|
||||
|
||||
const TICK_MS = 15_000;
|
||||
const STARTUP_DELAY_MS = 30_000;
|
||||
export const TICK_MS = 15_000;
|
||||
export const STARTUP_DELAY_MS = 30_000;
|
||||
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const RUN_RETENTION_DAYS = 30;
|
||||
/** A "running" row older than this belongs to a process that is gone. */
|
||||
const STALE_RUN_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
let tickTimer: NodeJS.Timeout | null = null;
|
||||
let startupTimer: NodeJS.Timeout | null = null;
|
||||
let lastPruneAt = 0;
|
||||
let ticking = false;
|
||||
|
||||
export function startAutomationScheduler(): void {
|
||||
if (tickTimer) return;
|
||||
|
||||
// Event-driven triggers come through the bus; the interval below only drives
|
||||
// the schedule-based ones.
|
||||
subscribeAutomationTriggers();
|
||||
|
||||
startupTimer = setTimeout(() => {
|
||||
void tick();
|
||||
}, STARTUP_DELAY_MS);
|
||||
startupTimer.unref?.();
|
||||
|
||||
tickTimer = setInterval(() => {
|
||||
void tick();
|
||||
}, TICK_MS);
|
||||
tickTimer.unref?.();
|
||||
export interface SchedulerDeps {
|
||||
repository: AutomationRepository;
|
||||
engine: Pick<AutomationEngine, "run">;
|
||||
log: PluginLogger;
|
||||
/** Watchers brought in line with the enabled automations every tick. */
|
||||
reconcile: Array<(now: number) => Promise<unknown>>;
|
||||
}
|
||||
|
||||
export function stopAutomationScheduler(): void {
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
if (startupTimer) clearTimeout(startupTimer);
|
||||
tickTimer = null;
|
||||
startupTimer = null;
|
||||
unsubscribeAutomationTriggers();
|
||||
resetDockerWatcher();
|
||||
}
|
||||
export function createScheduler(deps: SchedulerDeps) {
|
||||
const { repository, engine, log } = deps;
|
||||
let lastPruneAt = 0;
|
||||
let ticking = false;
|
||||
|
||||
/** Exposed for tests; the interval calls this. */
|
||||
export async function tick(now: Date = new Date()): Promise<void> {
|
||||
// A slow tick must not overlap the next one.
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
const errorText = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
try {
|
||||
await reconcileHeadlessViewers().catch(() => undefined);
|
||||
await runDueSchedules(now);
|
||||
await recheckOpenBreaches(now);
|
||||
await reconcileDockerWatch(now.getTime()).catch(() => undefined);
|
||||
await pruneIfDue(now);
|
||||
} catch (error) {
|
||||
statsLogger.warn("Automation scheduler tick failed", {
|
||||
operation: "automation_scheduler_tick_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
ticking = false;
|
||||
}
|
||||
}
|
||||
async function runDueSchedules(now: Date): Promise<void> {
|
||||
const due = await repository.listDueSchedules(now.toISOString());
|
||||
|
||||
async function runDueSchedules(now: Date): Promise<void> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const due = await repository.listDueSchedules(now.toISOString());
|
||||
|
||||
for (const schedule of due) {
|
||||
const automation = await repository.findById(schedule.automationId);
|
||||
if (!automation) continue;
|
||||
|
||||
// Background work can only touch a user's data while their key resolves.
|
||||
if (!canAccess(automation.userId)) {
|
||||
statsLogger.warn("Skipping scheduled automation: data key unavailable", {
|
||||
operation: "automation_schedule_locked",
|
||||
automationId: automation.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDueAt = computeNextDueAt(
|
||||
{
|
||||
cron: schedule.cron,
|
||||
intervalSeconds: schedule.intervalSeconds,
|
||||
timezone: schedule.timezone,
|
||||
},
|
||||
now,
|
||||
);
|
||||
await repository.markScheduleTicked(
|
||||
schedule.automationId,
|
||||
nextDueAt,
|
||||
now.toISOString(),
|
||||
);
|
||||
|
||||
AutomationEngine.getInstance()
|
||||
.run({
|
||||
automationId: schedule.automationId,
|
||||
triggerType: "schedule",
|
||||
triggerContext: { scheduledFor: now.toISOString() },
|
||||
})
|
||||
.catch((error) => {
|
||||
statsLogger.warn("Scheduled automation failed to start", {
|
||||
operation: "automation_schedule_error",
|
||||
automationId: schedule.automationId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires sustained breaches whose window has elapsed.
|
||||
*
|
||||
* Without this a dwell window only completes when another sample happens to
|
||||
* arrive, so a breach that starts just before polling stops would never fire.
|
||||
*/
|
||||
async function recheckOpenBreaches(now: Date): Promise<void> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const open = await repository.listOpenBreaches();
|
||||
const nowMs = now.getTime();
|
||||
|
||||
for (const state of open) {
|
||||
const automation = await repository.findById(state.automationId);
|
||||
if (!automation || !automation.enabled) continue;
|
||||
if (!canAccess(automation.userId)) continue;
|
||||
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
definition = JSON.parse(automation.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "metric_threshold") continue;
|
||||
if (!trigger.forSeconds) continue;
|
||||
if (!hasDwelled(state.breachStartedAt, trigger.forSeconds, nowMs)) continue;
|
||||
if (isCoolingDown(state.lastFiredAt, trigger.cooldownMinutes, nowMs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostId = Number(state.stateKey.split(":")[0]);
|
||||
await repository.upsertTriggerState({
|
||||
automationId: automation.id,
|
||||
stateKey: state.stateKey,
|
||||
lastFiredAt: now.toISOString(),
|
||||
});
|
||||
|
||||
AutomationEngine.getInstance()
|
||||
.run({
|
||||
automationId: automation.id,
|
||||
triggerType: "metric_threshold",
|
||||
triggerContext: {
|
||||
hostId,
|
||||
value: state.lastValue,
|
||||
threshold: trigger.value,
|
||||
metric: trigger.metric.path,
|
||||
sustained: true,
|
||||
for (const schedule of due) {
|
||||
const nextDueAt = computeNextDueAt(
|
||||
{
|
||||
cron: schedule.cron,
|
||||
intervalSeconds: schedule.intervalSeconds,
|
||||
timezone: schedule.timezone,
|
||||
},
|
||||
triggerHostId: Number.isFinite(hostId) ? hostId : undefined,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
now,
|
||||
);
|
||||
await repository.markScheduleTicked(
|
||||
schedule.automationId,
|
||||
nextDueAt,
|
||||
now.toISOString(),
|
||||
);
|
||||
|
||||
async function pruneIfDue(now: Date): Promise<void> {
|
||||
if (now.getTime() - lastPruneAt < PRUNE_INTERVAL_MS) return;
|
||||
lastPruneAt = now.getTime();
|
||||
|
||||
const repository = createCurrentAutomationRepository();
|
||||
try {
|
||||
await repository.failStaleRunningRuns(
|
||||
new Date(now.getTime() - STALE_RUN_MS).toISOString(),
|
||||
);
|
||||
const deleted = await repository.pruneRunsOlderThan(RUN_RETENTION_DAYS);
|
||||
if (deleted > 0) {
|
||||
statsLogger.info(`Pruned ${deleted} old automation run(s)`, {
|
||||
operation: "automation_run_prune",
|
||||
deleted,
|
||||
});
|
||||
void engine
|
||||
.run({
|
||||
automationId: schedule.automationId,
|
||||
triggerType: "schedule",
|
||||
triggerContext: { scheduledFor: now.toISOString() },
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
log.warn(
|
||||
`Scheduled automation ${schedule.automationId} failed to start: ${errorText(error)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.warn("Automation run pruning failed", {
|
||||
operation: "automation_run_prune_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canAccess(userId: string): boolean {
|
||||
try {
|
||||
return DataCrypto.canUserAccessData(userId);
|
||||
} catch {
|
||||
return false;
|
||||
/**
|
||||
* Fires sustained breaches whose window has elapsed. Without this a dwell
|
||||
* window only completes when another sample happens to arrive.
|
||||
*/
|
||||
async function recheckOpenBreaches(now: Date): Promise<void> {
|
||||
const open = await repository.listOpenBreaches();
|
||||
const nowMs = now.getTime();
|
||||
|
||||
for (const state of open) {
|
||||
const automation = await repository.findById(state.automationId);
|
||||
if (!automation || !automation.enabled) continue;
|
||||
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
definition = JSON.parse(automation.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "metric_threshold") continue;
|
||||
if (!trigger.forSeconds) continue;
|
||||
if (!hasDwelled(state.breachStartedAt, trigger.forSeconds, nowMs)) {
|
||||
continue;
|
||||
}
|
||||
if (isCoolingDown(state.lastFiredAt, trigger.cooldownMinutes, nowMs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostId = Number(state.stateKey.split(":")[0]);
|
||||
await repository.upsertTriggerState({
|
||||
automationId: automation.id,
|
||||
stateKey: state.stateKey,
|
||||
lastFiredAt: now.toISOString(),
|
||||
});
|
||||
|
||||
void engine
|
||||
.run({
|
||||
automationId: automation.id,
|
||||
triggerType: "metric_threshold",
|
||||
triggerContext: {
|
||||
hostId,
|
||||
value: state.lastValue,
|
||||
threshold: trigger.value,
|
||||
metric: trigger.metric.path,
|
||||
sustained: true,
|
||||
},
|
||||
triggerHostId: Number.isFinite(hostId) ? hostId : undefined,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneIfDue(now: Date): Promise<void> {
|
||||
if (now.getTime() - lastPruneAt < PRUNE_INTERVAL_MS) return;
|
||||
lastPruneAt = now.getTime();
|
||||
try {
|
||||
await repository.failStaleRunningRuns(
|
||||
new Date(now.getTime() - STALE_RUN_MS).toISOString(),
|
||||
);
|
||||
const deleted = await repository.pruneRunsOlderThan(RUN_RETENTION_DAYS);
|
||||
if (deleted > 0) log.info(`Pruned ${deleted} old automation run(s)`);
|
||||
} catch (error) {
|
||||
log.warn(`Automation run pruning failed: ${errorText(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async tick(now: Date = new Date()): Promise<void> {
|
||||
// A slow tick must not overlap the next one.
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
try {
|
||||
for (const reconcile of deps.reconcile) {
|
||||
await reconcile(now.getTime()).catch((error: unknown) =>
|
||||
log.warn(`Automation watcher failed: ${errorText(error)}`),
|
||||
);
|
||||
}
|
||||
await runDueSchedules(now);
|
||||
await recheckOpenBreaches(now);
|
||||
await pruneIfDue(now);
|
||||
} catch (error) {
|
||||
log.warn(`Automation scheduler tick failed: ${errorText(error)}`);
|
||||
} finally {
|
||||
ticking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { PluginEvents, PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
import type { AutomationDefinition, HostSelector, Trigger } from "../types.js";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
HostSelector,
|
||||
Trigger,
|
||||
} from "../../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { AutomationEngineRow } from "../../../../src/backend/database/repositories/automation-repository.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
AutomationEngineRow,
|
||||
AutomationRepository,
|
||||
} from "./repository.js";
|
||||
import {
|
||||
compare,
|
||||
extractMetricValue,
|
||||
@@ -15,11 +13,7 @@ import {
|
||||
severityForValue,
|
||||
type MetricsSnapshot,
|
||||
} from "./conditions.js";
|
||||
import { AutomationEngine } from "./engine.js";
|
||||
import {
|
||||
pluginEvents,
|
||||
TOPICS,
|
||||
} from "../../../../src/backend/plugins/events.js";
|
||||
import { TOPIC_AUTOMATION_FAILED, type AutomationEngine } from "./engine.js";
|
||||
|
||||
/**
|
||||
* Matches events against automation triggers and decides what fires.
|
||||
@@ -68,377 +62,404 @@ interface LoadedAutomation {
|
||||
definition: AutomationDefinition;
|
||||
}
|
||||
|
||||
async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> {
|
||||
try {
|
||||
const rows =
|
||||
await createCurrentAutomationRepository().listEnabledForUser(userId);
|
||||
const loaded: LoadedAutomation[] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
loaded.push({
|
||||
row,
|
||||
definition: JSON.parse(row.definition) as AutomationDefinition,
|
||||
});
|
||||
} catch {
|
||||
// A malformed definition should not stop the others from evaluating.
|
||||
export type Triggers = ReturnType<typeof createTriggers>;
|
||||
|
||||
/** Built once per activation. */
|
||||
export function createTriggers(
|
||||
repository: AutomationRepository,
|
||||
engine: Pick<AutomationEngine, "run">,
|
||||
log: PluginLogger,
|
||||
) {
|
||||
async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> {
|
||||
try {
|
||||
const rows = await repository.listEnabledForUser(userId);
|
||||
const loaded: LoadedAutomation[] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
loaded.push({
|
||||
row,
|
||||
definition: JSON.parse(row.definition) as AutomationDefinition,
|
||||
});
|
||||
} catch {
|
||||
// A malformed definition should not stop the others from evaluating.
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return loaded;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a selector covers a host. Ownership is checked by the caller. */
|
||||
function selectorCoversHost(
|
||||
selector: HostSelector | undefined,
|
||||
hostId: number,
|
||||
): boolean {
|
||||
if (!selector) return true;
|
||||
switch (selector.kind) {
|
||||
case "all":
|
||||
case "trigger":
|
||||
return true;
|
||||
case "host":
|
||||
return selector.hostId === hostId;
|
||||
case "hosts":
|
||||
return selector.hostIds.includes(hostId);
|
||||
case "fleet":
|
||||
// Fleet membership is resolved at execution time; evaluate optimistically
|
||||
// so a fleet-scoped trigger still reaches the engine.
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
/** Whether a selector covers a host. Ownership is checked by the caller. */
|
||||
function selectorCoversHost(
|
||||
selector: HostSelector | undefined,
|
||||
hostId: number,
|
||||
): boolean {
|
||||
if (!selector) return true;
|
||||
switch (selector.kind) {
|
||||
case "all":
|
||||
case "trigger":
|
||||
return true;
|
||||
case "host":
|
||||
return selector.hostId === hostId;
|
||||
case "hosts":
|
||||
return selector.hostIds.includes(hostId);
|
||||
case "fleet":
|
||||
// Fleet membership is resolved at execution time; evaluate optimistically
|
||||
// so a fleet-scoped trigger still reaches the engine.
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fire(
|
||||
automation: AutomationEngineRow,
|
||||
stateKey: string,
|
||||
triggerType: string,
|
||||
triggerContext: Record<string, unknown>,
|
||||
hostId?: number,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
await repository.upsertTriggerState({
|
||||
automationId: automation.id,
|
||||
stateKey,
|
||||
lastFiredAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
AutomationEngine.getInstance()
|
||||
.run({
|
||||
async function fire(
|
||||
automation: AutomationEngineRow,
|
||||
stateKey: string,
|
||||
triggerType: string,
|
||||
triggerContext: Record<string, unknown>,
|
||||
hostId?: number,
|
||||
): Promise<void> {
|
||||
await repository.upsertTriggerState({
|
||||
automationId: automation.id,
|
||||
triggerType,
|
||||
triggerContext,
|
||||
triggerHostId: hostId,
|
||||
})
|
||||
.catch((error) => {
|
||||
statsLogger.warn("Automation run failed to start", {
|
||||
operation: "automation_trigger_error",
|
||||
stateKey,
|
||||
lastFiredAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
engine
|
||||
.run({
|
||||
automationId: automation.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
triggerType,
|
||||
triggerContext,
|
||||
triggerHostId: hostId,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
log.warn(
|
||||
`Automation ${automation.id} failed to start: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric samples. Called for every polled host, including hosts polled only
|
||||
* because an automation asked for them.
|
||||
*/
|
||||
export async function onMetrics(event: MetricEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "metric_threshold") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
|
||||
const value = extractMetricValue(event.metrics, trigger.metric);
|
||||
if (value === null) continue;
|
||||
|
||||
const stateKey = metricStateKey(event.hostId, trigger.metric);
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
const breaching = compare(value, trigger.operator, trigger.value);
|
||||
|
||||
if (!breaching) {
|
||||
if (state?.breachStartedAt) {
|
||||
await repository.clearBreach(row.id, stateKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Open the dwell window on the first breaching sample.
|
||||
if (!state?.breachStartedAt) {
|
||||
await repository.upsertTriggerState({
|
||||
automationId: row.id,
|
||||
stateKey,
|
||||
breachStartedAt: new Date(now).toISOString(),
|
||||
lastValue: value,
|
||||
});
|
||||
if (trigger.forSeconds) continue;
|
||||
}
|
||||
|
||||
const breachStartedAt =
|
||||
state?.breachStartedAt ?? new Date(now).toISOString();
|
||||
if (!hasDwelled(breachStartedAt, trigger.forSeconds, now)) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"metric_threshold",
|
||||
{
|
||||
hostId: event.hostId,
|
||||
value,
|
||||
threshold: trigger.value,
|
||||
operator: trigger.operator,
|
||||
metric: trigger.metric.path,
|
||||
mount: "mount" in trigger.metric ? trigger.metric.mount : undefined,
|
||||
severity: severityForValue(value, trigger.severity),
|
||||
},
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Host reachability transitions. Only edges fire, never steady state. */
|
||||
export async function onStatus(event: StatusEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
const observed = event.online ? "online" : "offline";
|
||||
/**
|
||||
* Metric samples. Called for every polled host, including hosts polled only
|
||||
* because an automation asked for them.
|
||||
*/
|
||||
async function onMetrics(event: MetricEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const now = Date.now();
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "host_status") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "metric_threshold") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
|
||||
const stateKey = String(event.hostId);
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
const value = extractMetricValue(event.metrics, trigger.metric);
|
||||
if (value === null) continue;
|
||||
|
||||
if (state?.lastObservedState === observed) continue;
|
||||
const stateKey = metricStateKey(event.hostId, trigger.metric);
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
const breaching = compare(value, trigger.operator, trigger.value);
|
||||
|
||||
await repository.upsertTriggerState({
|
||||
automationId: row.id,
|
||||
stateKey,
|
||||
lastObservedState: observed,
|
||||
});
|
||||
|
||||
// The first observation establishes a baseline rather than firing, so a
|
||||
// restart does not announce every host as though it just changed.
|
||||
if (!state?.lastObservedState) continue;
|
||||
if (trigger.to !== observed) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"host_status",
|
||||
{ hostId: event.hostId, status: observed },
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onHealthCheck(event: HealthEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.userId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
const observed = event.ok ? "recovered" : "failing";
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "health_check") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
if (trigger.checkId && trigger.checkId !== event.checkId) continue;
|
||||
|
||||
const stateKey = `${event.hostId}:${event.checkId}`;
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
|
||||
if (state?.lastObservedState === observed) continue;
|
||||
|
||||
await repository.upsertTriggerState({
|
||||
automationId: row.id,
|
||||
stateKey,
|
||||
lastObservedState: observed,
|
||||
});
|
||||
|
||||
if (!state?.lastObservedState) continue;
|
||||
if (trigger.to !== observed) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"health_check",
|
||||
{
|
||||
hostId: event.hostId,
|
||||
checkId: event.checkId,
|
||||
state: observed,
|
||||
detail: event.detail,
|
||||
},
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onDockerEvent(event: DockerEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "docker_event") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
if (trigger.container && trigger.container !== event.container) continue;
|
||||
if (trigger.event !== event.event) continue;
|
||||
|
||||
const stateKey = `${event.hostId}:${event.container}`;
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"docker_event",
|
||||
{
|
||||
hostId: event.hostId,
|
||||
container: event.container,
|
||||
event: event.event,
|
||||
},
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onInternalEvent(event: InternalEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.userId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "internal_event") continue;
|
||||
if (trigger.event !== event.event) continue;
|
||||
if (
|
||||
event.hostId !== undefined &&
|
||||
!selectorCoversHost(trigger.hostSelector, event.hostId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stateKey = event.hostId ? String(event.hostId) : "global";
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"internal_event",
|
||||
{ event: event.event, hostId: event.hostId, ...(event.details ?? {}) },
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let unsubscribers: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* Subscribes the engine to the event bus.
|
||||
*
|
||||
* The hosts modules publish onto the bus and know nothing about automations;
|
||||
* this is the other half of that arrangement, and it is what keeps the edge
|
||||
* one-directional (automations may import repositories, hosts modules must not
|
||||
* import automations).
|
||||
*
|
||||
* Handlers are async and the bus is fire-and-forget, so a rejection here is
|
||||
* logged by the bus rather than surfacing to whichever host feature emitted.
|
||||
*/
|
||||
export function subscribeAutomationTriggers(): void {
|
||||
if (unsubscribers.length > 0) return;
|
||||
|
||||
unsubscribers = [
|
||||
pluginEvents.on("plugin.host-metrics.snapshot", (payload) =>
|
||||
onMetrics(payload as MetricEvent),
|
||||
),
|
||||
pluginEvents.on(TOPICS.hostStatus, (payload) =>
|
||||
onStatus(payload as StatusEvent),
|
||||
),
|
||||
pluginEvents.on("plugin.host-metrics.health-check", (payload) =>
|
||||
onHealthCheck(payload as HealthEvent),
|
||||
),
|
||||
pluginEvents.on(TOPICS.internalEvent, (payload) =>
|
||||
onInternalEvent(payload as InternalEvent),
|
||||
),
|
||||
// An SSH login seen by the terminal, which host-metrics used to relay.
|
||||
pluginEvents.on(TOPICS.hostLogin, (payload) => {
|
||||
const login = payload as {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
sshUser?: string;
|
||||
fromIp?: string;
|
||||
};
|
||||
if (!login?.userId) return;
|
||||
return onInternalEvent({
|
||||
event: "user_login",
|
||||
userId: login.userId,
|
||||
hostId: login.hostId,
|
||||
details: { sshUser: login.sshUser, fromIp: login.fromIp },
|
||||
});
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function unsubscribeAutomationTriggers(): void {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe();
|
||||
unsubscribers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that an enabled automation watches, so the poller knows to collect
|
||||
* metrics for them even when nobody is looking.
|
||||
*/
|
||||
export async function listAutomationWatchedHosts(): Promise<
|
||||
Map<number, string>
|
||||
> {
|
||||
const watched = new Map<number, string>();
|
||||
|
||||
try {
|
||||
const rows = await createCurrentAutomationRepository().listAllEnabled();
|
||||
for (const row of rows) {
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
if (!breaching) {
|
||||
if (state?.breachStartedAt) {
|
||||
await repository.clearBreach(row.id, stateKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const trigger: Trigger | undefined = definition.trigger;
|
||||
// Only metric thresholds need heavy collection; status triggers are
|
||||
// already served by the cheap reachability probe.
|
||||
if (trigger?.kind !== "metric_threshold") continue;
|
||||
|
||||
const selector = trigger.hostSelector;
|
||||
if (selector?.kind === "host") {
|
||||
watched.set(selector.hostId, row.userId);
|
||||
} else if (selector?.kind === "hosts") {
|
||||
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
|
||||
// Open the dwell window on the first breaching sample.
|
||||
if (!state?.breachStartedAt) {
|
||||
await repository.upsertTriggerState({
|
||||
automationId: row.id,
|
||||
stateKey,
|
||||
breachStartedAt: new Date(now).toISOString(),
|
||||
lastValue: value,
|
||||
});
|
||||
if (trigger.forSeconds) continue;
|
||||
}
|
||||
// Fleet and "all" selectors are resolved by the scheduler, which can
|
||||
// expand them without blocking this call.
|
||||
|
||||
const breachStartedAt =
|
||||
state?.breachStartedAt ?? new Date(now).toISOString();
|
||||
if (!hasDwelled(breachStartedAt, trigger.forSeconds, now)) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"metric_threshold",
|
||||
{
|
||||
hostId: event.hostId,
|
||||
value,
|
||||
threshold: trigger.value,
|
||||
operator: trigger.operator,
|
||||
metric: trigger.metric.path,
|
||||
mount: "mount" in trigger.metric ? trigger.metric.mount : undefined,
|
||||
severity: severityForValue(value, trigger.severity),
|
||||
},
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Host reachability transitions. Only edges fire, never steady state. */
|
||||
async function onStatus(event: StatusEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const now = Date.now();
|
||||
const observed = event.online ? "online" : "offline";
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "host_status") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
|
||||
const stateKey = String(event.hostId);
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
|
||||
if (state?.lastObservedState === observed) continue;
|
||||
|
||||
await repository.upsertTriggerState({
|
||||
automationId: row.id,
|
||||
stateKey,
|
||||
lastObservedState: observed,
|
||||
});
|
||||
|
||||
// The first observation establishes a baseline rather than firing, so a
|
||||
// restart does not announce every host as though it just changed.
|
||||
if (!state?.lastObservedState) continue;
|
||||
if (trigger.to !== observed) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"host_status",
|
||||
{ hostId: event.hostId, status: observed },
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function onHealthCheck(event: HealthEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.userId);
|
||||
const now = Date.now();
|
||||
const observed = event.ok ? "recovered" : "failing";
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "health_check") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
if (trigger.checkId && trigger.checkId !== event.checkId) continue;
|
||||
|
||||
const stateKey = `${event.hostId}:${event.checkId}`;
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
|
||||
if (state?.lastObservedState === observed) continue;
|
||||
|
||||
await repository.upsertTriggerState({
|
||||
automationId: row.id,
|
||||
stateKey,
|
||||
lastObservedState: observed,
|
||||
});
|
||||
|
||||
if (!state?.lastObservedState) continue;
|
||||
if (trigger.to !== observed) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"health_check",
|
||||
{
|
||||
hostId: event.hostId,
|
||||
checkId: event.checkId,
|
||||
state: observed,
|
||||
detail: event.detail,
|
||||
},
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDockerEvent(event: DockerEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const now = Date.now();
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "docker_event") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
if (trigger.container && trigger.container !== event.container) continue;
|
||||
if (trigger.event !== event.event) continue;
|
||||
|
||||
const stateKey = `${event.hostId}:${event.container}`;
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"docker_event",
|
||||
{
|
||||
hostId: event.hostId,
|
||||
container: event.container,
|
||||
event: event.event,
|
||||
},
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function onInternalEvent(event: InternalEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.userId);
|
||||
const now = Date.now();
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "internal_event") continue;
|
||||
if (trigger.event !== event.event) continue;
|
||||
if (
|
||||
event.hostId !== undefined &&
|
||||
!selectorCoversHost(trigger.hostSelector, event.hostId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stateKey = event.hostId ? String(event.hostId) : "global";
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"internal_event",
|
||||
{ event: event.event, hostId: event.hostId, ...(event.details ?? {}) },
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts an enabled automation watches with the given trigger kind, with
|
||||
* the owning user. Fleet and "all" selectors are not expanded: collecting
|
||||
* for every host a user owns is far too costly.
|
||||
*/
|
||||
async function watchedHosts(
|
||||
kind: "metric_threshold" | "docker_event",
|
||||
): Promise<Map<number, string>> {
|
||||
const watched = new Map<number, string>();
|
||||
try {
|
||||
for (const row of await repository.listAllEnabled()) {
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const trigger: Trigger | undefined = definition.trigger;
|
||||
if (trigger?.kind !== kind) continue;
|
||||
const selector = trigger.hostSelector;
|
||||
if (selector?.kind === "host") {
|
||||
watched.set(selector.hostId, row.userId);
|
||||
} else if (selector?.kind === "hosts") {
|
||||
for (const hostId of selector.hostIds)
|
||||
watched.set(hostId, row.userId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return watched;
|
||||
}
|
||||
} catch {
|
||||
return watched;
|
||||
}
|
||||
|
||||
return watched;
|
||||
/**
|
||||
* Subscribes to the topics the triggers read. Every listener is disposed
|
||||
* with the plugin. Handlers are async and the bus is fire-and-forget, so a
|
||||
* rejection is logged here rather than reaching whoever emitted.
|
||||
*/
|
||||
function subscribe(events: PluginEvents): void {
|
||||
const on = <T>(
|
||||
topic: string,
|
||||
handler: (payload: T) => Promise<void> | undefined,
|
||||
) =>
|
||||
events.on(topic, (payload) => {
|
||||
void Promise.resolve(handler(payload as T)).catch((error: unknown) =>
|
||||
log.warn(
|
||||
`Automation trigger for ${topic} failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<MetricEvent>("plugin.host-metrics.snapshot", onMetrics);
|
||||
on<HealthEvent>("plugin.host-metrics.health-check", onHealthCheck);
|
||||
on<StatusEvent>("host.status", onStatus);
|
||||
on<InternalEvent>("internal.event", (event) =>
|
||||
event?.userId ? onInternalEvent(event) : undefined,
|
||||
);
|
||||
// An SSH login seen by the terminal.
|
||||
on<{ hostId: number; userId: string; sshUser?: string; fromIp?: string }>(
|
||||
"host.login",
|
||||
(login) =>
|
||||
login?.userId
|
||||
? onInternalEvent({
|
||||
event: "user_login",
|
||||
userId: login.userId,
|
||||
hostId: login.hostId,
|
||||
details: { sshUser: login.sshUser, fromIp: login.fromIp },
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
// Nothing arrives here while the tunnels plugin is off, which is the
|
||||
// whole of that optional dependency.
|
||||
on<{ userId?: string; hostId?: number; tunnelName?: string }>(
|
||||
"plugin.tunnels.tunnel_disconnected",
|
||||
(event) =>
|
||||
event?.userId
|
||||
? onInternalEvent({
|
||||
event: "tunnel_disconnected",
|
||||
userId: event.userId,
|
||||
hostId: event.hostId,
|
||||
details: { tunnelName: event.tunnelName },
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
on<{
|
||||
userId?: string;
|
||||
automationId?: number;
|
||||
automationName?: string;
|
||||
runId?: number;
|
||||
error?: string | null;
|
||||
}>(TOPIC_AUTOMATION_FAILED, (event) =>
|
||||
event?.userId
|
||||
? onInternalEvent({
|
||||
event: "automation_failed",
|
||||
userId: event.userId,
|
||||
details: {
|
||||
automationId: event.automationId,
|
||||
automationName: event.automationName,
|
||||
runId: event.runId,
|
||||
error: event.error ?? null,
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
onMetrics,
|
||||
onStatus,
|
||||
onHealthCheck,
|
||||
onDockerEvent,
|
||||
onInternalEvent,
|
||||
watchedHosts,
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginServices | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setTunnelServices(services: PluginServices | null): void {
|
||||
current = services;
|
||||
}
|
||||
|
||||
/** The tunnels plugin, as ctx.services.get("tunnels.access") returns it. */
|
||||
interface TunnelsAccess {
|
||||
start: (name: string) => Promise<void>;
|
||||
stop: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TunnelActionResult {
|
||||
ok: boolean;
|
||||
/** True when the tunnels plugin is off or the user may not use it. */
|
||||
unavailable?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects or disconnects a saved tunnel as userId. Optional: without the
|
||||
* tunnels plugin the step fails with a clear message rather than the plugin
|
||||
* failing to activate.
|
||||
*/
|
||||
export async function runTunnelAction(
|
||||
userId: string,
|
||||
action: "connect" | "disconnect",
|
||||
name: string,
|
||||
): Promise<TunnelActionResult> {
|
||||
if (!current) {
|
||||
return {
|
||||
ok: false,
|
||||
unavailable: true,
|
||||
error: "The tunnels plugin is not available",
|
||||
};
|
||||
}
|
||||
|
||||
let tunnels: TunnelsAccess;
|
||||
try {
|
||||
tunnels = current.get<TunnelsAccess>("tunnels.access", { userId });
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
unavailable: true,
|
||||
error: "The tunnels plugin is not available",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "connect") await tunnels.start(name);
|
||||
else await tunnels.stop(name);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
unavailable: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { AutomationDefinition, Step, Trigger } from "../types.js";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "../types.js";
|
||||
import { isValidCron, isValidTimezone } from "./cron.js";
|
||||
|
||||
const TRIGGER_KINDS = new Set([
|
||||
"metric_threshold",
|
||||
"host_status",
|
||||
"health_check",
|
||||
"schedule",
|
||||
"docker_event",
|
||||
"internal_event",
|
||||
"webhook",
|
||||
]);
|
||||
|
||||
const STEP_TYPES = new Set([
|
||||
"notify",
|
||||
"http",
|
||||
"run_snippet",
|
||||
"run_command",
|
||||
"docker",
|
||||
"tunnel",
|
||||
"wol",
|
||||
"wait",
|
||||
"set_var",
|
||||
"if",
|
||||
"run_automation",
|
||||
"stop",
|
||||
]);
|
||||
|
||||
const OPERATORS = new Set([
|
||||
">",
|
||||
"<",
|
||||
">=",
|
||||
"<=",
|
||||
"==",
|
||||
"!=",
|
||||
"contains",
|
||||
"not_contains",
|
||||
"changed",
|
||||
]);
|
||||
|
||||
export function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a definition before it is stored. The engine treats the stored
|
||||
* blob as trusted, so everything it relies on is checked once here.
|
||||
*/
|
||||
export function validateDefinition(value: unknown): {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
definition?: AutomationDefinition;
|
||||
} {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return { ok: false, error: "Definition must be an object" };
|
||||
}
|
||||
|
||||
const candidate = value as Partial<AutomationDefinition>;
|
||||
const trigger = candidate.trigger as Trigger | undefined;
|
||||
|
||||
if (!trigger || !TRIGGER_KINDS.has(trigger.kind)) {
|
||||
return { ok: false, error: "Unknown or missing trigger kind" };
|
||||
}
|
||||
|
||||
if (trigger.kind === "metric_threshold") {
|
||||
if (!OPERATORS.has(trigger.operator)) {
|
||||
return { ok: false, error: "Unknown comparison operator" };
|
||||
}
|
||||
if (typeof trigger.value !== "number" || !Number.isFinite(trigger.value)) {
|
||||
return { ok: false, error: "Threshold value must be a number" };
|
||||
}
|
||||
if (!trigger.metric?.path) {
|
||||
return { ok: false, error: "Trigger is missing a metric" };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.kind === "schedule") {
|
||||
const hasInterval =
|
||||
typeof trigger.intervalSeconds === "number" &&
|
||||
trigger.intervalSeconds > 0;
|
||||
const hasCron = isNonEmptyString(trigger.cron);
|
||||
if (!hasInterval && !hasCron) {
|
||||
return { ok: false, error: "Schedule needs an interval or a cron" };
|
||||
}
|
||||
if (hasCron && !isValidCron(trigger.cron as string)) {
|
||||
return { ok: false, error: "Cron expression is not valid" };
|
||||
}
|
||||
if (hasInterval && (trigger.intervalSeconds as number) < 60) {
|
||||
return { ok: false, error: "Interval must be at least 60 seconds" };
|
||||
}
|
||||
if (
|
||||
isNonEmptyString(trigger.timezone) &&
|
||||
!isValidTimezone(trigger.timezone)
|
||||
) {
|
||||
return { ok: false, error: "Time zone is not valid" };
|
||||
}
|
||||
}
|
||||
|
||||
const steps = candidate.steps;
|
||||
if (!Array.isArray(steps)) {
|
||||
return { ok: false, error: "Definition must include a steps array" };
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stepError = validateSteps(steps as Step[], seen);
|
||||
if (stepError) return { ok: false, error: stepError };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
definition: {
|
||||
version: candidate.version ?? AUTOMATION_DEFINITION_VERSION,
|
||||
trigger,
|
||||
steps: steps as Step[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateSteps(steps: Step[], seen: Set<string>): string | null {
|
||||
for (const step of steps) {
|
||||
if (!step || typeof step !== "object") return "Step must be an object";
|
||||
if (!isNonEmptyString(step.id)) return "Every step needs an id";
|
||||
if (seen.has(step.id)) return `Duplicate step id: ${step.id}`;
|
||||
seen.add(step.id);
|
||||
if (!STEP_TYPES.has(step.type)) {
|
||||
return `Unknown step type: ${step.type}`;
|
||||
}
|
||||
|
||||
if (step.type === "if") {
|
||||
if (!step.condition || !OPERATORS.has(step.condition.operator)) {
|
||||
return "Condition needs a valid operator";
|
||||
}
|
||||
const thenError = validateSteps(step.then ?? [], seen);
|
||||
if (thenError) return thenError;
|
||||
const elseError = validateSteps(step.else ?? [], seen);
|
||||
if (elseError) return elseError;
|
||||
}
|
||||
|
||||
if (step.type === "http" && !isNonEmptyString(step.url)) {
|
||||
return "HTTP steps need a URL";
|
||||
}
|
||||
if (step.type === "run_command" && !isNonEmptyString(step.command)) {
|
||||
return "Command steps need a command";
|
||||
}
|
||||
if (step.type === "wait" && typeof step.seconds !== "number") {
|
||||
return "Wait steps need a number of seconds";
|
||||
}
|
||||
if (step.type === "set_var" && !isNonEmptyString(step.name)) {
|
||||
return "Variable steps need a name";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
@@ -12,30 +11,22 @@ import {
|
||||
Trash2,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Badge } from "@/components/badge";
|
||||
import {
|
||||
createAutomation,
|
||||
deleteAutomation,
|
||||
listAutomationRunSteps,
|
||||
listAutomationRuns,
|
||||
listAutomations,
|
||||
runAutomation,
|
||||
updateAutomation,
|
||||
Badge,
|
||||
Button,
|
||||
NotificationChannelDialog,
|
||||
deleteNotificationChannel,
|
||||
getBasePath,
|
||||
getNotificationChannels,
|
||||
testNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useAutomationsApi,
|
||||
type AutomationRow,
|
||||
type AutomationRunRow,
|
||||
type AutomationRunStepRow,
|
||||
} from "./automations-api";
|
||||
import {
|
||||
deleteNotificationChannel,
|
||||
getNotificationChannels,
|
||||
testNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/notification-channels-api";
|
||||
import { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
|
||||
import { getSnippets } from "@/api/snippets-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { invokeAction } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
import {
|
||||
AutomationEditor,
|
||||
@@ -47,6 +38,10 @@ import {
|
||||
type AutomationEditorOptions,
|
||||
} from "./automations/editor-types";
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
type PanelTab = "automations" | "runs" | "channels";
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
@@ -58,25 +53,20 @@ const STATUS_CLASS: Record<string, string> = {
|
||||
cancelled: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function timeAgo(iso: string | null, t: Translate): string {
|
||||
if (!iso) return "";
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
const base = "newUi.sidebar.automations.ago";
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
if (sec < 60) return t(`${base}.seconds`, { count: sec });
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
if (min < 60) return t(`${base}.minutes`, { count: min });
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
/** Fleets from the fleets plugin, or none when it is not running. */
|
||||
async function listFleets(): Promise<{ id: number; name: string }[]> {
|
||||
const result = await invokeAction("fleets.list");
|
||||
return Array.isArray(result)
|
||||
? (result as { id: number; name: string }[])
|
||||
: [];
|
||||
if (hr < 24) return t(`${base}.hours`, { count: hr });
|
||||
return t(`${base}.days`, { count: Math.floor(hr / 24) });
|
||||
}
|
||||
|
||||
export function AutomationsPanel({
|
||||
@@ -87,6 +77,7 @@ export function AutomationsPanel({
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const api = useAutomationsApi();
|
||||
const base = "newUi.sidebar.automations";
|
||||
|
||||
const [tab, setTab] = useState<PanelTab>("automations");
|
||||
@@ -113,60 +104,34 @@ export function AutomationsPanel({
|
||||
const loadAutomations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setAutomations(await listAutomations());
|
||||
setAutomations(await api.list());
|
||||
} catch {
|
||||
// A failed load leaves the previous list on screen.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [api]);
|
||||
|
||||
const loadRuns = useCallback(async () => {
|
||||
try {
|
||||
setRuns(await listAutomationRuns({ limit: 100 }));
|
||||
setRuns(await api.runs({ limit: 100 }));
|
||||
} catch {
|
||||
// Non-fatal.
|
||||
}
|
||||
}, []);
|
||||
}, [api]);
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
const [hosts, snippets, channelsResult, fleets] = await Promise.allSettled([
|
||||
getSSHHosts({ includeStatus: false }),
|
||||
getSnippets(),
|
||||
const [editorOptions, channelsResult] = await Promise.allSettled([
|
||||
api.editorOptions(),
|
||||
getNotificationChannels(),
|
||||
listFleets(),
|
||||
]);
|
||||
|
||||
if (channelsResult.status === "fulfilled") {
|
||||
setChannels(channelsResult.value);
|
||||
}
|
||||
|
||||
setOptions({
|
||||
hosts:
|
||||
hosts.status === "fulfilled"
|
||||
? hosts.value.map((host) => ({
|
||||
id: host.id,
|
||||
name: host.name || host.ip,
|
||||
}))
|
||||
: [],
|
||||
snippets:
|
||||
snippets.status === "fulfilled"
|
||||
? snippets.value.map((s) => ({
|
||||
id: s.id,
|
||||
// SnippetRow only types its id; the rest is narrowed by callers.
|
||||
name: typeof s.name === "string" ? s.name : `#${s.id}`,
|
||||
}))
|
||||
: [],
|
||||
channels:
|
||||
channelsResult.status === "fulfilled"
|
||||
? channelsResult.value.map((c) => ({ id: c.id, name: c.name }))
|
||||
: [],
|
||||
fleets:
|
||||
fleets.status === "fulfilled"
|
||||
? fleets.value.map((f) => ({ id: f.id, name: f.name }))
|
||||
: [],
|
||||
});
|
||||
}, []);
|
||||
if (editorOptions.status === "fulfilled") {
|
||||
setOptions(editorOptions.value);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -222,7 +187,7 @@ export function AutomationsPanel({
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingId === null) {
|
||||
const created = await createAutomation({
|
||||
const created = await api.create({
|
||||
name: draft.name.trim(),
|
||||
description: draft.description || null,
|
||||
enabled: draft.enabled,
|
||||
@@ -236,7 +201,7 @@ export function AutomationsPanel({
|
||||
setEditorOpen(false);
|
||||
}
|
||||
} else {
|
||||
await updateAutomation(editingId, {
|
||||
await api.update(editingId, {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description || null,
|
||||
enabled: draft.enabled,
|
||||
@@ -257,7 +222,7 @@ export function AutomationsPanel({
|
||||
async function remove(row: AutomationRow) {
|
||||
if (!confirm(t(`${base}.deleteConfirm`))) return;
|
||||
try {
|
||||
await deleteAutomation(row.id);
|
||||
await api.remove(row.id);
|
||||
toast.success(t(`${base}.deleted`));
|
||||
await loadAutomations();
|
||||
} catch (error) {
|
||||
@@ -267,7 +232,7 @@ export function AutomationsPanel({
|
||||
|
||||
async function run(row: AutomationRow, dryRun: boolean) {
|
||||
try {
|
||||
const outcome = await runAutomation(row.id, { dryRun });
|
||||
const outcome = await api.run(row.id, { dryRun });
|
||||
toast.success(t(`${base}.runFinished`, { status: outcome.status }));
|
||||
await loadAutomations();
|
||||
if (tab === "runs") await loadRuns();
|
||||
@@ -304,7 +269,7 @@ export function AutomationsPanel({
|
||||
}
|
||||
setExpandedRun(runId);
|
||||
try {
|
||||
setRunSteps(await listAutomationRunSteps(runId));
|
||||
setRunSteps(await api.runSteps(runId));
|
||||
} catch {
|
||||
setRunSteps([]);
|
||||
}
|
||||
@@ -314,7 +279,7 @@ export function AutomationsPanel({
|
||||
// host manager does, rather than opening a dialog over the app.
|
||||
if (editorOpen) {
|
||||
const webhookUrl = webhookToken
|
||||
? `${window.location.origin}/automations/webhook/${webhookToken}`
|
||||
? `${window.location.origin}${getBasePath()}/plugin-api/automations/webhook/${webhookToken}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
@@ -432,7 +397,7 @@ export function AutomationsPanel({
|
||||
rel="noreferrer"
|
||||
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
{t(`${base}.docsLink`)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -475,9 +440,20 @@ export function AutomationsPanel({
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
{!!row.missingPlugins?.length && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] border-destructive/50 text-destructive"
|
||||
title={t(`${base}.needsPluginHint`)}
|
||||
>
|
||||
{t(`${base}.needsPlugin`, {
|
||||
plugin: row.missingPlugins.join(", "),
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
{!row.enabled && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
off
|
||||
{t(`${base}.offBadge`)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -490,7 +466,7 @@ export function AutomationsPanel({
|
||||
)
|
||||
: ""}
|
||||
{row.last_run_status
|
||||
? ` · ${timeAgo(row.last_run_at)}`
|
||||
? ` · ${timeAgo(row.last_run_at, t)}`
|
||||
: ""}
|
||||
</span>
|
||||
<Button
|
||||
@@ -555,7 +531,7 @@ export function AutomationsPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{timeAgo(entry.started_at)}
|
||||
{timeAgo(entry.started_at, t)}
|
||||
{entry.duration_ms !== null
|
||||
? ` · ${Math.round(entry.duration_ms / 100) / 10}s`
|
||||
: ""}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
type PluginApiClient,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
ConcurrencyPolicy,
|
||||
@@ -6,7 +10,7 @@ import type {
|
||||
StepStatus,
|
||||
StepType,
|
||||
TriggerKind,
|
||||
} from "@/types/automations";
|
||||
} from "../types";
|
||||
|
||||
export interface AutomationRow {
|
||||
id: number;
|
||||
@@ -24,6 +28,8 @@ export interface AutomationRow {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
channels: number[];
|
||||
/** Plugins this automation needs that are not running. */
|
||||
missingPlugins?: string[];
|
||||
/** Returned once, only when a webhook automation is created. */
|
||||
webhookToken?: string;
|
||||
}
|
||||
@@ -73,76 +79,82 @@ export interface AutomationInput {
|
||||
channels?: number[];
|
||||
}
|
||||
|
||||
export async function listAutomations(): Promise<AutomationRow[]> {
|
||||
/** Which optional plugins are running, keyed like the backend's deps. */
|
||||
export interface AutomationProviders {
|
||||
snippets: boolean;
|
||||
fleets: boolean;
|
||||
tunnels: boolean;
|
||||
docker: boolean;
|
||||
"docker-events": boolean;
|
||||
"host-metrics": boolean;
|
||||
"wake-on-lan": boolean;
|
||||
}
|
||||
|
||||
export interface AutomationEditorOptionsResponse {
|
||||
hosts: Array<{ id: number; name: string }>;
|
||||
snippets: Array<{ id: number; name: string }>;
|
||||
fleets: Array<{ id: number; name: string }>;
|
||||
channels: Array<{ id: number; name: string }>;
|
||||
providers: AutomationProviders;
|
||||
}
|
||||
|
||||
function apiError(error: unknown, action: string): Error {
|
||||
const data = (
|
||||
error as { response?: { data?: { error?: string; message?: string } } }
|
||||
)?.response?.data;
|
||||
return new Error(
|
||||
data?.error ||
|
||||
data?.message ||
|
||||
(error instanceof Error ? error.message : `Failed to ${action}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
action: string,
|
||||
request: () => Promise<{ data: T }>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/automations")).data;
|
||||
return (await request()).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch automations");
|
||||
throw apiError(error, action);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAutomation(
|
||||
input: AutomationInput,
|
||||
): Promise<AutomationRow> {
|
||||
try {
|
||||
return (await authApi.post("/plugin-api/automations", input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create automation");
|
||||
}
|
||||
export function createAutomationsApi(api: PluginApiClient) {
|
||||
return {
|
||||
list: () => call<AutomationRow[]>("fetch automations", () => api.get("/")),
|
||||
editorOptions: () =>
|
||||
call<AutomationEditorOptionsResponse>("fetch editor options", () =>
|
||||
api.get("/editor-options"),
|
||||
),
|
||||
create: (input: AutomationInput) =>
|
||||
call<AutomationRow>("create automation", () => api.post("/", input)),
|
||||
update: (id: number, input: Partial<AutomationInput>) =>
|
||||
call<AutomationRow>("update automation", () => api.put(`/${id}`, input)),
|
||||
remove: (id: number) =>
|
||||
call<{ success: boolean }>("delete automation", () =>
|
||||
api.delete(`/${id}`),
|
||||
),
|
||||
run: (id: number, options: { dryRun?: boolean } = {}) =>
|
||||
call<AutomationRunOutcome>("run automation", () =>
|
||||
api.post(`/${id}/run`, options),
|
||||
),
|
||||
runs: (
|
||||
options: { automationId?: number; limit?: number; offset?: number } = {},
|
||||
) =>
|
||||
call<AutomationRunRow[]>("fetch automation runs", () =>
|
||||
api.get("/runs/history", { params: options }),
|
||||
),
|
||||
runSteps: (runId: number) =>
|
||||
call<AutomationRunStepRow[]>("fetch run steps", () =>
|
||||
api.get(`/runs/${runId}/steps`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateAutomation(
|
||||
id: number,
|
||||
input: Partial<AutomationInput>,
|
||||
): Promise<AutomationRow> {
|
||||
try {
|
||||
return (await authApi.put(`/plugin-api/automations/${id}`, input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update automation");
|
||||
}
|
||||
}
|
||||
export type AutomationsApi = ReturnType<typeof createAutomationsApi>;
|
||||
|
||||
export async function deleteAutomation(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/plugin-api/automations/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAutomation(
|
||||
id: number,
|
||||
options: { dryRun?: boolean } = {},
|
||||
): Promise<AutomationRunOutcome> {
|
||||
try {
|
||||
return (await authApi.post(`/plugin-api/automations/${id}/run`, options))
|
||||
.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "run automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAutomationRuns(
|
||||
options: { automationId?: number; limit?: number; offset?: number } = {},
|
||||
): Promise<AutomationRunRow[]> {
|
||||
try {
|
||||
return (
|
||||
await authApi.get("/plugin-api/automations/runs/history", {
|
||||
params: options,
|
||||
})
|
||||
).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch automation runs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAutomationRunSteps(
|
||||
runId: number,
|
||||
): Promise<AutomationRunStepRow[]> {
|
||||
try {
|
||||
return (await authApi.get(`/plugin-api/automations/runs/${runId}/steps`))
|
||||
.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch run steps");
|
||||
}
|
||||
export function useAutomationsApi(): AutomationsApi {
|
||||
const api = usePluginApi();
|
||||
return useMemo(() => createAutomationsApi(api), [api]);
|
||||
}
|
||||
@@ -1,24 +1,23 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import { Switch } from "@/components/switch";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
AUTOMATION_DEFINITION_VERSION,
|
||||
type AutomationDefinition,
|
||||
type ConcurrencyPolicy,
|
||||
type Step,
|
||||
} from "@/types/automations";
|
||||
} from "../../types";
|
||||
import { StepBlock } from "./StepBlock";
|
||||
import { TriggerCard, defaultTrigger } from "./TriggerCard";
|
||||
import { newStepId, type AutomationEditorOptions } from "./editor-types";
|
||||
@@ -246,7 +245,9 @@ export function AutomationEditor({
|
||||
setDefinition(parsed as AutomationDefinition);
|
||||
setJsonError(null);
|
||||
} catch (error) {
|
||||
setJsonError(getErrorMessage(error, String(error)));
|
||||
setJsonError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Label } from "@/components/label";
|
||||
import {
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import type { HostSelector } from "@/types/automations";
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { HostSelector } from "../../types";
|
||||
import type { AutomationEditorOptions } from "./editor-types";
|
||||
|
||||
/**
|
||||
@@ -56,9 +56,11 @@ export function HostSelectorField({
|
||||
<SelectItem value="host">
|
||||
{t(`${base}.hostSelector.host`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="fleet">
|
||||
{t(`${base}.hostSelector.fleet`)}
|
||||
</SelectItem>
|
||||
{(options.providers.fleets || kind === "fleet") && (
|
||||
<SelectItem value="fleet">
|
||||
{t(`${base}.hostSelector.fleet`)}
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import { Switch } from "@/components/switch";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
Switch,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import type { Step, StepType } from "@/types/automations";
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
|
||||
import type { Step, StepType } from "../../types";
|
||||
import { HostSelectorField } from "./HostSelectorField";
|
||||
import type { AutomationEditorOptions } from "./editor-types";
|
||||
import {
|
||||
missingStepProvider,
|
||||
type AutomationEditorOptions,
|
||||
} from "./editor-types";
|
||||
|
||||
const STEP_TYPES: StepType[] = [
|
||||
"notify",
|
||||
@@ -86,11 +89,20 @@ export function StepBlock({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STEP_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{t(`${base}.stepTypes.${type}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{STEP_TYPES.map((type) => {
|
||||
const needs = missingStepProvider(type, options.providers);
|
||||
// A type whose plugin is off is only listed when this step
|
||||
// already uses it, marked as needing that plugin.
|
||||
if (needs && type !== step.type) return null;
|
||||
return (
|
||||
<SelectItem key={type} value={type}>
|
||||
{t(`${base}.stepTypes.${type}`)}
|
||||
{needs
|
||||
? ` (${t(`${base}.needsPlugin`, { plugin: needs })})`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import {
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import type { Trigger, TriggerKind } from "@/types/automations";
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { Trigger, TriggerKind } from "../../types";
|
||||
import { HostSelectorField } from "./HostSelectorField";
|
||||
import type { AutomationEditorOptions } from "./editor-types";
|
||||
import {
|
||||
missingTriggerProvider,
|
||||
type AutomationEditorOptions,
|
||||
} from "./editor-types";
|
||||
|
||||
const TRIGGER_KINDS: TriggerKind[] = [
|
||||
"metric_threshold",
|
||||
@@ -98,11 +101,20 @@ export function TriggerCard({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRIGGER_KINDS.map((kind) => (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{t(`${base}.triggerKinds.${kind}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{TRIGGER_KINDS.map((kind) => {
|
||||
const needs = missingTriggerProvider(kind, options.providers);
|
||||
// A kind whose plugin is off is only listed when this trigger
|
||||
// already uses it, marked as needing that plugin.
|
||||
if (needs && kind !== trigger.kind) return null;
|
||||
return (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{t(`${base}.triggerKinds.${kind}`)}
|
||||
{needs
|
||||
? ` (${t(`${base}.needsPlugin`, { plugin: needs })})`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { StepType, TriggerKind } from "../../types";
|
||||
import type { AutomationProviders } from "../automations-api";
|
||||
|
||||
/** Lookup data the editor needs to turn ids into names. */
|
||||
export interface AutomationEditorOptions {
|
||||
hosts: Array<{ id: number; name: string }>;
|
||||
snippets: Array<{ id: number; name: string }>;
|
||||
channels: Array<{ id: number; name: string }>;
|
||||
fleets: Array<{ id: number; name: string }>;
|
||||
/** Which optional plugins are running. */
|
||||
providers: AutomationProviders;
|
||||
}
|
||||
|
||||
export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
|
||||
@@ -11,9 +16,57 @@ export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
|
||||
snippets: [],
|
||||
channels: [],
|
||||
fleets: [],
|
||||
providers: {
|
||||
snippets: false,
|
||||
fleets: false,
|
||||
tunnels: false,
|
||||
docker: false,
|
||||
"docker-events": false,
|
||||
"host-metrics": false,
|
||||
"wake-on-lan": false,
|
||||
},
|
||||
};
|
||||
|
||||
/** Short unique id for a new step, stable for the life of the automation. */
|
||||
export function newStepId(): string {
|
||||
return `s${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** The plugin a trigger kind needs, when it is not core. */
|
||||
const TRIGGER_PROVIDERS: Partial<
|
||||
Record<TriggerKind, keyof AutomationProviders>
|
||||
> = {
|
||||
metric_threshold: "host-metrics",
|
||||
health_check: "host-metrics",
|
||||
docker_event: "docker-events",
|
||||
};
|
||||
|
||||
/** The plugin a step type needs, when it is not core. */
|
||||
const STEP_PROVIDERS: Partial<Record<StepType, keyof AutomationProviders>> = {
|
||||
run_snippet: "snippets",
|
||||
docker: "docker",
|
||||
tunnel: "tunnels",
|
||||
wol: "wake-on-lan",
|
||||
};
|
||||
|
||||
/** The plugin id shown in "needs <plugin>", for a provider key. */
|
||||
export function providerPlugin(key: keyof AutomationProviders): string {
|
||||
return key === "docker-events" ? "docker" : key;
|
||||
}
|
||||
|
||||
/** The provider a kind needs that is not running, or null when it can run. */
|
||||
export function missingTriggerProvider(
|
||||
kind: TriggerKind,
|
||||
providers: AutomationProviders,
|
||||
): string | null {
|
||||
const key = TRIGGER_PROVIDERS[kind];
|
||||
return key && !providers[key] ? providerPlugin(key) : null;
|
||||
}
|
||||
|
||||
export function missingStepProvider(
|
||||
type: StepType,
|
||||
providers: AutomationProviders,
|
||||
): string | null {
|
||||
const key = STEP_PROVIDERS[type];
|
||||
return key && !providers[key] ? providerPlugin(key) : null;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { AutomationsPanel } from "./AutomationsPanel";
|
||||
import { listAutomations } from "./automations-api";
|
||||
import { createAutomationsApi } from "./automations-api";
|
||||
|
||||
const VIEW_ID = "automations";
|
||||
|
||||
@@ -40,7 +40,8 @@ export function activate(app: TermixApp): void {
|
||||
});
|
||||
|
||||
// The assistant's @-mentions list automations through this.
|
||||
app.registerAction("automations.list", () => listAutomations());
|
||||
const api = createAutomationsApi(app.api);
|
||||
app.registerAction("automations.list", () => api.list());
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "automations.feature",
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,82 @@
|
||||
import express from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import {
|
||||
NOTIFICATION_CHANNELS_DDL,
|
||||
definition,
|
||||
manifest,
|
||||
pluginDir,
|
||||
startServer,
|
||||
type TestServer,
|
||||
} from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("automations activate", () => {
|
||||
it("provides automations.access and mounts the webhook as public", async () => {
|
||||
server = await startServer();
|
||||
expect(server.mock.services.get("automations.access")).toBeDefined();
|
||||
expect(server.mock.httpRouters).toEqual([{ public: ["/webhook/:token"] }]);
|
||||
});
|
||||
|
||||
it.each(["db:own", "network:serve"])(
|
||||
"fails closed without %s",
|
||||
async (capability) => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
|
||||
});
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot notify without notify:send", async () => {
|
||||
server = await startServer();
|
||||
const withoutNotify = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "notify:send"),
|
||||
});
|
||||
await expect(
|
||||
withoutNotify.ctx.notify.send([1], { title: "t", body: "b" }),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("stops every timer on deactivate", async () => {
|
||||
server = await startServer();
|
||||
await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "Tick",
|
||||
definition: definition({ kind: "schedule", intervalSeconds: 300 }),
|
||||
},
|
||||
});
|
||||
for (const dispose of [...server.mock.disposals].reverse()) {
|
||||
await Promise.resolve(dispose()).catch(() => {});
|
||||
}
|
||||
server.mock.disposals.length = 0;
|
||||
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const fired = vi.hoisted(() => [] as unknown[]);
|
||||
|
||||
vi.mock("../../src/backend/triggers.js", () => ({
|
||||
onDockerEvent: async (event: unknown) => {
|
||||
fired.push(event);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => ({ listAllEnabled: async () => [] }),
|
||||
}));
|
||||
|
||||
import {
|
||||
reconcileDockerWatch,
|
||||
resetDockerWatcher,
|
||||
} from "../../src/backend/docker-watcher.js";
|
||||
import {
|
||||
setDockerServices,
|
||||
runDockerAction,
|
||||
} from "../../src/backend/docker.js";
|
||||
|
||||
type Listener = (event: {
|
||||
hostId: number;
|
||||
container: string;
|
||||
event: string;
|
||||
}) => void;
|
||||
|
||||
/** A docker.events provider that records who subscribed to what. */
|
||||
function fakeEvents() {
|
||||
const subscriptions = new Map<string, Set<Listener>>();
|
||||
const calls: Array<{ userId?: string; hostId: number }> = [];
|
||||
const services = {
|
||||
provide: () => {},
|
||||
providers: () => [],
|
||||
get: (name: string, options?: { userId?: string }) => {
|
||||
if (name !== "docker.events") return {};
|
||||
return {
|
||||
subscribe: async (hostId: number, listener: Listener) => {
|
||||
calls.push({ userId: options?.userId, hostId });
|
||||
const key = `${options?.userId}:${hostId}`;
|
||||
const set = subscriptions.get(key) ?? new Set<Listener>();
|
||||
set.add(listener);
|
||||
subscriptions.set(key, set);
|
||||
return () => set.delete(listener);
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as PluginServices;
|
||||
const emit = (key: string, event: Parameters<Listener>[0]) => {
|
||||
for (const listener of subscriptions.get(key) ?? []) listener(event);
|
||||
};
|
||||
return { services, subscriptions, calls, emit };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fired.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetDockerWatcher();
|
||||
setDockerServices(null);
|
||||
});
|
||||
|
||||
describe("docker watcher", () => {
|
||||
it("subscribes each watched host as its owner and fires the trigger", async () => {
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
|
||||
await reconcileDockerWatch(0, new Map([[7, "user-1"]]));
|
||||
expect(events.calls).toEqual([{ userId: "user-1", hostId: 7 }]);
|
||||
|
||||
events.emit("user-1:7", { hostId: 7, container: "web", event: "exited" });
|
||||
await Promise.resolve();
|
||||
expect(fired).toEqual([
|
||||
{ hostId: 7, ownerUserId: "user-1", container: "web", event: "exited" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-subscribes once a minute and drops hosts no longer watched", async () => {
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
const watched = new Map([[7, "user-1"]]);
|
||||
|
||||
await reconcileDockerWatch(1_000, watched);
|
||||
await reconcileDockerWatch(30_000, watched);
|
||||
expect(events.calls).toHaveLength(1);
|
||||
await reconcileDockerWatch(62_000, watched);
|
||||
expect(events.calls).toHaveLength(2);
|
||||
expect(events.subscriptions.get("user-1:7")?.size).toBe(1);
|
||||
|
||||
await reconcileDockerWatch(63_000, new Map());
|
||||
expect(events.subscriptions.get("user-1:7")?.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does nothing while the docker plugin is off, then picks it up", async () => {
|
||||
setDockerServices({
|
||||
get: () => ({}),
|
||||
} as unknown as PluginServices);
|
||||
await expect(
|
||||
reconcileDockerWatch(0, new Map([[7, "user-1"]])),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
await reconcileDockerWatch(1, new Map([[7, "user-1"]]));
|
||||
expect(events.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails a docker step cleanly while the plugin is off", async () => {
|
||||
setDockerServices({ get: () => ({}) } as unknown as PluginServices);
|
||||
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
|
||||
ok: false,
|
||||
unavailable: true,
|
||||
error: "The docker plugin is not available",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs a docker step through the docker service", async () => {
|
||||
const action = vi.fn(async () => {});
|
||||
setDockerServices({
|
||||
get: (name: string) => (name === "docker.containers" ? { action } : {}),
|
||||
} as unknown as PluginServices);
|
||||
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(action).toHaveBeenCalledWith(7, "web", "restart");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { definition, startServer, type TestServer } from "./helpers";
|
||||
|
||||
/**
|
||||
* The engine as activate() wires it: the ctx.schedule tick, the services it
|
||||
* reaches, and what happens when a plugin an automation needs is off.
|
||||
*/
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function create(
|
||||
s: TestServer,
|
||||
name: string,
|
||||
body: ReturnType<typeof definition>,
|
||||
): Promise<number> {
|
||||
const created = await s.request("POST", "/", {
|
||||
body: { name, definition: body },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
return created.body.id;
|
||||
}
|
||||
|
||||
function makeDue(s: TestServer, automationId: number) {
|
||||
s.db.sqlite
|
||||
.prepare(
|
||||
"UPDATE p_automations_schedules SET next_due_at = ? WHERE automation_id = ?",
|
||||
)
|
||||
.run(new Date(Date.now() - 60_000).toISOString(), automationId);
|
||||
}
|
||||
|
||||
describe("scheduled automations", () => {
|
||||
it("fire from the ctx.schedule tick and notify as the owner", async () => {
|
||||
server = await startServer();
|
||||
const id = await create(
|
||||
server,
|
||||
"Every five minutes",
|
||||
definition({ kind: "schedule", intervalSeconds: 300 }, [
|
||||
{
|
||||
id: "n",
|
||||
type: "notify",
|
||||
channelIds: [1],
|
||||
title: "Ping {{trigger.type}}",
|
||||
body: "hello",
|
||||
},
|
||||
]),
|
||||
);
|
||||
makeDue(server, id);
|
||||
|
||||
// The tick is registered through ctx.schedule, never a bare timer.
|
||||
expect(server.mock.scheduled.filter((job) => !job.stopped).length).toBe(2);
|
||||
await server.mock.runScheduled();
|
||||
|
||||
expect(await server.waitForRun(id)).toEqual({
|
||||
status: "success",
|
||||
error: null,
|
||||
});
|
||||
expect(server.mock.notifications).toEqual([
|
||||
{
|
||||
actor: "alice",
|
||||
channelIds: [1],
|
||||
notification: expect.objectContaining({
|
||||
title: "Ping schedule",
|
||||
body: "hello",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
// The next due time moved forward, so the next tick does not re-run it.
|
||||
const due = server.db.sqlite
|
||||
.prepare(
|
||||
"SELECT next_due_at FROM p_automations_schedules WHERE automation_id = ?",
|
||||
)
|
||||
.get(id) as { next_due_at: string };
|
||||
expect(new Date(due.next_due_at).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("are skipped with a reason while the docker plugin is off, and the engine keeps going", async () => {
|
||||
server = await startServer();
|
||||
const needsDocker = await create(
|
||||
server,
|
||||
"Restart app",
|
||||
definition({ kind: "schedule", intervalSeconds: 300 }, [
|
||||
{
|
||||
id: "d",
|
||||
type: "docker",
|
||||
action: "restart",
|
||||
container: "app",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const plain = await create(
|
||||
server,
|
||||
"Plain",
|
||||
definition({ kind: "schedule", intervalSeconds: 300 }),
|
||||
);
|
||||
makeDue(server, needsDocker);
|
||||
makeDue(server, plain);
|
||||
|
||||
await server.mock.runScheduled();
|
||||
|
||||
expect(await server.waitForRun(needsDocker)).toEqual({
|
||||
status: "skipped",
|
||||
error: "Needs the docker plugin",
|
||||
});
|
||||
expect(await server.waitForRun(plain)).toEqual({
|
||||
status: "success",
|
||||
error: null,
|
||||
});
|
||||
|
||||
// A manual run says the same thing instead of throwing.
|
||||
const manual = await server.request("POST", `/${needsDocker}/run`, {
|
||||
body: {},
|
||||
});
|
||||
expect(manual.status).toBe(200);
|
||||
expect(manual.body).toMatchObject({
|
||||
status: "skipped",
|
||||
error: "Needs the docker plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("run once docker is back", async () => {
|
||||
const actions: unknown[] = [];
|
||||
server = await startServer({
|
||||
services: {
|
||||
"docker.containers": {
|
||||
action: async (...args: unknown[]) => {
|
||||
actions.push(args);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"Restart app",
|
||||
definition({ kind: "schedule", intervalSeconds: 300 }, [
|
||||
{
|
||||
id: "d",
|
||||
type: "docker",
|
||||
action: "restart",
|
||||
container: "app",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
// Host 1 does not resolve here, so the step has no target, but the
|
||||
// automation is no longer skipped for a missing plugin.
|
||||
expect(outcome.body.status).not.toBe("skipped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("automations.access", () => {
|
||||
it("lists, reads, creates disabled and runs as the caller", async () => {
|
||||
server = await startServer();
|
||||
const service = server.service;
|
||||
|
||||
const created = await server.mock.ctx.asUser("alice", () =>
|
||||
service.create({
|
||||
name: "From the assistant",
|
||||
definition: definition({ kind: "schedule", intervalSeconds: 600 }),
|
||||
}),
|
||||
);
|
||||
const listed = await server.mock.ctx.asUser("alice", () => service.list());
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: created.id,
|
||||
enabled: false,
|
||||
triggerKind: "schedule",
|
||||
missingPlugins: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
const one = await server.mock.ctx.asUser("alice", () =>
|
||||
service.get(created.id),
|
||||
);
|
||||
expect(one?.definition?.trigger.kind).toBe("schedule");
|
||||
expect(
|
||||
await server.mock.ctx.asUser("bob", () => service.get(created.id)),
|
||||
).toBeNull();
|
||||
|
||||
const outcome = await server.mock.ctx.asUser("alice", () =>
|
||||
service.run(created.id, { dryRun: true }),
|
||||
);
|
||||
expect(outcome.status).toBe("success");
|
||||
});
|
||||
|
||||
it("refuses an invalid definition", async () => {
|
||||
server = await startServer();
|
||||
await expect(
|
||||
server.mock.ctx.asUser("alice", () =>
|
||||
server!.service.create({ name: "x", definition: { steps: [] } }),
|
||||
),
|
||||
).rejects.toThrow(/trigger/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional steps", () => {
|
||||
it("fail the step cleanly while the tunnels plugin is off", async () => {
|
||||
server = await startServer();
|
||||
const id = await create(
|
||||
server,
|
||||
"Tunnel",
|
||||
definition({ kind: "webhook" }, [
|
||||
{ id: "t", type: "tunnel", action: "connect", tunnelName: "db" },
|
||||
]),
|
||||
);
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
expect(outcome.body).toMatchObject({
|
||||
status: "skipped",
|
||||
error: "Needs the tunnels plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("start a tunnel through tunnels.access", async () => {
|
||||
const started: string[] = [];
|
||||
server = await startServer({
|
||||
services: {
|
||||
"tunnels.access": {
|
||||
start: async (name: string) => {
|
||||
started.push(name);
|
||||
},
|
||||
stop: async () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"Tunnel",
|
||||
definition({ kind: "webhook" }, [
|
||||
{ id: "t", type: "tunnel", action: "connect", tunnelName: "db" },
|
||||
]),
|
||||
);
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
expect(outcome.body.status).toBe("success");
|
||||
expect(started).toEqual(["db"]);
|
||||
});
|
||||
|
||||
it("wake a host through the wake-on-lan service", async () => {
|
||||
const woken: number[] = [];
|
||||
server = await startServer({
|
||||
services: {
|
||||
"wake-on-lan.send": {
|
||||
wake: async (hostId: number) => {
|
||||
woken.push(hostId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"Wake",
|
||||
definition({ kind: "webhook" }, [{ id: "w", type: "wol", hostId: 2 }]),
|
||||
);
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
expect(outcome.body.status).toBe("success");
|
||||
expect(woken).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("watchers", () => {
|
||||
it("subscribe to docker.events as the owner and fire docker_event triggers", async () => {
|
||||
let listener: ((event: unknown) => void) | null = null;
|
||||
const subscribedAs: Array<string | undefined> = [];
|
||||
server = await startServer({
|
||||
services: {
|
||||
"docker.events": {
|
||||
subscribe: async (_hostId: number, fn: (event: unknown) => void) => {
|
||||
listener = fn;
|
||||
subscribedAs.push(server?.mock.ctx.currentActor());
|
||||
return () => {
|
||||
listener = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"Container died",
|
||||
definition({
|
||||
kind: "docker_event",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
container: "app",
|
||||
event: "exited",
|
||||
cooldownMinutes: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await server.mock.runScheduled();
|
||||
expect(subscribedAs).toEqual(["alice"]);
|
||||
expect(listener).not.toBeNull();
|
||||
|
||||
listener!({ hostId: 1, container: "app", event: "exited" });
|
||||
expect(await server.waitForRun(id)).toEqual({
|
||||
status: "success",
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keep headless metrics viewers registered as the owner", async () => {
|
||||
const registered: Array<[number, string | undefined]> = [];
|
||||
let beats = 0;
|
||||
server = await startServer({
|
||||
services: {
|
||||
"host-metrics.viewers": {
|
||||
register: async (hostId: number) => {
|
||||
registered.push([hostId, server?.mock.ctx.currentActor()]);
|
||||
return { viewerSessionId: `v${hostId}` };
|
||||
},
|
||||
heartbeat: () => {
|
||||
beats++;
|
||||
return true;
|
||||
},
|
||||
unregister: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
await create(
|
||||
server,
|
||||
"CPU",
|
||||
definition({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "hosts", hostIds: [1, 2] },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
}),
|
||||
);
|
||||
|
||||
await server.mock.runScheduled();
|
||||
expect(registered).toEqual([
|
||||
[1, "alice"],
|
||||
[2, "alice"],
|
||||
]);
|
||||
const before = beats;
|
||||
await server.mock.runScheduled();
|
||||
// Each tick heartbeats both viewers and registers nothing new.
|
||||
expect(beats).toBeGreaterThan(before);
|
||||
expect((beats - before) % 2).toBe(0);
|
||||
expect(registered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("fire a metric trigger from a host-metrics snapshot event", async () => {
|
||||
server = await startServer({
|
||||
services: {
|
||||
"host-metrics.viewers": {
|
||||
register: async () => ({ viewerSessionId: "v" }),
|
||||
heartbeat: () => true,
|
||||
unregister: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"CPU",
|
||||
definition({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
}),
|
||||
);
|
||||
|
||||
server.mock.ctx.events.emit("plugin.host-metrics.snapshot", {
|
||||
hostId: 1,
|
||||
ownerUserId: "alice",
|
||||
metrics: { cpu: { percent: 97 } },
|
||||
});
|
||||
expect(await server.waitForRun(id)).toEqual({
|
||||
status: "success",
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
} from "../../../../src/types/automations.js";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
|
||||
import type { AutomationDefinition, Step } from "../../src/types.js";
|
||||
|
||||
/**
|
||||
* The engine reaches the database through the repository factory and the
|
||||
* outside world through the step executors, so both are mocked here. What is
|
||||
* under test is the run loop itself: ordering, branching, error policy,
|
||||
* The engine reaches the database through its repository and the outside
|
||||
* world through the step executors, so both are faked here. What is under
|
||||
* test is the run loop itself: ordering, branching, error policy,
|
||||
* concurrency, recursion and dry-run.
|
||||
*/
|
||||
|
||||
@@ -50,15 +49,7 @@ const repository = {
|
||||
}),
|
||||
};
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
|
||||
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
let hosts: PluginHostSummary[] = [];
|
||||
|
||||
const executeStep = vi.fn();
|
||||
vi.mock("../../src/backend/actions/index.js", () => ({
|
||||
@@ -67,6 +58,18 @@ vi.mock("../../src/backend/actions/index.js", () => ({
|
||||
|
||||
const { AutomationEngine } = await import("../../src/backend/engine.js");
|
||||
|
||||
// Every provider is running unless a test says otherwise.
|
||||
const deps = { available: () => true };
|
||||
|
||||
let current: InstanceType<typeof AutomationEngine>;
|
||||
function getEngine() {
|
||||
return current;
|
||||
}
|
||||
function freshEngine() {
|
||||
const { ctx } = createFakeContext({ pluginId: "automations", hosts });
|
||||
current = new AutomationEngine(ctx, repository as never, deps as never);
|
||||
}
|
||||
|
||||
function defineAutomation(
|
||||
steps: Step[],
|
||||
overrides: Partial<FakeAutomation> = {},
|
||||
@@ -102,23 +105,30 @@ beforeEach(() => {
|
||||
nextRunId = 1;
|
||||
nextStepRowId = 1;
|
||||
vi.clearAllMocks();
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
hosts = [];
|
||||
executeStep.mockResolvedValue({ success: true, output: "ok" });
|
||||
// The singleton carries in-flight state between tests.
|
||||
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
|
||||
freshEngine();
|
||||
});
|
||||
|
||||
describe("AutomationEngine.run", () => {
|
||||
it("adds the trigger host name to the template context", async () => {
|
||||
defineAutomation([step({ id: "notify", type: "notify" })]);
|
||||
resolveHostById.mockResolvedValue({
|
||||
name: "Proxmox Node",
|
||||
ip: "10.0.0.11",
|
||||
username: "root",
|
||||
port: 22,
|
||||
});
|
||||
hosts = [
|
||||
{
|
||||
id: 11,
|
||||
userId: "user-1",
|
||||
name: "Proxmox Node",
|
||||
ip: "10.0.0.11",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
},
|
||||
];
|
||||
freshEngine();
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "metric_threshold",
|
||||
triggerHostId: 11,
|
||||
@@ -143,7 +153,7 @@ describe("AutomationEngine.run", () => {
|
||||
step({ id: "b", type: "http" }),
|
||||
]);
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -161,7 +171,7 @@ describe("AutomationEngine.run", () => {
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -178,7 +188,7 @@ describe("AutomationEngine.run", () => {
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -193,7 +203,7 @@ describe("AutomationEngine.run", () => {
|
||||
step({ id: "b", type: "http" }),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -209,7 +219,7 @@ describe("AutomationEngine.run", () => {
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({ success: true, output: "hello" });
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -231,7 +241,7 @@ describe("AutomationEngine.run", () => {
|
||||
vars: { x: "1" },
|
||||
});
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -254,7 +264,7 @@ describe("AutomationEngine.run", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -274,7 +284,7 @@ describe("AutomationEngine.run", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -296,7 +306,7 @@ describe("AutomationEngine.run", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "metric_threshold",
|
||||
triggerContext: { value: 95 },
|
||||
@@ -316,7 +326,7 @@ describe("AutomationEngine.run", () => {
|
||||
step({ id: "after", type: "http" }),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -335,7 +345,7 @@ describe("AutomationEngine.run", () => {
|
||||
halt: { status: "success" },
|
||||
});
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -351,7 +361,7 @@ describe("AutomationEngine.run", () => {
|
||||
halt: { status: "failed" },
|
||||
});
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -365,7 +375,7 @@ describe("AutomationEngine.run", () => {
|
||||
step({ id: "nested", type: "run_automation", automationId: 1 }),
|
||||
]);
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -389,7 +399,7 @@ describe("AutomationEngine.run", () => {
|
||||
{ id: 2 },
|
||||
);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -402,7 +412,7 @@ describe("AutomationEngine.run", () => {
|
||||
it("refuses to nest deeper than the maximum depth", async () => {
|
||||
defineAutomation([step({ id: "a", type: "http" })]);
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
depth: 99,
|
||||
@@ -428,7 +438,7 @@ describe("AutomationEngine.run", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const engine = AutomationEngine.getInstance();
|
||||
const engine = getEngine();
|
||||
const first = engine.run({ automationId: 1, triggerType: "manual" });
|
||||
// Let the first run register itself as in flight.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
@@ -461,7 +471,7 @@ describe("AutomationEngine.run", () => {
|
||||
|
||||
// No gap between the two: the in-flight slot used to be claimed several
|
||||
// awaits after it was checked, so both runs got through.
|
||||
const engine = AutomationEngine.getInstance();
|
||||
const engine = getEngine();
|
||||
const [first, second] = await Promise.all([
|
||||
engine.run({ automationId: 1, triggerType: "manual" }),
|
||||
engine.run({ automationId: 1, triggerType: "manual" }),
|
||||
@@ -478,7 +488,7 @@ describe("AutomationEngine.run", () => {
|
||||
defineAutomation([step({ id: "a", type: "run_command" })]);
|
||||
repository.createRun.mockRejectedValueOnce(new Error("db down"));
|
||||
|
||||
const engine = AutomationEngine.getInstance();
|
||||
const engine = getEngine();
|
||||
const failed = await engine.run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
@@ -497,7 +507,7 @@ describe("AutomationEngine.run", () => {
|
||||
it("propagates the dry-run flag to executors", async () => {
|
||||
defineAutomation([step({ id: "a", type: "http" })], { dryRun: true });
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -509,7 +519,7 @@ describe("AutomationEngine.run", () => {
|
||||
it("lets a caller force a dry run on a live automation", async () => {
|
||||
defineAutomation([step({ id: "a", type: "http" })], { dryRun: false });
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
dryRun: true,
|
||||
@@ -520,7 +530,7 @@ describe("AutomationEngine.run", () => {
|
||||
});
|
||||
|
||||
it("fails cleanly when the automation is missing", async () => {
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 404,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -539,7 +549,7 @@ describe("AutomationEngine.run", () => {
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -551,7 +561,7 @@ describe("AutomationEngine.run", () => {
|
||||
defineAutomation([step({ id: "a", type: "run_command" })]);
|
||||
executeStep.mockRejectedValueOnce(new Error("connection reset"));
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -568,7 +578,7 @@ describe("AutomationEngine.run", () => {
|
||||
output: "x".repeat(40_000),
|
||||
});
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -591,7 +601,7 @@ describe("AutomationEngine.run", () => {
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listAutomationWatchedHosts = vi.fn();
|
||||
vi.mock("../../src/backend/triggers.js", () => ({
|
||||
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
|
||||
}));
|
||||
|
||||
const {
|
||||
automationSessionId,
|
||||
reconcileHeadlessViewers,
|
||||
releaseHeadlessViewers,
|
||||
setViewerRegistry,
|
||||
} = await import("../../src/backend/headless-viewer.js");
|
||||
|
||||
const registry = {
|
||||
registerViewer: vi.fn(),
|
||||
unregisterViewer: vi.fn(),
|
||||
updateHeartbeat: vi.fn(() => true),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Drop any viewers the previous test left behind before the mocks are
|
||||
// cleared, so those unregister calls are not counted against this test.
|
||||
releaseHeadlessViewers();
|
||||
vi.clearAllMocks();
|
||||
setViewerRegistry(registry);
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map());
|
||||
});
|
||||
|
||||
describe("reconcileHeadlessViewers", () => {
|
||||
it("registers a synthetic viewer for each watched host", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(
|
||||
new Map([
|
||||
[7, "user-1"],
|
||||
[9, "user-2"],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await reconcileHeadlessViewers();
|
||||
|
||||
expect(result.added).toBe(2);
|
||||
expect(registry.registerViewer).toHaveBeenCalledWith(
|
||||
7,
|
||||
"automation:7",
|
||||
"user-1",
|
||||
);
|
||||
expect(registry.registerViewer).toHaveBeenCalledWith(
|
||||
9,
|
||||
"automation:9",
|
||||
"user-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("heartbeats instead of re-registering a host it already holds", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
|
||||
await reconcileHeadlessViewers();
|
||||
registry.registerViewer.mockClear();
|
||||
|
||||
const second = await reconcileHeadlessViewers();
|
||||
|
||||
// The 120s reaper drops viewers with a stale heartbeat, so every tick has
|
||||
// to refresh the ones it is keeping.
|
||||
expect(registry.updateHeartbeat).toHaveBeenCalledWith("automation:7");
|
||||
expect(registry.registerViewer).not.toHaveBeenCalled();
|
||||
expect(second.added).toBe(0);
|
||||
expect(second.active).toBe(1);
|
||||
});
|
||||
|
||||
it("releases a viewer once no automation watches the host", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
|
||||
await reconcileHeadlessViewers();
|
||||
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map());
|
||||
const result = await reconcileHeadlessViewers();
|
||||
|
||||
expect(result.removed).toBe(1);
|
||||
expect(result.active).toBe(0);
|
||||
expect(registry.unregisterViewer).toHaveBeenCalledWith(7, "automation:7");
|
||||
});
|
||||
|
||||
it("does nothing when no registry has been wired up", async () => {
|
||||
setViewerRegistry(null);
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
|
||||
|
||||
const result = await reconcileHeadlessViewers();
|
||||
|
||||
expect(result).toEqual({ added: 0, removed: 0, active: 0 });
|
||||
expect(registry.registerViewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps going when the watch list cannot be loaded", async () => {
|
||||
listAutomationWatchedHosts.mockRejectedValue(new Error("db down"));
|
||||
await expect(reconcileHeadlessViewers()).resolves.toMatchObject({
|
||||
added: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("survives a registry that throws on register", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(
|
||||
new Map([
|
||||
[7, "user-1"],
|
||||
[8, "user-1"],
|
||||
]),
|
||||
);
|
||||
registry.registerViewer.mockImplementationOnce(() => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
|
||||
const result = await reconcileHeadlessViewers();
|
||||
|
||||
// One host failing must not stop the other from being registered.
|
||||
expect(result.added).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseHeadlessViewers", () => {
|
||||
it("drops every viewer it is holding", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(
|
||||
new Map([
|
||||
[7, "user-1"],
|
||||
[8, "user-1"],
|
||||
]),
|
||||
);
|
||||
await reconcileHeadlessViewers();
|
||||
|
||||
releaseHeadlessViewers();
|
||||
|
||||
expect(registry.unregisterViewer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("automationSessionId", () => {
|
||||
it("namespaces the session so it cannot collide with a real viewer", () => {
|
||||
expect(automationSessionId(42)).toBe("automation:42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type {
|
||||
PluginHostSummary,
|
||||
PluginNotificationChannel,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import type { AutomationsAccessV1 } from "../../src/backend/service.js";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
"automations.view",
|
||||
"automations.create",
|
||||
"automations.edit",
|
||||
"automations.delete",
|
||||
"automations.run",
|
||||
];
|
||||
|
||||
/** Core's notification_channels, which automation_channels points at. */
|
||||
export const NOTIFICATION_CHANNELS_DDL = `
|
||||
CREATE TABLE notification_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
export function host(id: number, userId = "alice"): PluginHostSummary {
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
name: `host-${id}`,
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
};
|
||||
}
|
||||
|
||||
/** A snippets.access provider, always there since snippets is a hard dependency. */
|
||||
export function fakeSnippets() {
|
||||
return {
|
||||
list: async () => [{ id: 5, name: "Restart nginx", isNote: false }],
|
||||
get: async (id: number) =>
|
||||
id === 5
|
||||
? {
|
||||
id: 5,
|
||||
name: "Restart nginx",
|
||||
content: "systemctl restart nginx",
|
||||
isNote: false,
|
||||
}
|
||||
: null,
|
||||
resolveCommand: async (id: number) =>
|
||||
id === 5 ? "systemctl restart nginx" : null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
service: AutomationsAccessV1;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string | null; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
/** Waits until a run for the automation reaches a final status. */
|
||||
waitForRun: (
|
||||
automationId: number,
|
||||
) => Promise<{ status: string; error: string | null }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* the way core mounts it: JSON parsed and the acting user set from the
|
||||
* request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
services?: Record<string, object>;
|
||||
hosts?: PluginHostSummary[];
|
||||
channels?: PluginNotificationChannel[];
|
||||
before?: (db: TestDb["sqlite"]) => void;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec(NOTIFICATION_CHANNELS_DDL);
|
||||
options.before?.(sqlite);
|
||||
},
|
||||
});
|
||||
for (const user of ["alice", "bob"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
const channels = options.channels ?? [
|
||||
{ id: 1, name: "ops", type: "webhook", enabled: true },
|
||||
];
|
||||
for (const channel of channels) {
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO notification_channels (id, user_id, name, type, config, enabled) VALUES (?, 'alice', ?, ?, '{}', ?)",
|
||||
)
|
||||
.run(channel.id, channel.name, channel.type, channel.enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ALL_PERMISSIONS,
|
||||
hosts: options.hosts ?? [host(1), host(2)],
|
||||
notificationChannels: channels,
|
||||
services: { "snippets.access": fakeSnippets(), ...options.services },
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") || undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
service: mock.services.get("automations.access") as AutomationsAccessV1,
|
||||
async request(method, path, { user = "alice", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(user ? { "x-test-user": user } : {}),
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async waitForRun(automationId) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const row = db.sqlite
|
||||
.prepare(
|
||||
"SELECT status, error FROM p_automations_runs WHERE automation_id = ? AND status != 'running' ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.get(automationId) as
|
||||
{ status: string; error: string | null } | undefined;
|
||||
if (row) return row;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`No finished run for automation ${automationId}`);
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
for (const dispose of [...mock.disposals].reverse()) {
|
||||
await Promise.resolve(dispose()).catch(() => {});
|
||||
}
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A definition with one step, for tests that only care about the trigger. */
|
||||
export function definition(
|
||||
trigger: Record<string, unknown>,
|
||||
steps: Array<Record<string, unknown>> = [
|
||||
{ id: "v", type: "set_var", name: "done", value: "yes" },
|
||||
],
|
||||
) {
|
||||
return { version: 1, trigger, steps };
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { NOTIFICATION_CHANNELS_DDL, pluginDir } from "./helpers";
|
||||
|
||||
// The six tables as core's SQLite bootstrap created them before 2.9.0, with
|
||||
// the indexes performance-indexes.ts added.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE automations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
definition TEXT NOT NULL,
|
||||
definition_version INTEGER NOT NULL DEFAULT 1,
|
||||
concurrency_policy TEXT NOT NULL DEFAULT 'skip',
|
||||
max_run_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
last_run_at TEXT,
|
||||
last_run_status TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE automation_trigger_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
state_key TEXT NOT NULL,
|
||||
breach_started_at TEXT,
|
||||
last_fired_at TEXT,
|
||||
last_value REAL,
|
||||
last_observed_state TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE automation_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
cron TEXT,
|
||||
interval_seconds INTEGER,
|
||||
timezone TEXT,
|
||||
next_due_at TEXT,
|
||||
last_tick_at TEXT
|
||||
);
|
||||
CREATE TABLE automation_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_context TEXT,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
error TEXT,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
parent_run_id INTEGER
|
||||
);
|
||||
CREATE TABLE automation_run_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE,
|
||||
step_index INTEGER NOT NULL,
|
||||
step_id TEXT NOT NULL,
|
||||
step_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at TEXT,
|
||||
output TEXT,
|
||||
error TEXT,
|
||||
truncated INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE automation_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_automations_user ON automations (user_id, enabled);
|
||||
CREATE UNIQUE INDEX idx_automation_trigger_state_key ON automation_trigger_state (automation_id, state_key);
|
||||
CREATE UNIQUE INDEX idx_automation_schedules_automation ON automation_schedules (automation_id);
|
||||
CREATE INDEX idx_automation_schedules_due ON automation_schedules (next_due_at);
|
||||
CREATE INDEX idx_automation_runs_automation ON automation_runs (automation_id, started_at);
|
||||
CREATE INDEX idx_automation_runs_user ON automation_runs (user_id, started_at);
|
||||
CREATE INDEX idx_automation_run_steps_run ON automation_run_steps (run_id, step_index);
|
||||
CREATE UNIQUE INDEX idx_automation_channels_pair ON automation_channels (automation_id, channel_id);
|
||||
`;
|
||||
|
||||
const SEED = `
|
||||
INSERT INTO users (id, username) VALUES ('alice', 'alice');
|
||||
INSERT INTO notification_channels (id, user_id, name, type, config) VALUES (4, 'alice', 'ops', 'webhook', '{}');
|
||||
INSERT INTO automations (id, user_id, name, definition) VALUES (7, 'alice', 'Disk', '{"version":1,"trigger":{"kind":"webhook","tokenHash":"x"},"steps":[]}');
|
||||
INSERT INTO automation_trigger_state (automation_id, state_key, last_fired_at) VALUES (7, '1:/data', '2026-01-01T00:00:00Z');
|
||||
INSERT INTO automation_schedules (automation_id, interval_seconds, next_due_at) VALUES (7, 300, '2026-01-01T00:05:00Z');
|
||||
INSERT INTO automation_runs (id, automation_id, user_id, trigger_type, status) VALUES (3, 7, 'alice', 'manual', 'success');
|
||||
INSERT INTO automation_run_steps (run_id, step_index, step_id, step_type, status) VALUES (3, 0, 'a', 'notify', 'success');
|
||||
INSERT INTO automation_channels (automation_id, channel_id) VALUES (7, 4);
|
||||
`;
|
||||
|
||||
const ADOPTED = [
|
||||
"p_automations_automations",
|
||||
"p_automations_trigger_state",
|
||||
"p_automations_schedules",
|
||||
"p_automations_runs",
|
||||
"p_automations_run_steps",
|
||||
"p_automations_channels",
|
||||
];
|
||||
|
||||
const LEGACY = [
|
||||
"automations",
|
||||
"automation_trigger_state",
|
||||
"automation_schedules",
|
||||
"automation_runs",
|
||||
"automation_run_steps",
|
||||
"automation_channels",
|
||||
];
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
function tables(target: TestDb): string[] {
|
||||
return (
|
||||
target.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all() as Array<{ name: string }>
|
||||
).map((row) => row.name);
|
||||
}
|
||||
|
||||
function indexes(target: TestDb, table: string): string[] {
|
||||
return (
|
||||
target.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
.all(table) as Array<{ name: string }>
|
||||
)
|
||||
.map((row) => row.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const count = (target: TestDb, table: string) =>
|
||||
(
|
||||
target.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
|
||||
describe("adopting the automation tables", () => {
|
||||
it("keeps every row, index and cascade when the legacy tables exist", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec(NOTIFICATION_CHANNELS_DDL);
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite.exec(SEED);
|
||||
},
|
||||
});
|
||||
|
||||
const names = tables(db);
|
||||
for (const table of ADOPTED) expect(names).toContain(table);
|
||||
for (const table of LEGACY) expect(names).not.toContain(table);
|
||||
|
||||
for (const table of ADOPTED) expect(count(db, table)).toBe(1);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT name FROM p_automations_automations WHERE id = 7")
|
||||
.get(),
|
||||
).toEqual({ name: "Disk" });
|
||||
|
||||
// Only one copy of each legacy index, never a second one.
|
||||
expect(indexes(db, "p_automations_trigger_state")).toEqual([
|
||||
"idx_automation_trigger_state_key",
|
||||
]);
|
||||
expect(indexes(db, "p_automations_runs")).toEqual([
|
||||
"idx_automation_runs_automation",
|
||||
"idx_automation_runs_user",
|
||||
]);
|
||||
|
||||
// The foreign keys followed the rename: deleting the automation still
|
||||
// takes its state, schedule, runs, steps and channel links with it.
|
||||
db.sqlite
|
||||
.prepare("DELETE FROM p_automations_automations WHERE id = 7")
|
||||
.run();
|
||||
for (const table of ADOPTED) expect(count(db, table)).toBe(0);
|
||||
});
|
||||
|
||||
it("creates the same tables on a fresh install", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
|
||||
});
|
||||
|
||||
const names = tables(db);
|
||||
for (const table of ADOPTED) expect(names).toContain(table);
|
||||
for (const table of LEGACY) expect(names).not.toContain(table);
|
||||
expect(indexes(db, "p_automations_schedules")).toEqual([
|
||||
"idx_automation_schedules_automation",
|
||||
"idx_automation_schedules_due",
|
||||
]);
|
||||
|
||||
db.sqlite.exec(`
|
||||
INSERT INTO users (id, username) VALUES ('alice', 'alice');
|
||||
INSERT INTO p_automations_automations (id, user_id, name, definition) VALUES (1, 'alice', 'A', '{}');
|
||||
INSERT INTO p_automations_runs (id, automation_id, user_id, trigger_type, status) VALUES (1, 1, 'alice', 'manual', 'success');
|
||||
INSERT INTO p_automations_run_steps (run_id, step_index, step_id, step_type, status) VALUES (1, 0, 'a', 'notify', 'success');
|
||||
`);
|
||||
// Deleting the user cascades all the way down.
|
||||
db.sqlite.prepare("DELETE FROM users WHERE id = 'alice'").run();
|
||||
expect(count(db, "p_automations_automations")).toBe(0);
|
||||
expect(count(db, "p_automations_run_steps")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const automationFetch = vi.fn();
|
||||
|
||||
vi.mock("../../src/backend/http.js", () => ({
|
||||
automationFetch: (...args: unknown[]) => automationFetch(...args),
|
||||
}));
|
||||
|
||||
const { sendAutomationNotification } =
|
||||
await import("../../src/backend/notify.js");
|
||||
|
||||
beforeEach(() => {
|
||||
automationFetch.mockReset();
|
||||
automationFetch.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("sendAutomationNotification", () => {
|
||||
it("keeps alert-compatible host and rule fields in webhook payloads", async () => {
|
||||
await sendAutomationNotification(
|
||||
{ id: 1, type: "webhook", config: '{"url":"https://example.com"}' },
|
||||
{
|
||||
title: "CPU warning",
|
||||
body: "cpu.percent is at 97",
|
||||
severity: "warning",
|
||||
context: {
|
||||
host: { id: 11, name: "Proxmox Node" },
|
||||
trigger: { value: 97, threshold: 90 },
|
||||
run: { automationId: 42 },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const options = automationFetch.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(options.body as string)).toMatchObject({
|
||||
hostName: "Proxmox Node",
|
||||
hostId: 11,
|
||||
ruleName: "CPU warning",
|
||||
ruleId: 42,
|
||||
value: 97,
|
||||
threshold: 90,
|
||||
message: "cpu.percent is at 97",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,513 +1,231 @@
|
||||
import crypto from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { AutomationDefinition } from "../../../../src/types/automations.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { definition, startServer, type TestServer } from "./helpers";
|
||||
|
||||
/**
|
||||
* Route-level behaviour: validation, ownership and webhook token handling. The repository and engine are mocked; what matters here is what
|
||||
* the HTTP layer accepts, rejects and hands back.
|
||||
*/
|
||||
let server: TestServer | null = null;
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
rows: [] as Array<Record<string, unknown>>,
|
||||
nextId: 1,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../../src/backend/utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const repository = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
findForUser: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
listAllEnabled: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
findRunForUser: vi.fn(),
|
||||
listRunSteps: vi.fn(),
|
||||
upsertSchedule: vi.fn(),
|
||||
deleteSchedule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const run = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../src/backend/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../../src/backend/database/routes/automation-dispatch.js",
|
||||
() => ({
|
||||
registerAutomationsRouter: vi.fn(),
|
||||
unregisterAutomationsRouter: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
requirePermission:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => undefined),
|
||||
getAuditUsername: vi.fn(async () => "alice"),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const { router } = await import("../../src/backend/routes.js");
|
||||
|
||||
/** Runs the whole middleware chain for the route, not just its handler. */
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: (...args: unknown[]) => unknown }>;
|
||||
};
|
||||
}>;
|
||||
const layer = stack.find(
|
||||
(l) => l.route?.path === path && l.route?.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
|
||||
const req = {
|
||||
userId: state.currentUserId,
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
query: overrides.query ?? {},
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
(this as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
for (const entry of layer.route.stack) {
|
||||
let advanced = false;
|
||||
await entry.handle(req, res, () => {
|
||||
advanced = true;
|
||||
});
|
||||
if (!advanced) break;
|
||||
}
|
||||
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
function definition(
|
||||
overrides: Partial<AutomationDefinition> = {},
|
||||
): AutomationDefinition {
|
||||
return {
|
||||
version: 1,
|
||||
trigger: {
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
metric: { path: "disk.percent", mount: "/data" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
},
|
||||
steps: [{ id: "a", type: "notify", channelIds: [1] }],
|
||||
...overrides,
|
||||
} as AutomationDefinition;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.rows = [];
|
||||
state.nextId = 1;
|
||||
vi.clearAllMocks();
|
||||
|
||||
repository.list.mockImplementation(async (userId: string) =>
|
||||
state.rows.filter((row) => row.user_id === userId),
|
||||
);
|
||||
repository.findForUser.mockImplementation(
|
||||
async (id: number, userId: string) =>
|
||||
state.rows.find((row) => row.id === id && row.user_id === userId) ?? null,
|
||||
);
|
||||
repository.create.mockImplementation(
|
||||
async (input: Record<string, unknown>) => {
|
||||
const row = {
|
||||
id: state.nextId++,
|
||||
user_id: input.userId,
|
||||
name: input.name,
|
||||
definition: input.definition,
|
||||
enabled: input.enabled === false ? 0 : 1,
|
||||
channels: input.channels ?? [],
|
||||
};
|
||||
state.rows.push(row);
|
||||
return row;
|
||||
},
|
||||
);
|
||||
repository.delete.mockImplementation(async (id: number, userId: string) => {
|
||||
const index = state.rows.findIndex(
|
||||
(row) => row.id === id && row.user_id === userId,
|
||||
);
|
||||
if (index === -1) return false;
|
||||
state.rows.splice(index, 1);
|
||||
return true;
|
||||
});
|
||||
repository.listAllEnabled.mockImplementation(async () =>
|
||||
state.rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
definition: row.definition,
|
||||
enabled: true,
|
||||
})),
|
||||
);
|
||||
repository.upsertSchedule.mockResolvedValue(undefined);
|
||||
repository.deleteSchedule.mockResolvedValue(undefined);
|
||||
run.mockResolvedValue({ runId: 1, status: "success" });
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("POST /", () => {
|
||||
it("creates an automation from a valid definition", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { name: "Disk watch", definition: definition() },
|
||||
});
|
||||
const schedule = definition({ kind: "schedule", intervalSeconds: 300 });
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.jsonBody?.name).toBe("Disk watch");
|
||||
describe("automation routes", () => {
|
||||
it("creates, lists, updates and deletes an automation", async () => {
|
||||
server = await startServer();
|
||||
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: " Nightly ", definition: schedule, channels: [1, 99] },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body).toMatchObject({
|
||||
name: "Nightly",
|
||||
enabled: 1,
|
||||
// 99 is not one of alice's channels, so it is not linked.
|
||||
channels: [1],
|
||||
missingPlugins: [],
|
||||
});
|
||||
const id = created.body.id;
|
||||
|
||||
// A schedule trigger registers its next due time.
|
||||
const scheduleRow = server.db.sqlite
|
||||
.prepare(
|
||||
"SELECT next_due_at FROM p_automations_schedules WHERE automation_id = ?",
|
||||
)
|
||||
.get(id) as { next_due_at: string | null };
|
||||
expect(scheduleRow.next_due_at).toBeTruthy();
|
||||
|
||||
const listed = await server.request("GET", "/");
|
||||
expect(listed.body.map((row: { id: number }) => row.id)).toEqual([id]);
|
||||
expect(listed.body[0].definition.trigger.kind).toBe("schedule");
|
||||
|
||||
const updated = await server.request("PUT", `/${id}`, {
|
||||
body: { enabled: false, name: "Nightly job" },
|
||||
});
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body).toMatchObject({ enabled: 0, name: "Nightly job" });
|
||||
|
||||
expect((await server.request("DELETE", `/${id}`)).status).toBe(200);
|
||||
expect((await server.request("GET", `/${id}`)).status).toBe(404);
|
||||
// The schedule goes with it.
|
||||
expect(
|
||||
server.db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_automations_schedules")
|
||||
.get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it("requires a name", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { definition: definition() },
|
||||
it("never shows one user's automations to another", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "Mine", definition: schedule },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/name/i);
|
||||
|
||||
expect((await server.request("GET", "/", { user: "bob" })).body).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
(await server.request("GET", `/${created.body.id}`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("DELETE", `/${created.body.id}`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an unknown trigger kind", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
it("rejects an invalid definition", async () => {
|
||||
server = await startServer();
|
||||
const response = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: { version: 1, trigger: { kind: "nope" }, steps: [] },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/trigger/i);
|
||||
});
|
||||
|
||||
it("rejects an unknown operator", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
trigger: {
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "all" },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: "~=",
|
||||
value: 1,
|
||||
cooldownMinutes: 5,
|
||||
},
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/operator/i);
|
||||
});
|
||||
|
||||
it("rejects an unknown step type", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
steps: [{ id: "a", type: "launch_missiles" }],
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/step type/i);
|
||||
});
|
||||
|
||||
it("rejects duplicate step ids, including inside a branch", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
steps: [
|
||||
{ id: "dup", type: "wait", seconds: 1 },
|
||||
{
|
||||
id: "branch",
|
||||
type: "if",
|
||||
condition: { left: "1", operator: "==", right: "1" },
|
||||
then: [{ id: "dup", type: "wait", seconds: 1 }],
|
||||
},
|
||||
],
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/duplicate/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid cron expression", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
trigger: { kind: "schedule", cron: "not a cron" },
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/cron/i);
|
||||
});
|
||||
|
||||
it("rejects an interval under a minute", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
definition: {
|
||||
trigger: { kind: "schedule", intervalSeconds: 5 },
|
||||
} as Partial<AutomationDefinition>),
|
||||
steps: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/60 seconds/i);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toMatch(/60 seconds/);
|
||||
});
|
||||
|
||||
it("registers a schedule for a schedule trigger", async () => {
|
||||
await invoke("post", "/", {
|
||||
it("marks an automation that needs a plugin that is off", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "Nightly",
|
||||
definition: definition({
|
||||
trigger: { kind: "schedule", cron: "0 2 * * *" },
|
||||
} as Partial<AutomationDefinition>),
|
||||
name: "Restart app",
|
||||
definition: definition({ kind: "schedule", intervalSeconds: 300 }, [
|
||||
{
|
||||
id: "d",
|
||||
type: "docker",
|
||||
action: "restart",
|
||||
container: "app",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(repository.upsertSchedule).toHaveBeenCalled();
|
||||
expect(created.body.missingPlugins).toEqual(["docker"]);
|
||||
});
|
||||
|
||||
it("returns a webhook token once and stores only its hash", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Hooked",
|
||||
definition: definition({
|
||||
trigger: { kind: "webhook", tokenHash: "" },
|
||||
} as Partial<AutomationDefinition>),
|
||||
it("offers editor options and the running providers", async () => {
|
||||
server = await startServer({
|
||||
services: {
|
||||
"fleets.access": { list: async () => [{ id: 3, name: "web" }] },
|
||||
},
|
||||
});
|
||||
const response = await server.request("GET", "/editor-options");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
hosts: [
|
||||
{ id: 1, name: "host-1" },
|
||||
{ id: 2, name: "host-2" },
|
||||
],
|
||||
snippets: [{ id: 5, name: "Restart nginx" }],
|
||||
fleets: [{ id: 3, name: "web" }],
|
||||
channels: [{ id: 1, name: "ops" }],
|
||||
});
|
||||
expect(response.body.providers).toMatchObject({
|
||||
snippets: true,
|
||||
fleets: true,
|
||||
docker: false,
|
||||
"host-metrics": false,
|
||||
"wake-on-lan": false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const token = res.jsonBody?.webhookToken as string;
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
||||
it("returns a webhook token once and runs the automation from it", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "Hook", definition: definition({ kind: "webhook" }) },
|
||||
});
|
||||
const token = created.body.webhookToken as string;
|
||||
expect(token).toHaveLength(64);
|
||||
expect(created.body.definition.trigger.tokenHash).toBe("");
|
||||
|
||||
const stored = JSON.parse(state.rows[0].definition as string);
|
||||
expect(stored.trigger.tokenHash).not.toBe(token);
|
||||
expect(stored.trigger.tokenHash).toBe(
|
||||
const stored = server.db.sqlite
|
||||
.prepare("SELECT definition FROM p_automations_automations WHERE id = ?")
|
||||
.get(created.body.id) as { definition: string };
|
||||
expect(JSON.parse(stored.definition).trigger.tokenHash).toBe(
|
||||
crypto.createHash("sha256").update(token).digest("hex"),
|
||||
);
|
||||
|
||||
// The hash is never echoed back to the client.
|
||||
const body = res.jsonBody as { definition: AutomationDefinition };
|
||||
expect((body.definition.trigger as { tokenHash: string }).tokenHash).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("stores the automation against the caller, not a supplied user id", async () => {
|
||||
// Authorization here is ownership, the same as the other data routes:
|
||||
// every read and write is scoped to req.userId, so a client cannot create
|
||||
// an automation that belongs to somebody else.
|
||||
await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Mine",
|
||||
definition: definition(),
|
||||
userId: "user-2",
|
||||
user_id: "user-2",
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.rows).toHaveLength(1);
|
||||
expect(state.rows[0].user_id).toBe("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /", () => {
|
||||
it("only returns the caller's automations", async () => {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-2",
|
||||
name: "Theirs",
|
||||
definition: JSON.stringify(definition()),
|
||||
channels: [],
|
||||
});
|
||||
|
||||
const res = await invoke("get", "/");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /:id", () => {
|
||||
it("will not delete another user's automation", async () => {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-2",
|
||||
name: "Theirs",
|
||||
definition: JSON.stringify(definition()),
|
||||
channels: [],
|
||||
});
|
||||
|
||||
const res = await invoke("delete", "/:id", { params: { id: "1" } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(state.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/run", () => {
|
||||
function seedOwned() {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-1",
|
||||
name: "Mine",
|
||||
definition: JSON.stringify(definition()),
|
||||
channels: [],
|
||||
});
|
||||
}
|
||||
|
||||
it("runs an owned automation", async () => {
|
||||
seedOwned();
|
||||
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ automationId: 1, triggerType: "manual" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the dry-run flag through", async () => {
|
||||
seedOwned();
|
||||
await invoke("post", "/:id/run", {
|
||||
params: { id: "1" },
|
||||
body: { dryRun: true },
|
||||
});
|
||||
expect(run).toHaveBeenCalledWith(expect.objectContaining({ dryRun: true }));
|
||||
});
|
||||
|
||||
it("refuses to run someone else's automation", async () => {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-2",
|
||||
name: "Theirs",
|
||||
definition: JSON.stringify(definition()),
|
||||
channels: [],
|
||||
});
|
||||
|
||||
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /webhook/:token", () => {
|
||||
function seedWebhook(token: string) {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-1",
|
||||
name: "Hooked",
|
||||
definition: JSON.stringify(
|
||||
definition({
|
||||
trigger: {
|
||||
kind: "webhook",
|
||||
tokenHash: crypto.createHash("sha256").update(token).digest("hex"),
|
||||
},
|
||||
} as Partial<AutomationDefinition>),
|
||||
),
|
||||
channels: [],
|
||||
});
|
||||
}
|
||||
|
||||
it("runs the automation matching the token", async () => {
|
||||
const token = "a".repeat(64);
|
||||
seedWebhook(token);
|
||||
|
||||
const res = await invoke("post", "/webhook/:token", {
|
||||
params: { token },
|
||||
const fired = await server.request("POST", `/webhook/${token}`, {
|
||||
user: null,
|
||||
body: { hello: "world" },
|
||||
});
|
||||
expect(fired.status).toBe(202);
|
||||
expect(fired.body.status).toBe("success");
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ automationId: 1, triggerType: "webhook" }),
|
||||
);
|
||||
const wrong = await server.request("POST", `/webhook/${"0".repeat(64)}`, {
|
||||
user: null,
|
||||
body: {},
|
||||
});
|
||||
expect(wrong.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a token that does not match", async () => {
|
||||
seedWebhook("a".repeat(64));
|
||||
|
||||
const res = await invoke("post", "/webhook/:token", {
|
||||
params: { token: "b".repeat(64) },
|
||||
it("runs now, records the steps and lists the run", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "Now", definition: schedule },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a token too short to be real", async () => {
|
||||
seedWebhook("a".repeat(64));
|
||||
|
||||
const res = await invoke("post", "/webhook/:token", {
|
||||
params: { token: "short" },
|
||||
const outcome = await server.request("POST", `/${created.body.id}/run`, {
|
||||
body: { dryRun: true },
|
||||
});
|
||||
expect(outcome.body.status).toBe("success");
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
const runs = await server.request("GET", "/runs/history");
|
||||
expect(runs.body[0]).toMatchObject({
|
||||
automation_id: created.body.id,
|
||||
automation_name: "Now",
|
||||
status: "success",
|
||||
dry_run: 1,
|
||||
trigger_type: "manual",
|
||||
});
|
||||
const steps = await server.request("GET", `/runs/${runs.body[0].id}/steps`);
|
||||
expect(steps.body).toMatchObject([
|
||||
{ step_type: "set_var", status: "success" },
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
await server.request("GET", `/runs/${runs.body[0].id}/steps`, {
|
||||
user: "bob",
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions", () => {
|
||||
const routes: Array<[string, string, string]> = [
|
||||
["GET", "/", "view"],
|
||||
["GET", "/editor-options", "view"],
|
||||
["GET", "/runs/history", "view"],
|
||||
["GET", "/runs/1/steps", "view"],
|
||||
["GET", "/1", "view"],
|
||||
["POST", "/", "create"],
|
||||
["PUT", "/1", "edit"],
|
||||
["DELETE", "/1", "delete"],
|
||||
["POST", "/1/run", "run"],
|
||||
];
|
||||
|
||||
it.each(routes)(
|
||||
"%s %s answers 403 without automations.%s",
|
||||
async (method, path) => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const response = await server.request(method, path, { body: {} });
|
||||
expect(response.status).toBe(403);
|
||||
},
|
||||
);
|
||||
|
||||
it("serves the webhook without a session", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const response = await server.request(
|
||||
"POST",
|
||||
`/webhook/${"a".repeat(64)}`,
|
||||
{ user: null, body: {} },
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Trigger,
|
||||
} from "../../../../src/types/automations.js";
|
||||
import type { AutomationDefinition, Trigger } from "../../src/types.js";
|
||||
import { createTriggers } from "../../src/backend/triggers.js";
|
||||
|
||||
/**
|
||||
* Trigger matching, dwell windows and cooldowns. The repository and the engine
|
||||
@@ -42,16 +40,14 @@ const repository = {
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
|
||||
vi.mock("../../src/backend/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
const triggers = await import("../../src/backend/triggers.js");
|
||||
const log = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
const triggers = createTriggers(repository as never, { run }, log);
|
||||
|
||||
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
|
||||
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
|
||||
@@ -415,7 +411,7 @@ describe("onDockerEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAutomationWatchedHosts", () => {
|
||||
describe("watchedHosts", () => {
|
||||
it("collects hosts from metric triggers so they can be polled headlessly", async () => {
|
||||
addAutomation({
|
||||
kind: "metric_threshold",
|
||||
@@ -426,7 +422,7 @@ describe("listAutomationWatchedHosts", () => {
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
|
||||
const watched = await triggers.listAutomationWatchedHosts();
|
||||
const watched = await triggers.watchedHosts("metric_threshold");
|
||||
|
||||
expect([...watched.keys()].sort()).toEqual([3, 4]);
|
||||
});
|
||||
@@ -439,6 +435,6 @@ describe("listAutomationWatchedHosts", () => {
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
|
||||
expect((await triggers.listAutomationWatchedHosts()).size).toBe(0);
|
||||
expect((await triggers.watchedHosts("metric_threshold")).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentNotificationChannelRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
const { setTunnelServices } = await import("../../src/backend/tunnels.js");
|
||||
const { executeStep } = await import("../../src/backend/actions/index.js");
|
||||
|
||||
afterEach(() => {
|
||||
setTunnelServices(null);
|
||||
});
|
||||
|
||||
function context(dryRun = false) {
|
||||
return {
|
||||
userId: "user-1",
|
||||
automationId: 1,
|
||||
runId: 1,
|
||||
dryRun,
|
||||
template: {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const step = (action: "connect" | "disconnect") =>
|
||||
({
|
||||
id: "s1",
|
||||
type: "tunnel",
|
||||
action,
|
||||
tunnelName: "7::0::web::8080::db::5432",
|
||||
}) as never;
|
||||
|
||||
describe("the tunnel step", () => {
|
||||
it("starts and stops the tunnel through tunnels.access as the automation's owner", async () => {
|
||||
const fake = createFakeContext({ pluginId: "automations" });
|
||||
const tunnels = {
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
};
|
||||
fake.ctx.services.provide("tunnels.access", tunnels);
|
||||
const get = vi.spyOn(fake.ctx.services, "get");
|
||||
setTunnelServices(fake.ctx.services);
|
||||
|
||||
const connected = await executeStep(step("connect"), context());
|
||||
expect(connected.success).toBe(true);
|
||||
expect(tunnels.start).toHaveBeenCalledWith("7::0::web::8080::db::5432");
|
||||
expect(get).toHaveBeenCalledWith("tunnels.access", { userId: "user-1" });
|
||||
|
||||
const stopped = await executeStep(step("disconnect"), context());
|
||||
expect(stopped.success).toBe(true);
|
||||
expect(tunnels.stop).toHaveBeenCalledWith("7::0::web::8080::db::5432");
|
||||
});
|
||||
|
||||
it("reports the tunnel plugin's own error", async () => {
|
||||
const fake = createFakeContext({ pluginId: "automations" });
|
||||
fake.ctx.services.provide("tunnels.access", {
|
||||
start: vi.fn(async () => {
|
||||
throw new Error('Tunnel "x" is not configured');
|
||||
}),
|
||||
stop: vi.fn(),
|
||||
});
|
||||
setTunnelServices(fake.ctx.services);
|
||||
|
||||
const result = await executeStep(step("connect"), context());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error ?? result.output).toMatch(/not configured/);
|
||||
});
|
||||
|
||||
it("fails the step cleanly while the tunnels plugin is off", async () => {
|
||||
// No provider: the optional dependency is missing.
|
||||
setTunnelServices(
|
||||
createFakeContext({ pluginId: "automations" }).ctx.services,
|
||||
);
|
||||
const withoutProvider = await executeStep(step("connect"), context());
|
||||
expect(withoutProvider.success).toBe(false);
|
||||
expect(withoutProvider.error ?? withoutProvider.output).toMatch(
|
||||
/tunnels plugin is not available/,
|
||||
);
|
||||
|
||||
setTunnelServices(null);
|
||||
const inactive = await executeStep(step("disconnect"), context());
|
||||
expect(inactive.success).toBe(false);
|
||||
});
|
||||
|
||||
it("only describes what it would do on a dry run", async () => {
|
||||
const result = await executeStep(step("connect"), context(true));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toMatch(/Would connect tunnel/);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { defaultTrigger } from "../../src/frontend/automations/TriggerCard";
|
||||
import { emptyDraft } from "../../src/frontend/automations/AutomationEditor";
|
||||
import { newStepId } from "../../src/frontend/automations/editor-types";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "../../src/types";
|
||||
|
||||
describe("emptyDraft", () => {
|
||||
it("starts on a metric threshold with no steps", () => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Restart app",
|
||||
description: null,
|
||||
enabled: 1,
|
||||
definition: {
|
||||
version: 1,
|
||||
trigger: { kind: "schedule", intervalSeconds: 300 },
|
||||
steps: [],
|
||||
},
|
||||
concurrency_policy: "skip",
|
||||
last_run_at: null,
|
||||
last_run_status: null,
|
||||
channels: [],
|
||||
missingPlugins: ["docker"],
|
||||
},
|
||||
];
|
||||
|
||||
function stubApi(): PluginApiClient {
|
||||
return {
|
||||
get: vi.fn(async (path: string) => {
|
||||
if (path === "/editor-options") {
|
||||
return {
|
||||
data: {
|
||||
hosts: [],
|
||||
snippets: [],
|
||||
fleets: [],
|
||||
channels: [],
|
||||
providers: { snippets: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: path === "/" ? rows : [] };
|
||||
}),
|
||||
post: vi.fn(async () => ({ data: {} })),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: {} })),
|
||||
} as unknown as PluginApiClient;
|
||||
}
|
||||
|
||||
describe("automations panel", () => {
|
||||
it("lists automations from the plugin API and marks one that needs a plugin", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
const element = rendered.renderPanel("automations") as HTMLElement;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(element.textContent).toContain("Restart app");
|
||||
expect(element.textContent).toContain("Needs docker");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,6 @@
|
||||
"plugins/ai/src/frontend/terminal/TerminalProposalCard.tsx",
|
||||
"plugins/ai/src/frontend/use-ai-stream.ts",
|
||||
"plugins/ai/src/frontend/useMentions.ts",
|
||||
"plugins/automations/src/frontend/AutomationsPanel.tsx",
|
||||
"plugins/automations/src/frontend/automations-api.ts",
|
||||
"plugins/automations/src/frontend/automations/AutomationEditor.tsx",
|
||||
"plugins/automations/src/frontend/automations/HostSelectorField.tsx",
|
||||
"plugins/automations/src/frontend/automations/StepBlock.tsx",
|
||||
"plugins/automations/src/frontend/automations/TriggerCard.tsx",
|
||||
"plugins/file-manager/src/frontend/FileManager.tsx",
|
||||
"plugins/file-manager/src/frontend/FileManagerContextMenu.tsx",
|
||||
"plugins/file-manager/src/frontend/FileManagerDialogs.tsx",
|
||||
@@ -81,22 +75,10 @@
|
||||
"plugins/ai/src/backend/routes.ts",
|
||||
"plugins/ai/src/backend/tools/executor.ts",
|
||||
"plugins/ai/src/backend/tools/read-tools.ts",
|
||||
"plugins/automations/src/backend/actions/host-targets.ts",
|
||||
"plugins/automations/src/backend/actions/index.ts",
|
||||
"plugins/automations/src/backend/actions/types.ts",
|
||||
"plugins/automations/src/backend/conditions.ts",
|
||||
"plugins/automations/src/backend/docker-watcher.ts",
|
||||
"plugins/automations/src/backend/engine.ts",
|
||||
"plugins/automations/src/backend/headless-viewer.ts",
|
||||
"plugins/automations/src/backend/http.ts",
|
||||
"plugins/automations/src/backend/notify.ts",
|
||||
"plugins/automations/src/backend/routes.ts",
|
||||
"plugins/automations/src/backend/scheduler.ts",
|
||||
"plugins/automations/src/backend/triggers.ts",
|
||||
"plugins/file-manager/src/backend/index.ts",
|
||||
"plugins/file-manager/src/backend/operation-routes.ts",
|
||||
"plugins/proxmox/src/backend/routes.ts",
|
||||
"plugins/tailscale/src/backend/routes.ts"
|
||||
],
|
||||
"plugin-to-plugin": ["plugins/ai/src/backend/tools/executor.ts"]
|
||||
"plugin-to-plugin": []
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The one place a login becomes a session: JWT, cookie, response body,
|
||||
* audit line and the automation event. Every login method ends here, so
|
||||
* audit line and the user_login internal event. Every login method ends here, so
|
||||
* they all behave the same way.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@ import { authLogger } from "../utils/logger.js";
|
||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
||||
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import { notifyAutomationInternalEvent } from "../hosts/automation-events.js";
|
||||
import { emitInternalEvent } from "../hosts/internal-events.js";
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function issueSession(
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
notifyAutomationInternalEvent("user_login", user.id, undefined, {
|
||||
emitInternalEvent("user_login", user.id, undefined, {
|
||||
username: user.username,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
@@ -2030,158 +2030,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
// --- alerts end ---
|
||||
|
||||
// --- automations begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM automations LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS automations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
definition TEXT NOT NULL,
|
||||
definition_version INTEGER NOT NULL DEFAULT 1,
|
||||
concurrency_policy TEXT NOT NULL DEFAULT 'skip',
|
||||
max_run_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
last_run_at TEXT,
|
||||
last_run_status TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create automations table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM automation_trigger_state LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS automation_trigger_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
state_key TEXT NOT NULL,
|
||||
breach_started_at TEXT,
|
||||
last_fired_at TEXT,
|
||||
last_value REAL,
|
||||
last_observed_state TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create automation_trigger_state table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM automation_schedules LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS automation_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
cron TEXT,
|
||||
interval_seconds INTEGER,
|
||||
timezone TEXT,
|
||||
next_due_at TEXT,
|
||||
last_tick_at TEXT
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create automation_schedules table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM automation_runs LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS automation_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_context TEXT,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
error TEXT,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
parent_run_id INTEGER
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create automation_runs table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM automation_run_steps LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS automation_run_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE,
|
||||
step_index INTEGER NOT NULL,
|
||||
step_id TEXT NOT NULL,
|
||||
step_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at TEXT,
|
||||
output TEXT,
|
||||
error TEXT,
|
||||
truncated INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create automation_run_steps table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM automation_channels LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS automation_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create automation_channels table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- automations end ---
|
||||
|
||||
// --- homepage begin ---
|
||||
try {
|
||||
|
||||
@@ -125,51 +125,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
},
|
||||
|
||||
|
||||
// Automations.
|
||||
{
|
||||
name: "idx_automations_user",
|
||||
table: "automations",
|
||||
columns: "user_id, enabled",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_trigger_state_key",
|
||||
table: "automation_trigger_state",
|
||||
columns: "automation_id, state_key",
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: "idx_automation_schedules_automation",
|
||||
table: "automation_schedules",
|
||||
columns: "automation_id",
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: "idx_automation_schedules_due",
|
||||
table: "automation_schedules",
|
||||
columns: "next_due_at",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_runs_automation",
|
||||
table: "automation_runs",
|
||||
columns: "automation_id, started_at",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_runs_user",
|
||||
table: "automation_runs",
|
||||
columns: "user_id, started_at",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_run_steps_run",
|
||||
table: "automation_run_steps",
|
||||
columns: "run_id, step_index",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_channels_pair",
|
||||
table: "automation_channels",
|
||||
columns: "automation_id, channel_id",
|
||||
unique: true,
|
||||
},
|
||||
|
||||
// AI assistant.
|
||||
{
|
||||
name: "idx_ai_providers_user_label",
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
varchar,
|
||||
int,
|
||||
boolean,
|
||||
double,
|
||||
index,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
@@ -1032,164 +1031,6 @@ export const notificationChannels = mysqlTable("notification_channels", {
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
// --- automations begin ---
|
||||
export const automations = mysqlTable(
|
||||
"automations",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
// The whole trigger + steps graph, shaped by AutomationDefinition. Read and
|
||||
// written as a unit, never queried by its inner structure.
|
||||
definition: text("definition").notNull(),
|
||||
definitionVersion: int("definition_version").notNull().default(1),
|
||||
concurrencyPolicy: text("concurrency_policy").notNull().default("skip"),
|
||||
maxRunSeconds: int("max_run_seconds").notNull().default(300),
|
||||
dryRun: boolean("dry_run").notNull().default(false),
|
||||
lastRunAt: text("last_run_at"),
|
||||
lastRunStatus: text("last_run_status"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
// The scheduler sweeps enabled automations for one user at a time.
|
||||
(table) => [index("idx_automations_user").on(table.userId, table.enabled)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Durable per-target trigger state. state_key scopes a trigger to what it is
|
||||
* actually watching ("<hostId>", "<hostId>:/data", "<hostId>:<container>"), so
|
||||
* a sustained-breach window can track one filesystem rather than a whole host.
|
||||
* Living in the database rather than memory means cooldowns and dwell windows
|
||||
* survive a restart.
|
||||
*/
|
||||
export const automationTriggerState = mysqlTable(
|
||||
"automation_trigger_state",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
automationId: int("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
stateKey: varchar("state_key", { length: 255 }).notNull(),
|
||||
breachStartedAt: text("breach_started_at"),
|
||||
lastFiredAt: text("last_fired_at"),
|
||||
lastValue: double("last_value"),
|
||||
lastObservedState: text("last_observed_state"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_automation_trigger_state_key").on(
|
||||
table.automationId,
|
||||
table.stateKey,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationSchedules = mysqlTable(
|
||||
"automation_schedules",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
automationId: int("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
cron: text("cron"),
|
||||
intervalSeconds: int("interval_seconds"),
|
||||
timezone: text("timezone"),
|
||||
nextDueAt: varchar("next_due_at", { length: 255 }),
|
||||
lastTickAt: text("last_tick_at"),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_automation_schedules_automation").on(table.automationId),
|
||||
index("idx_automation_schedules_due").on(table.nextDueAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationRuns = mysqlTable(
|
||||
"automation_runs",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
automationId: int("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
triggerType: text("trigger_type").notNull(),
|
||||
triggerContext: text("trigger_context"),
|
||||
status: varchar("status", { length: 255 }).notNull(),
|
||||
startedAt: varchar("started_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
finishedAt: text("finished_at"),
|
||||
durationMs: int("duration_ms"),
|
||||
error: text("error"),
|
||||
dryRun: boolean("dry_run").notNull().default(false),
|
||||
// Set when one automation invoked another, so a chain can be traced.
|
||||
parentRunId: int("parent_run_id"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_automation_runs_automation").on(
|
||||
table.automationId,
|
||||
table.startedAt,
|
||||
),
|
||||
index("idx_automation_runs_user").on(table.userId, table.startedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationRunSteps = mysqlTable(
|
||||
"automation_run_steps",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
runId: int("run_id")
|
||||
.notNull()
|
||||
.references(() => automationRuns.id, { onDelete: "cascade" }),
|
||||
stepIndex: int("step_index").notNull(),
|
||||
stepId: text("step_id").notNull(),
|
||||
stepType: text("step_type").notNull(),
|
||||
status: varchar("status", { length: 255 }).notNull(),
|
||||
startedAt: varchar("started_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
finishedAt: text("finished_at"),
|
||||
output: text("output"),
|
||||
error: text("error"),
|
||||
truncated: boolean("truncated")
|
||||
.notNull()
|
||||
.default(false),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_automation_run_steps_run").on(table.runId, table.stepIndex),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationChannels = mysqlTable(
|
||||
"automation_channels",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
automationId: int("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
channelId: int("channel_id")
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_automation_channels_pair").on(
|
||||
table.automationId,
|
||||
table.channelId,
|
||||
),
|
||||
],
|
||||
);
|
||||
// --- automations end ---
|
||||
|
||||
// --- homepage begin ---
|
||||
export const homepageItems = mysqlTable(
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
integer,
|
||||
serial,
|
||||
boolean,
|
||||
doublePrecision,
|
||||
index,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
@@ -1033,164 +1032,6 @@ export const notificationChannels = pgTable("notification_channels", {
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
// --- automations begin ---
|
||||
export const automations = pgTable(
|
||||
"automations",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
// The whole trigger + steps graph, shaped by AutomationDefinition. Read and
|
||||
// written as a unit, never queried by its inner structure.
|
||||
definition: text("definition").notNull(),
|
||||
definitionVersion: integer("definition_version").notNull().default(1),
|
||||
concurrencyPolicy: text("concurrency_policy").notNull().default("skip"),
|
||||
maxRunSeconds: integer("max_run_seconds").notNull().default(300),
|
||||
dryRun: boolean("dry_run").notNull().default(false),
|
||||
lastRunAt: text("last_run_at"),
|
||||
lastRunStatus: text("last_run_status"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
// The scheduler sweeps enabled automations for one user at a time.
|
||||
(table) => [index("idx_automations_user").on(table.userId, table.enabled)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Durable per-target trigger state. state_key scopes a trigger to what it is
|
||||
* actually watching ("<hostId>", "<hostId>:/data", "<hostId>:<container>"), so
|
||||
* a sustained-breach window can track one filesystem rather than a whole host.
|
||||
* Living in the database rather than memory means cooldowns and dwell windows
|
||||
* survive a restart.
|
||||
*/
|
||||
export const automationTriggerState = pgTable(
|
||||
"automation_trigger_state",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
automationId: integer("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
stateKey: varchar("state_key", { length: 255 }).notNull(),
|
||||
breachStartedAt: text("breach_started_at"),
|
||||
lastFiredAt: text("last_fired_at"),
|
||||
lastValue: doublePrecision("last_value"),
|
||||
lastObservedState: text("last_observed_state"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_automation_trigger_state_key").on(
|
||||
table.automationId,
|
||||
table.stateKey,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationSchedules = pgTable(
|
||||
"automation_schedules",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
automationId: integer("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
cron: text("cron"),
|
||||
intervalSeconds: integer("interval_seconds"),
|
||||
timezone: text("timezone"),
|
||||
nextDueAt: varchar("next_due_at", { length: 255 }),
|
||||
lastTickAt: text("last_tick_at"),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_automation_schedules_automation").on(table.automationId),
|
||||
index("idx_automation_schedules_due").on(table.nextDueAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationRuns = pgTable(
|
||||
"automation_runs",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
automationId: integer("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
triggerType: text("trigger_type").notNull(),
|
||||
triggerContext: text("trigger_context"),
|
||||
status: varchar("status", { length: 255 }).notNull(),
|
||||
startedAt: varchar("started_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
finishedAt: text("finished_at"),
|
||||
durationMs: integer("duration_ms"),
|
||||
error: text("error"),
|
||||
dryRun: boolean("dry_run").notNull().default(false),
|
||||
// Set when one automation invoked another, so a chain can be traced.
|
||||
parentRunId: integer("parent_run_id"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_automation_runs_automation").on(
|
||||
table.automationId,
|
||||
table.startedAt,
|
||||
),
|
||||
index("idx_automation_runs_user").on(table.userId, table.startedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationRunSteps = pgTable(
|
||||
"automation_run_steps",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
runId: integer("run_id")
|
||||
.notNull()
|
||||
.references(() => automationRuns.id, { onDelete: "cascade" }),
|
||||
stepIndex: integer("step_index").notNull(),
|
||||
stepId: text("step_id").notNull(),
|
||||
stepType: text("step_type").notNull(),
|
||||
status: varchar("status", { length: 255 }).notNull(),
|
||||
startedAt: varchar("started_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
finishedAt: text("finished_at"),
|
||||
output: text("output"),
|
||||
error: text("error"),
|
||||
truncated: boolean("truncated")
|
||||
.notNull()
|
||||
.default(false),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_automation_run_steps_run").on(table.runId, table.stepIndex),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationChannels = pgTable(
|
||||
"automation_channels",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
automationId: integer("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
channelId: integer("channel_id")
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_automation_channels_pair").on(
|
||||
table.automationId,
|
||||
table.channelId,
|
||||
),
|
||||
],
|
||||
);
|
||||
// --- automations end ---
|
||||
|
||||
// --- homepage begin ---
|
||||
export const homepageItems = pgTable(
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
real,
|
||||
index,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
@@ -1029,164 +1028,6 @@ export const notificationChannels = sqliteTable("notification_channels", {
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
// --- automations begin ---
|
||||
export const automations = sqliteTable(
|
||||
"automations",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
// The whole trigger + steps graph, shaped by AutomationDefinition. Read and
|
||||
// written as a unit, never queried by its inner structure.
|
||||
definition: text("definition").notNull(),
|
||||
definitionVersion: integer("definition_version").notNull().default(1),
|
||||
concurrencyPolicy: text("concurrency_policy").notNull().default("skip"),
|
||||
maxRunSeconds: integer("max_run_seconds").notNull().default(300),
|
||||
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
|
||||
lastRunAt: text("last_run_at"),
|
||||
lastRunStatus: text("last_run_status"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
// The scheduler sweeps enabled automations for one user at a time.
|
||||
(table) => [index("idx_automations_user").on(table.userId, table.enabled)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Durable per-target trigger state. state_key scopes a trigger to what it is
|
||||
* actually watching ("<hostId>", "<hostId>:/data", "<hostId>:<container>"), so
|
||||
* a sustained-breach window can track one filesystem rather than a whole host.
|
||||
* Living in the database rather than memory means cooldowns and dwell windows
|
||||
* survive a restart.
|
||||
*/
|
||||
export const automationTriggerState = sqliteTable(
|
||||
"automation_trigger_state",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
automationId: integer("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
stateKey: text("state_key").notNull(),
|
||||
breachStartedAt: text("breach_started_at"),
|
||||
lastFiredAt: text("last_fired_at"),
|
||||
lastValue: real("last_value"),
|
||||
lastObservedState: text("last_observed_state"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_automation_trigger_state_key").on(
|
||||
table.automationId,
|
||||
table.stateKey,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationSchedules = sqliteTable(
|
||||
"automation_schedules",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
automationId: integer("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
cron: text("cron"),
|
||||
intervalSeconds: integer("interval_seconds"),
|
||||
timezone: text("timezone"),
|
||||
nextDueAt: text("next_due_at"),
|
||||
lastTickAt: text("last_tick_at"),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_automation_schedules_automation").on(table.automationId),
|
||||
index("idx_automation_schedules_due").on(table.nextDueAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationRuns = sqliteTable(
|
||||
"automation_runs",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
automationId: integer("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
triggerType: text("trigger_type").notNull(),
|
||||
triggerContext: text("trigger_context"),
|
||||
status: text("status").notNull(),
|
||||
startedAt: text("started_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
finishedAt: text("finished_at"),
|
||||
durationMs: integer("duration_ms"),
|
||||
error: text("error"),
|
||||
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
|
||||
// Set when one automation invoked another, so a chain can be traced.
|
||||
parentRunId: integer("parent_run_id"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_automation_runs_automation").on(
|
||||
table.automationId,
|
||||
table.startedAt,
|
||||
),
|
||||
index("idx_automation_runs_user").on(table.userId, table.startedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationRunSteps = sqliteTable(
|
||||
"automation_run_steps",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
runId: integer("run_id")
|
||||
.notNull()
|
||||
.references(() => automationRuns.id, { onDelete: "cascade" }),
|
||||
stepIndex: integer("step_index").notNull(),
|
||||
stepId: text("step_id").notNull(),
|
||||
stepType: text("step_type").notNull(),
|
||||
status: text("status").notNull(),
|
||||
startedAt: text("started_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
finishedAt: text("finished_at"),
|
||||
output: text("output"),
|
||||
error: text("error"),
|
||||
truncated: integer("truncated", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_automation_run_steps_run").on(table.runId, table.stepIndex),
|
||||
],
|
||||
);
|
||||
|
||||
export const automationChannels = sqliteTable(
|
||||
"automation_channels",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
automationId: integer("automation_id")
|
||||
.notNull()
|
||||
.references(() => automations.id, { onDelete: "cascade" }),
|
||||
channelId: integer("channel_id")
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_automation_channels_pair").on(
|
||||
table.automationId,
|
||||
table.channelId,
|
||||
),
|
||||
],
|
||||
);
|
||||
// --- automations end ---
|
||||
|
||||
// --- homepage begin ---
|
||||
export const homepageItems = sqliteTable(
|
||||
|
||||
@@ -1,784 +0,0 @@
|
||||
import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm";
|
||||
import {
|
||||
automationChannels,
|
||||
automationRunSteps,
|
||||
automationRuns,
|
||||
automationSchedules,
|
||||
automationTriggerState,
|
||||
automations,
|
||||
notificationChannels,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
type AutomationRecord = typeof automations.$inferSelect;
|
||||
type AutomationRunRecord = typeof automationRuns.$inferSelect;
|
||||
type AutomationRunStepRecord = typeof automationRunSteps.$inferSelect;
|
||||
type TriggerStateRecord = typeof automationTriggerState.$inferSelect;
|
||||
type ScheduleRecord = typeof automationSchedules.$inferSelect;
|
||||
|
||||
export interface AutomationRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: number;
|
||||
definition: string;
|
||||
definition_version: number;
|
||||
concurrency_policy: string;
|
||||
max_run_seconds: number;
|
||||
dry_run: number;
|
||||
last_run_at: string | null;
|
||||
last_run_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
channels: number[];
|
||||
}
|
||||
|
||||
export interface AutomationRunRow {
|
||||
id: number;
|
||||
automation_id: number;
|
||||
user_id: string;
|
||||
trigger_type: string;
|
||||
trigger_context: string | null;
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
error: string | null;
|
||||
dry_run: number;
|
||||
parent_run_id: number | null;
|
||||
automation_name?: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationRunStepRow {
|
||||
id: number;
|
||||
run_id: number;
|
||||
step_index: number;
|
||||
step_id: string;
|
||||
step_type: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
output: string | null;
|
||||
error: string | null;
|
||||
truncated: number;
|
||||
}
|
||||
|
||||
/** The shape the engine loads; camelCase and already parsed where useful. */
|
||||
export interface AutomationEngineRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
definition: string;
|
||||
concurrencyPolicy: string;
|
||||
maxRunSeconds: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export interface TriggerStateRow {
|
||||
automationId: number;
|
||||
stateKey: string;
|
||||
breachStartedAt: string | null;
|
||||
lastFiredAt: string | null;
|
||||
lastValue: number | null;
|
||||
lastObservedState: string | null;
|
||||
}
|
||||
|
||||
export interface DueScheduleRow {
|
||||
automationId: number;
|
||||
cron: string | null;
|
||||
intervalSeconds: number | null;
|
||||
timezone: string | null;
|
||||
nextDueAt: string | null;
|
||||
}
|
||||
|
||||
export class AutomationRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async list(userId: string): Promise<AutomationRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(eq(automations.userId, userId))
|
||||
.orderBy(asc(automations.name));
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// One query for every automation's channels rather than one per row.
|
||||
const links = await this.context.drizzle
|
||||
.select({
|
||||
automationId: automationChannels.automationId,
|
||||
channelId: automationChannels.channelId,
|
||||
})
|
||||
.from(automationChannels)
|
||||
.where(
|
||||
inArray(
|
||||
automationChannels.automationId,
|
||||
rows.map((row) => row.id),
|
||||
),
|
||||
);
|
||||
|
||||
const byAutomation = new Map<number, number[]>();
|
||||
for (const link of links) {
|
||||
const list = byAutomation.get(link.automationId) ?? [];
|
||||
list.push(link.channelId);
|
||||
byAutomation.set(link.automationId, list);
|
||||
}
|
||||
|
||||
return rows.map((row) => mapAutomationRow(row, byAutomation.get(row.id)));
|
||||
}
|
||||
|
||||
async findForUser(id: number, userId: string): Promise<AutomationRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(and(eq(automations.id, id), eq(automations.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return null;
|
||||
return mapAutomationRow(rows[0], await this.listChannelIds(id));
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<AutomationEngineRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(eq(automations.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ? mapEngineRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
definition: string;
|
||||
concurrencyPolicy?: string;
|
||||
maxRunSeconds?: number;
|
||||
dryRun?: boolean;
|
||||
channels?: number[];
|
||||
now?: string;
|
||||
}): Promise<AutomationRow> {
|
||||
const now = input.now ?? new Date().toISOString();
|
||||
const [created] = await insertReturning(this.context, automations, {
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
definition: input.definition,
|
||||
concurrencyPolicy: input.concurrencyPolicy ?? "skip",
|
||||
maxRunSeconds: input.maxRunSeconds ?? 300,
|
||||
dryRun: input.dryRun ?? false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const channels = await this.replaceChannels(
|
||||
created.id,
|
||||
input.userId,
|
||||
input.channels ?? [],
|
||||
);
|
||||
await this.afterWrite();
|
||||
return mapAutomationRow(created, channels);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
definition?: string;
|
||||
concurrencyPolicy?: string;
|
||||
maxRunSeconds?: number;
|
||||
dryRun?: boolean;
|
||||
channels?: number[];
|
||||
now?: string;
|
||||
},
|
||||
): Promise<AutomationRow | null> {
|
||||
const { channels, now, ...fields } = input;
|
||||
const values: Record<string, unknown> = { ...fields };
|
||||
|
||||
if (Object.keys(values).length > 0) {
|
||||
values.updatedAt = now ?? new Date().toISOString();
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
automations,
|
||||
values,
|
||||
and(eq(automations.id, id), eq(automations.userId, userId)),
|
||||
);
|
||||
if (!updated) return null;
|
||||
} else {
|
||||
const existing = await this.findForUser(id, userId);
|
||||
if (!existing) return null;
|
||||
}
|
||||
|
||||
if (channels) {
|
||||
await this.replaceChannels(id, userId, channels);
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
return this.findForUser(id, userId);
|
||||
}
|
||||
|
||||
async delete(id: number, userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(automations)
|
||||
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
|
||||
|
||||
const deleted = rowsAffected(result) > 0;
|
||||
if (deleted) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async setEnabled(
|
||||
id: number,
|
||||
userId: string,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(automations)
|
||||
.set({ enabled, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
|
||||
|
||||
const changed = rowsAffected(result) > 0;
|
||||
if (changed) await this.afterWrite();
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled automations owned by whoever owns the given host. Wildcard targets
|
||||
* must never reach across users, which is the bug the alert engine shipped
|
||||
* with.
|
||||
*/
|
||||
async listEnabledForUser(userId: string): Promise<AutomationEngineRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(
|
||||
and(eq(automations.enabled, true), eq(automations.userId, userId)),
|
||||
);
|
||||
return rows.map(mapEngineRow);
|
||||
}
|
||||
|
||||
async listAllEnabled(): Promise<AutomationEngineRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(eq(automations.enabled, true));
|
||||
return rows.map(mapEngineRow);
|
||||
}
|
||||
|
||||
// --- trigger state ---
|
||||
|
||||
async getTriggerState(
|
||||
automationId: number,
|
||||
stateKey: string,
|
||||
): Promise<TriggerStateRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automationTriggerState)
|
||||
.where(
|
||||
and(
|
||||
eq(automationTriggerState.automationId, automationId),
|
||||
eq(automationTriggerState.stateKey, stateKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ? mapTriggerStateRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async upsertTriggerState(input: {
|
||||
automationId: number;
|
||||
stateKey: string;
|
||||
breachStartedAt?: string | null;
|
||||
lastFiredAt?: string | null;
|
||||
lastValue?: number | null;
|
||||
lastObservedState?: string | null;
|
||||
}): Promise<void> {
|
||||
const existing = await this.getTriggerState(
|
||||
input.automationId,
|
||||
input.stateKey,
|
||||
);
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
const values: Record<string, unknown> = { updatedAt };
|
||||
if (input.breachStartedAt !== undefined)
|
||||
values.breachStartedAt = input.breachStartedAt;
|
||||
if (input.lastFiredAt !== undefined)
|
||||
values.lastFiredAt = input.lastFiredAt;
|
||||
if (input.lastValue !== undefined) values.lastValue = input.lastValue;
|
||||
if (input.lastObservedState !== undefined)
|
||||
values.lastObservedState = input.lastObservedState;
|
||||
|
||||
await this.context.drizzle
|
||||
.update(automationTriggerState)
|
||||
.set(values)
|
||||
.where(
|
||||
and(
|
||||
eq(automationTriggerState.automationId, input.automationId),
|
||||
eq(automationTriggerState.stateKey, input.stateKey),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await this.context.drizzle.insert(automationTriggerState).values({
|
||||
automationId: input.automationId,
|
||||
stateKey: input.stateKey,
|
||||
breachStartedAt: input.breachStartedAt ?? null,
|
||||
lastFiredAt: input.lastFiredAt ?? null,
|
||||
lastValue: input.lastValue ?? null,
|
||||
lastObservedState: input.lastObservedState ?? null,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async clearBreach(automationId: number, stateKey: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(automationTriggerState)
|
||||
.set({ breachStartedAt: null, updatedAt: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(automationTriggerState.automationId, automationId),
|
||||
eq(automationTriggerState.stateKey, stateKey),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
/** Dwell windows the scheduler has to re-check without a fresh sample. */
|
||||
async listOpenBreaches(): Promise<TriggerStateRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automationTriggerState)
|
||||
.where(sql`${automationTriggerState.breachStartedAt} IS NOT NULL`);
|
||||
return rows.map(mapTriggerStateRow);
|
||||
}
|
||||
|
||||
// --- schedules ---
|
||||
|
||||
async upsertSchedule(input: {
|
||||
automationId: number;
|
||||
cron: string | null;
|
||||
intervalSeconds: number | null;
|
||||
timezone: string | null;
|
||||
nextDueAt: string | null;
|
||||
}): Promise<void> {
|
||||
const existing = await this.context.drizzle
|
||||
.select({ id: automationSchedules.id })
|
||||
.from(automationSchedules)
|
||||
.where(eq(automationSchedules.automationId, input.automationId))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await this.context.drizzle
|
||||
.update(automationSchedules)
|
||||
.set({
|
||||
cron: input.cron,
|
||||
intervalSeconds: input.intervalSeconds,
|
||||
timezone: input.timezone,
|
||||
nextDueAt: input.nextDueAt,
|
||||
})
|
||||
.where(eq(automationSchedules.automationId, input.automationId));
|
||||
} else {
|
||||
await this.context.drizzle.insert(automationSchedules).values(input);
|
||||
}
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteSchedule(automationId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(automationSchedules)
|
||||
.where(eq(automationSchedules.automationId, automationId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
/** Schedules due at or before `now`, joined to their enabled automation. */
|
||||
async listDueSchedules(now: string): Promise<DueScheduleRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
automationId: automationSchedules.automationId,
|
||||
cron: automationSchedules.cron,
|
||||
intervalSeconds: automationSchedules.intervalSeconds,
|
||||
timezone: automationSchedules.timezone,
|
||||
nextDueAt: automationSchedules.nextDueAt,
|
||||
})
|
||||
.from(automationSchedules)
|
||||
.innerJoin(
|
||||
automations,
|
||||
eq(automations.id, automationSchedules.automationId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(automations.enabled, true),
|
||||
lte(automationSchedules.nextDueAt, now),
|
||||
),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async markScheduleTicked(
|
||||
automationId: number,
|
||||
nextDueAt: string | null,
|
||||
lastTickAt: string,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(automationSchedules)
|
||||
.set({ nextDueAt, lastTickAt })
|
||||
.where(eq(automationSchedules.automationId, automationId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
// --- runs ---
|
||||
|
||||
async createRun(input: {
|
||||
automationId: number;
|
||||
userId: string;
|
||||
triggerType: string;
|
||||
triggerContext?: string | null;
|
||||
status: string;
|
||||
dryRun?: boolean;
|
||||
parentRunId?: number | null;
|
||||
now?: string;
|
||||
}): Promise<AutomationRunRow> {
|
||||
const [created] = await insertReturning(this.context, automationRuns, {
|
||||
automationId: input.automationId,
|
||||
userId: input.userId,
|
||||
triggerType: input.triggerType,
|
||||
triggerContext: input.triggerContext ?? null,
|
||||
status: input.status,
|
||||
startedAt: input.now ?? new Date().toISOString(),
|
||||
dryRun: input.dryRun ?? false,
|
||||
parentRunId: input.parentRunId ?? null,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return mapRunRow(created);
|
||||
}
|
||||
|
||||
async finishRun(
|
||||
runId: number,
|
||||
input: {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
finishedAt?: string;
|
||||
durationMs?: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const finishedAt = input.finishedAt ?? new Date().toISOString();
|
||||
await this.context.drizzle
|
||||
.update(automationRuns)
|
||||
.set({
|
||||
status: input.status,
|
||||
error: input.error ?? null,
|
||||
finishedAt,
|
||||
durationMs: input.durationMs ?? null,
|
||||
})
|
||||
.where(eq(automationRuns.id, runId));
|
||||
|
||||
const run = await this.context.drizzle
|
||||
.select({
|
||||
automationId: automationRuns.automationId,
|
||||
startedAt: automationRuns.startedAt,
|
||||
})
|
||||
.from(automationRuns)
|
||||
.where(eq(automationRuns.id, runId))
|
||||
.limit(1);
|
||||
|
||||
if (run[0]) {
|
||||
await this.context.drizzle
|
||||
.update(automations)
|
||||
.set({ lastRunAt: run[0].startedAt, lastRunStatus: input.status })
|
||||
.where(eq(automations.id, run[0].automationId));
|
||||
}
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listRuns(
|
||||
userId: string,
|
||||
options: { automationId?: number; limit?: number; offset?: number } = {},
|
||||
): Promise<AutomationRunRow[]> {
|
||||
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
|
||||
const offset = Math.max(options.offset ?? 0, 0);
|
||||
|
||||
const where = options.automationId
|
||||
? and(
|
||||
eq(automationRuns.userId, userId),
|
||||
eq(automationRuns.automationId, options.automationId),
|
||||
)
|
||||
: eq(automationRuns.userId, userId);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
run: automationRuns,
|
||||
automationName: automations.name,
|
||||
})
|
||||
.from(automationRuns)
|
||||
.leftJoin(automations, eq(automations.id, automationRuns.automationId))
|
||||
.where(where)
|
||||
.orderBy(desc(automationRuns.startedAt), desc(automationRuns.id))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...mapRunRow(row.run),
|
||||
automation_name: row.automationName ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async findRunForUser(
|
||||
runId: number,
|
||||
userId: string,
|
||||
): Promise<AutomationRunRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automationRuns)
|
||||
.where(
|
||||
and(eq(automationRuns.id, runId), eq(automationRuns.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ? mapRunRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async countRunningFor(automationId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: automationRuns.id })
|
||||
.from(automationRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(automationRuns.automationId, automationId),
|
||||
eq(automationRuns.status, "running"),
|
||||
),
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// --- run steps ---
|
||||
|
||||
async createRunStep(input: {
|
||||
runId: number;
|
||||
stepIndex: number;
|
||||
stepId: string;
|
||||
stepType: string;
|
||||
status: string;
|
||||
now?: string;
|
||||
}): Promise<number> {
|
||||
const [created] = await insertReturning(this.context, automationRunSteps, {
|
||||
runId: input.runId,
|
||||
stepIndex: input.stepIndex,
|
||||
stepId: input.stepId,
|
||||
stepType: input.stepType,
|
||||
status: input.status,
|
||||
startedAt: input.now ?? new Date().toISOString(),
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async finishRunStep(
|
||||
stepRowId: number,
|
||||
input: {
|
||||
status: string;
|
||||
output?: string | null;
|
||||
error?: string | null;
|
||||
truncated?: boolean;
|
||||
finishedAt?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(automationRunSteps)
|
||||
.set({
|
||||
status: input.status,
|
||||
output: input.output ?? null,
|
||||
error: input.error ?? null,
|
||||
truncated: input.truncated ?? false,
|
||||
finishedAt: input.finishedAt ?? new Date().toISOString(),
|
||||
})
|
||||
.where(eq(automationRunSteps.id, stepRowId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listRunSteps(runId: number): Promise<AutomationRunStepRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automationRunSteps)
|
||||
.where(eq(automationRunSteps.runId, runId))
|
||||
.orderBy(asc(automationRunSteps.stepIndex));
|
||||
return rows.map(mapRunStepRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims run history. Called from the scheduler's daily sweep rather than on
|
||||
* every write, which is what made the alert engine's pruning expensive.
|
||||
*/
|
||||
async pruneRunsOlderThan(days: number): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
||||
const result = await this.context.drizzle
|
||||
.delete(automationRuns)
|
||||
.where(lt(automationRuns.startedAt, cutoff));
|
||||
const deleted = rowsAffected(result);
|
||||
if (deleted > 0) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Marks runs left behind by a crash so they do not block concurrency. */
|
||||
async failStaleRunningRuns(olderThanIso: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.update(automationRuns)
|
||||
.set({
|
||||
status: "failed",
|
||||
error: "Interrupted by a server restart",
|
||||
finishedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(automationRuns.status, "running"),
|
||||
lt(automationRuns.startedAt, olderThanIso),
|
||||
),
|
||||
);
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(automations)
|
||||
.where(eq(automations.userId, userId));
|
||||
const deleted = rowsAffected(result);
|
||||
if (deleted > 0) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private async listChannelIds(automationId: number): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ channelId: automationChannels.channelId })
|
||||
.from(automationChannels)
|
||||
.where(eq(automationChannels.automationId, automationId));
|
||||
return rows.map((row) => row.channelId);
|
||||
}
|
||||
|
||||
private async replaceChannels(
|
||||
automationId: number,
|
||||
userId: string,
|
||||
channelIds: number[],
|
||||
): Promise<number[]> {
|
||||
await this.context.drizzle
|
||||
.delete(automationChannels)
|
||||
.where(eq(automationChannels.automationId, automationId));
|
||||
|
||||
if (channelIds.length === 0) return [];
|
||||
|
||||
// One lookup for the whole set instead of a query per channel.
|
||||
const owned = await this.context.drizzle
|
||||
.select({ id: notificationChannels.id })
|
||||
.from(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.userId, userId),
|
||||
inArray(notificationChannels.id, channelIds),
|
||||
),
|
||||
);
|
||||
|
||||
const linked = owned.map((row) => row.id);
|
||||
if (linked.length > 0) {
|
||||
await this.context.drizzle
|
||||
.insert(automationChannels)
|
||||
.values(linked.map((channelId) => ({ automationId, channelId })));
|
||||
}
|
||||
return linked;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
|
||||
function mapAutomationRow(
|
||||
row: AutomationRecord,
|
||||
channels: number[] = [],
|
||||
): AutomationRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
definition: row.definition,
|
||||
definition_version: row.definitionVersion,
|
||||
concurrency_policy: row.concurrencyPolicy,
|
||||
max_run_seconds: row.maxRunSeconds,
|
||||
dry_run: row.dryRun ? 1 : 0,
|
||||
last_run_at: row.lastRunAt ?? null,
|
||||
last_run_status: row.lastRunStatus ?? null,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
channels,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEngineRow(row: AutomationRecord): AutomationEngineRow {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
enabled: !!row.enabled,
|
||||
definition: row.definition,
|
||||
concurrencyPolicy: row.concurrencyPolicy,
|
||||
maxRunSeconds: row.maxRunSeconds,
|
||||
dryRun: !!row.dryRun,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTriggerStateRow(row: TriggerStateRecord): TriggerStateRow {
|
||||
return {
|
||||
automationId: row.automationId,
|
||||
stateKey: row.stateKey,
|
||||
breachStartedAt: row.breachStartedAt ?? null,
|
||||
lastFiredAt: row.lastFiredAt ?? null,
|
||||
lastValue: row.lastValue ?? null,
|
||||
lastObservedState: row.lastObservedState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRunRow(row: AutomationRunRecord): AutomationRunRow {
|
||||
return {
|
||||
id: row.id,
|
||||
automation_id: row.automationId,
|
||||
user_id: row.userId,
|
||||
trigger_type: row.triggerType,
|
||||
trigger_context: row.triggerContext ?? null,
|
||||
status: row.status,
|
||||
started_at: row.startedAt,
|
||||
finished_at: row.finishedAt ?? null,
|
||||
duration_ms: row.durationMs ?? null,
|
||||
error: row.error ?? null,
|
||||
dry_run: row.dryRun ? 1 : 0,
|
||||
parent_run_id: row.parentRunId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRunStepRow(row: AutomationRunStepRecord): AutomationRunStepRow {
|
||||
return {
|
||||
id: row.id,
|
||||
run_id: row.runId,
|
||||
step_index: row.stepIndex,
|
||||
step_id: row.stepId,
|
||||
step_type: row.stepType,
|
||||
status: row.status,
|
||||
started_at: row.startedAt,
|
||||
finished_at: row.finishedAt ?? null,
|
||||
output: row.output ?? null,
|
||||
error: row.error ?? null,
|
||||
truncated: row.truncated ? 1 : 0,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { DatabaseContext } from "./database-context.js";
|
||||
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
|
||||
import { AiRepository } from "./ai-repository.js";
|
||||
import { NotificationChannelRepository } from "./notification-channel-repository.js";
|
||||
import { AutomationRepository } from "./automation-repository.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
import { CredentialRepository } from "./credential-repository.js";
|
||||
@@ -155,13 +154,6 @@ export function createCurrentNotificationChannelRepository(): NotificationChanne
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAutomationRepository(): AutomationRepository {
|
||||
return new AutomationRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("automation_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentApiKeyRepository(): ApiKeyRepository {
|
||||
return new ApiKeyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
} from "../../hosts/credential-username.js";
|
||||
import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js";
|
||||
import { emitInternalEvent } from "../../hosts/internal-events.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentOpksshTokenRepository,
|
||||
@@ -534,12 +534,9 @@ router.post(
|
||||
success: true,
|
||||
});
|
||||
|
||||
notifyAutomationInternalEvent(
|
||||
"host_added",
|
||||
userId,
|
||||
createdHost.id as number,
|
||||
{ name: String(name ?? ip) },
|
||||
);
|
||||
emitInternalEvent("host_added", userId, createdHost.id as number, {
|
||||
name: String(name ?? ip),
|
||||
});
|
||||
|
||||
res.json(stripSensitiveFields(resolvedHost));
|
||||
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
|
||||
@@ -2483,7 +2480,7 @@ router.delete(
|
||||
success: true,
|
||||
});
|
||||
|
||||
notifyAutomationInternalEvent("host_deleted", userId, numericHostId, {
|
||||
emitInternalEvent("host_deleted", userId, numericHostId, {
|
||||
name: hostToDelete.name ?? hostToDelete.ip,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
import { createPluginHosts } from "./ctx-hosts.js";
|
||||
import { createPluginSchedule } from "./schedule.js";
|
||||
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
@@ -730,6 +731,8 @@ export function createPluginContext(
|
||||
),
|
||||
),
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
notify: createPluginNotify({ manifest, audit: auditCall }),
|
||||
fetch: createPluginFetch({ manifest, audit: auditCall }),
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
|
||||
desktop: {
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
/**
|
||||
* The process-wide event bus behind ctx.events.
|
||||
*
|
||||
* This formalises the ad-hoc publish paths that already existed:
|
||||
* - hosts/automation-events.ts (generic named internal events)
|
||||
* - hosts/host-session-status.ts (the "host.session.status" topic)
|
||||
*
|
||||
* Each of those lazily imported the automations engine and swallowed errors,
|
||||
* for two reasons that still apply and are preserved here:
|
||||
* Core publishes here (hosts/internal-events.ts for generic named events,
|
||||
* hosts/host-session-status.ts for "host.session.status", and the topics in
|
||||
* TOPICS below) and plugins subscribe through ctx.events. Two rules:
|
||||
*
|
||||
* 1. Fire-and-forget. A failing subscriber must never disturb the caller. A
|
||||
* metrics poll or a host delete does not fail because an automation threw.
|
||||
* 2. No static import of the automations layer. It reads repositories, which
|
||||
* several hosts modules also pull in, so a static edge would close a
|
||||
* cycle. Subscribers register themselves here instead of being reached
|
||||
* into.
|
||||
* metrics poll or a host delete does not fail because a listener threw.
|
||||
* 2. No static import of a subscriber. Subscribers register themselves here
|
||||
* instead of being reached into, so core never names a plugin.
|
||||
*
|
||||
* Topics are dotted strings. The internal ones the server itself publishes are
|
||||
* listed in TOPICS; a plugin may emit and subscribe to any topic, but only
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* The heavy dependencies are imported lazily inside each function rather than
|
||||
* at module scope: several pull in the repository layer, and a static import
|
||||
* here would close the cycle hosts/automation-events.ts documents.
|
||||
* here would close an import cycle through the repositories.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
@@ -38,7 +38,10 @@ export type { PluginSettingsScope };
|
||||
* to it means deciding that every plugin holding settings:read-core may see
|
||||
* that value, so it is a review decision rather than a convenience.
|
||||
*/
|
||||
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = ["app_name"];
|
||||
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
|
||||
"app_name",
|
||||
"notification_private_endpoint_allowlist",
|
||||
];
|
||||
|
||||
type ChangeListener = (value: unknown) => void;
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../hosts/automation-events.js", () => ({
|
||||
notifyAutomationInternalEvent: vi.fn(),
|
||||
vi.mock("../../hosts/internal-events.js", () => ({
|
||||
emitInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = {
|
||||
|
||||
@@ -42,8 +42,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../hosts/automation-events.js", () => ({
|
||||
notifyAutomationInternalEvent: vi.fn(),
|
||||
vi.mock("../../hosts/internal-events.js", () => ({
|
||||
emitInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = {
|
||||
|
||||
@@ -22,8 +22,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(),
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
}));
|
||||
vi.mock("../../../hosts/automation-events.js", () => ({
|
||||
notifyAutomationInternalEvent: vi.fn(),
|
||||
vi.mock("../../../hosts/internal-events.js", () => ({
|
||||
emitInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@simplewebauthn/server", () => ({
|
||||
generateAuthenticationOptions: vi
|
||||
|
||||
@@ -90,15 +90,15 @@ describe("declared dependencies", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// plugins/ai/src/backend/tools/executor.ts imports automations' routes
|
||||
// directly, so the loader has to start automations first. B18 replaces the
|
||||
// import with a service call and this becomes optional.
|
||||
it("records ai's dependency on automations", () => {
|
||||
// ai reaches automations through the automations.access service, so it
|
||||
// keeps working when automations is off.
|
||||
it("keeps ai's use of automations optional", () => {
|
||||
if (!ids.includes("ai")) return;
|
||||
|
||||
const { manifest } = parseManifest(readManifest("ai"));
|
||||
|
||||
expect(manifest?.dependencies?.automations).toBeTruthy();
|
||||
expect(manifest?.dependencies?.automations).toBeUndefined();
|
||||
expect(manifest?.optionalDependencies?.automations).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* ctx.notify and ctx.fetch: each is gated on its capability and audited,
|
||||
* notify only ever reaches the acting user's own channels, and fetch goes
|
||||
* through the SSRF guard with only the hosts the caller listed.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
channels: new Map<
|
||||
string,
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
}>
|
||||
>(),
|
||||
delivered: [] as Array<{ channelId: number; title: string }>,
|
||||
failChannel: null as number | null,
|
||||
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentNotificationChannelRepository: () => ({
|
||||
listNotificationChannels: async (userId: string) =>
|
||||
state.channels.get(userId) ?? [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/notification-sender.js", () => ({
|
||||
deliverNotification: async (
|
||||
channel: { id: number },
|
||||
notification: { title: string },
|
||||
) => {
|
||||
if (channel.id === state.failChannel) throw new Error("HTTP 500");
|
||||
state.delivered.push({ channelId: channel.id, title: notification.title });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: async (
|
||||
url: string,
|
||||
_init: RequestInit,
|
||||
allowlist: readonly string[],
|
||||
) => {
|
||||
state.fetchCalls.push({ url, allowlist });
|
||||
return new Response("ok", { status: 200 });
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
grants.set("demo", capabilities);
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
state.channels.clear();
|
||||
state.delivered.length = 0;
|
||||
state.failChannel = null;
|
||||
state.fetchCalls.length = 0;
|
||||
state.channels.set("alice", [
|
||||
{ id: 1, name: "ops", type: "webhook", config: "{}", enabled: 1 },
|
||||
{ id: 2, name: "muted", type: "ntfy", config: "{}", enabled: 0 },
|
||||
{ id: 3, name: "pager", type: "discord", config: "{}", enabled: 1 },
|
||||
]);
|
||||
state.channels.set("bob", [
|
||||
{ id: 9, name: "bob's", type: "webhook", config: "{}", enabled: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("ctx.notify", () => {
|
||||
it("refuses without notify:send and audits the refusal", async () => {
|
||||
const ctx = contextFor([]);
|
||||
await expect(
|
||||
runAsActor("alice", "request", () => ctx.notify.channels()),
|
||||
).rejects.toThrow(/notify:send/);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_notify_channels",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a call with no acting user", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
await expect(
|
||||
ctx.notify.send([1], { title: "t", body: "b" }),
|
||||
).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("lists the actor's channels without their config", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
const channels = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.channels(),
|
||||
);
|
||||
expect(channels).toEqual([
|
||||
{ id: 1, name: "ops", type: "webhook", enabled: true },
|
||||
{ id: 2, name: "muted", type: "ntfy", enabled: false },
|
||||
{ id: 3, name: "pager", type: "discord", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends only to the actor's enabled channels and reports failures", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
state.failChannel = 3;
|
||||
|
||||
const result = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.send([1, 2, 3, 9], { title: "Disk", body: "full" }),
|
||||
);
|
||||
|
||||
expect(state.delivered).toEqual([{ channelId: 1, title: "Disk" }]);
|
||||
expect(result).toEqual({
|
||||
delivered: 1,
|
||||
failures: [{ channelId: 3, name: "pager", error: "HTTP 500" }],
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_notify_send",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.fetch", () => {
|
||||
it("refuses without network:outbound", async () => {
|
||||
const ctx = contextFor([]);
|
||||
await expect(ctx.fetch("https://example.com")).rejects.toThrow(
|
||||
/network:outbound/,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_fetch",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("goes through the SSRF guard with the listed private hosts only", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
const response = await ctx.fetch("https://ntfy.lan/topic", {
|
||||
method: "POST",
|
||||
body: "hi",
|
||||
allowPrivateHosts: ["NTFY.LAN"],
|
||||
});
|
||||
expect(await response.text()).toBe("ok");
|
||||
expect(state.fetchCalls).toEqual([
|
||||
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -108,13 +108,13 @@ describe("hosts publishers route through the bus", () => {
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
it("notifyAutomationInternalEvent emits internal.event", async () => {
|
||||
const { notifyAutomationInternalEvent } =
|
||||
await import("../../hosts/automation-events.js");
|
||||
it("emitInternalEvent emits internal.event", async () => {
|
||||
const { emitInternalEvent } =
|
||||
await import("../../hosts/internal-events.js");
|
||||
const listener = vi.fn();
|
||||
pluginEvents.on(TOPICS.internalEvent, listener);
|
||||
|
||||
notifyAutomationInternalEvent("host_deleted", "user-1", 42, { a: 1 });
|
||||
emitInternalEvent("host_deleted", "user-1", 42, { a: 1 });
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
event: "host_deleted",
|
||||
@@ -124,13 +124,13 @@ describe("hosts publishers route through the bus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("notifyAutomationInternalEvent ignores a missing userId", async () => {
|
||||
const { notifyAutomationInternalEvent } =
|
||||
await import("../../hosts/automation-events.js");
|
||||
it("emitInternalEvent ignores a missing userId", async () => {
|
||||
const { emitInternalEvent } =
|
||||
await import("../../hosts/internal-events.js");
|
||||
const listener = vi.fn();
|
||||
pluginEvents.on(TOPICS.internalEvent, listener);
|
||||
|
||||
notifyAutomationInternalEvent("host_deleted", "");
|
||||
emitInternalEvent("host_deleted", "");
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -146,3 +146,13 @@ export {
|
||||
parseCustomKeybindings,
|
||||
} from "@/api/open-tabs-api";
|
||||
export { setHostAutoTmux } from "@/api/host-terminal-config-api";
|
||||
|
||||
// Notification channels are core (ctx.notify on the backend). A plugin that
|
||||
// lets users pick or manage them shows core's own dialog.
|
||||
export { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
|
||||
export {
|
||||
getNotificationChannels,
|
||||
deleteNotificationChannel,
|
||||
testNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/notification-channels-api";
|
||||
Reference in new issue
Block a user