feat: finish the automations plugin (B17)

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

No files matched your search

+3 -3
View File
@@ -1,6 +1,6 @@
/**
* The one place a login becomes a session: JWT, cookie, response body,
* audit line and the automation event. Every login method ends here, so
* audit line and the user_login internal event. Every login method ends here, so
* they all behave the same way.
*/
@@ -10,7 +10,7 @@ import { authLogger } from "../utils/logger.js";
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { notifyAutomationInternalEvent } from "../hosts/automation-events.js";
import { emitInternalEvent } from "../hosts/internal-events.js";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import type { UserRecord } from "../database/repositories/user-repository.js";
@@ -112,7 +112,7 @@ export async function issueSession(
userAgent,
success: true,
});
notifyAutomationInternalEvent("user_login", user.id, undefined, {
emitInternalEvent("user_login", user.id, undefined, {
username: user.username,
ipAddress,
});
-152
View File
@@ -2030,158 +2030,6 @@ const migrateSchema = () => {
}
// --- alerts end ---
// --- automations begin ---
try {
sqlite.prepare("SELECT id FROM automations LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
definition TEXT NOT NULL,
definition_version INTEGER NOT NULL DEFAULT 1,
concurrency_policy TEXT NOT NULL DEFAULT 'skip',
max_run_seconds INTEGER NOT NULL DEFAULT 300,
dry_run INTEGER NOT NULL DEFAULT 0,
last_run_at TEXT,
last_run_status TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automations table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_trigger_state LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_trigger_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
state_key TEXT NOT NULL,
breach_started_at TEXT,
last_fired_at TEXT,
last_value REAL,
last_observed_state TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_trigger_state table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_schedules LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
cron TEXT,
interval_seconds INTEGER,
timezone TEXT,
next_due_at TEXT,
last_tick_at TEXT
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_schedules table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_runs LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL,
trigger_context TEXT,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
duration_ms INTEGER,
error TEXT,
dry_run INTEGER NOT NULL DEFAULT 0,
parent_run_id INTEGER
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_runs table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_run_steps LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_run_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE,
step_index INTEGER NOT NULL,
step_id TEXT NOT NULL,
step_type TEXT NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
output TEXT,
error TEXT,
truncated INTEGER NOT NULL DEFAULT 0
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_run_steps table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM automation_channels LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS automation_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create automation_channels table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- automations end ---
// --- homepage begin ---
try {
@@ -125,51 +125,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
},
// Automations.
{
name: "idx_automations_user",
table: "automations",
columns: "user_id, enabled",
},
{
name: "idx_automation_trigger_state_key",
table: "automation_trigger_state",
columns: "automation_id, state_key",
unique: true,
},
{
name: "idx_automation_schedules_automation",
table: "automation_schedules",
columns: "automation_id",
unique: true,
},
{
name: "idx_automation_schedules_due",
table: "automation_schedules",
columns: "next_due_at",
},
{
name: "idx_automation_runs_automation",
table: "automation_runs",
columns: "automation_id, started_at",
},
{
name: "idx_automation_runs_user",
table: "automation_runs",
columns: "user_id, started_at",
},
{
name: "idx_automation_run_steps_run",
table: "automation_run_steps",
columns: "run_id, step_index",
},
{
name: "idx_automation_channels_pair",
table: "automation_channels",
columns: "automation_id, channel_id",
unique: true,
},
// AI assistant.
{
name: "idx_ai_providers_user_label",
-159
View File
@@ -13,7 +13,6 @@ import {
varchar,
int,
boolean,
double,
index,
uniqueIndex,
foreignKey,
@@ -1032,164 +1031,6 @@ export const notificationChannels = mysqlTable("notification_channels", {
});
// --- alerts end ---
// --- automations begin ---
export const automations = mysqlTable(
"automations",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
enabled: boolean("enabled").notNull().default(true),
// The whole trigger + steps graph, shaped by AutomationDefinition. Read and
// written as a unit, never queried by its inner structure.
definition: text("definition").notNull(),
definitionVersion: int("definition_version").notNull().default(1),
concurrencyPolicy: text("concurrency_policy").notNull().default("skip"),
maxRunSeconds: int("max_run_seconds").notNull().default(300),
dryRun: boolean("dry_run").notNull().default(false),
lastRunAt: text("last_run_at"),
lastRunStatus: text("last_run_status"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// The scheduler sweeps enabled automations for one user at a time.
(table) => [index("idx_automations_user").on(table.userId, table.enabled)],
);
/**
* Durable per-target trigger state. state_key scopes a trigger to what it is
* actually watching ("<hostId>", "<hostId>:/data", "<hostId>:<container>"), so
* a sustained-breach window can track one filesystem rather than a whole host.
* Living in the database rather than memory means cooldowns and dwell windows
* survive a restart.
*/
export const automationTriggerState = mysqlTable(
"automation_trigger_state",
{
id: int("id").autoincrement().primaryKey(),
automationId: int("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
stateKey: varchar("state_key", { length: 255 }).notNull(),
breachStartedAt: text("breach_started_at"),
lastFiredAt: text("last_fired_at"),
lastValue: double("last_value"),
lastObservedState: text("last_observed_state"),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_automation_trigger_state_key").on(
table.automationId,
table.stateKey,
),
],
);
export const automationSchedules = mysqlTable(
"automation_schedules",
{
id: int("id").autoincrement().primaryKey(),
automationId: int("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
cron: text("cron"),
intervalSeconds: int("interval_seconds"),
timezone: text("timezone"),
nextDueAt: varchar("next_due_at", { length: 255 }),
lastTickAt: text("last_tick_at"),
},
(table) => [
uniqueIndex("idx_automation_schedules_automation").on(table.automationId),
index("idx_automation_schedules_due").on(table.nextDueAt),
],
);
export const automationRuns = mysqlTable(
"automation_runs",
{
id: int("id").autoincrement().primaryKey(),
automationId: int("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
triggerType: text("trigger_type").notNull(),
triggerContext: text("trigger_context"),
status: varchar("status", { length: 255 }).notNull(),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
finishedAt: text("finished_at"),
durationMs: int("duration_ms"),
error: text("error"),
dryRun: boolean("dry_run").notNull().default(false),
// Set when one automation invoked another, so a chain can be traced.
parentRunId: int("parent_run_id"),
},
(table) => [
index("idx_automation_runs_automation").on(
table.automationId,
table.startedAt,
),
index("idx_automation_runs_user").on(table.userId, table.startedAt),
],
);
export const automationRunSteps = mysqlTable(
"automation_run_steps",
{
id: int("id").autoincrement().primaryKey(),
runId: int("run_id")
.notNull()
.references(() => automationRuns.id, { onDelete: "cascade" }),
stepIndex: int("step_index").notNull(),
stepId: text("step_id").notNull(),
stepType: text("step_type").notNull(),
status: varchar("status", { length: 255 }).notNull(),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
finishedAt: text("finished_at"),
output: text("output"),
error: text("error"),
truncated: boolean("truncated")
.notNull()
.default(false),
},
(table) => [
index("idx_automation_run_steps_run").on(table.runId, table.stepIndex),
],
);
export const automationChannels = mysqlTable(
"automation_channels",
{
id: int("id").autoincrement().primaryKey(),
automationId: int("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
channelId: int("channel_id")
.notNull()
.references(() => notificationChannels.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("idx_automation_channels_pair").on(
table.automationId,
table.channelId,
),
],
);
// --- automations end ---
// --- homepage begin ---
export const homepageItems = mysqlTable(
-159
View File
@@ -14,7 +14,6 @@ import {
integer,
serial,
boolean,
doublePrecision,
index,
uniqueIndex,
foreignKey,
@@ -1033,164 +1032,6 @@ export const notificationChannels = pgTable("notification_channels", {
});
// --- alerts end ---
// --- automations begin ---
export const automations = pgTable(
"automations",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
enabled: boolean("enabled").notNull().default(true),
// The whole trigger + steps graph, shaped by AutomationDefinition. Read and
// written as a unit, never queried by its inner structure.
definition: text("definition").notNull(),
definitionVersion: integer("definition_version").notNull().default(1),
concurrencyPolicy: text("concurrency_policy").notNull().default("skip"),
maxRunSeconds: integer("max_run_seconds").notNull().default(300),
dryRun: boolean("dry_run").notNull().default(false),
lastRunAt: text("last_run_at"),
lastRunStatus: text("last_run_status"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// The scheduler sweeps enabled automations for one user at a time.
(table) => [index("idx_automations_user").on(table.userId, table.enabled)],
);
/**
* Durable per-target trigger state. state_key scopes a trigger to what it is
* actually watching ("<hostId>", "<hostId>:/data", "<hostId>:<container>"), so
* a sustained-breach window can track one filesystem rather than a whole host.
* Living in the database rather than memory means cooldowns and dwell windows
* survive a restart.
*/
export const automationTriggerState = pgTable(
"automation_trigger_state",
{
id: serial("id").primaryKey(),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
stateKey: varchar("state_key", { length: 255 }).notNull(),
breachStartedAt: text("breach_started_at"),
lastFiredAt: text("last_fired_at"),
lastValue: doublePrecision("last_value"),
lastObservedState: text("last_observed_state"),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_automation_trigger_state_key").on(
table.automationId,
table.stateKey,
),
],
);
export const automationSchedules = pgTable(
"automation_schedules",
{
id: serial("id").primaryKey(),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
cron: text("cron"),
intervalSeconds: integer("interval_seconds"),
timezone: text("timezone"),
nextDueAt: varchar("next_due_at", { length: 255 }),
lastTickAt: text("last_tick_at"),
},
(table) => [
uniqueIndex("idx_automation_schedules_automation").on(table.automationId),
index("idx_automation_schedules_due").on(table.nextDueAt),
],
);
export const automationRuns = pgTable(
"automation_runs",
{
id: serial("id").primaryKey(),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
triggerType: text("trigger_type").notNull(),
triggerContext: text("trigger_context"),
status: varchar("status", { length: 255 }).notNull(),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
finishedAt: text("finished_at"),
durationMs: integer("duration_ms"),
error: text("error"),
dryRun: boolean("dry_run").notNull().default(false),
// Set when one automation invoked another, so a chain can be traced.
parentRunId: integer("parent_run_id"),
},
(table) => [
index("idx_automation_runs_automation").on(
table.automationId,
table.startedAt,
),
index("idx_automation_runs_user").on(table.userId, table.startedAt),
],
);
export const automationRunSteps = pgTable(
"automation_run_steps",
{
id: serial("id").primaryKey(),
runId: integer("run_id")
.notNull()
.references(() => automationRuns.id, { onDelete: "cascade" }),
stepIndex: integer("step_index").notNull(),
stepId: text("step_id").notNull(),
stepType: text("step_type").notNull(),
status: varchar("status", { length: 255 }).notNull(),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
finishedAt: text("finished_at"),
output: text("output"),
error: text("error"),
truncated: boolean("truncated")
.notNull()
.default(false),
},
(table) => [
index("idx_automation_run_steps_run").on(table.runId, table.stepIndex),
],
);
export const automationChannels = pgTable(
"automation_channels",
{
id: serial("id").primaryKey(),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
channelId: integer("channel_id")
.notNull()
.references(() => notificationChannels.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("idx_automation_channels_pair").on(
table.automationId,
table.channelId,
),
],
);
// --- automations end ---
// --- homepage begin ---
export const homepageItems = pgTable(
-159
View File
@@ -2,7 +2,6 @@ import {
sqliteTable,
text,
integer,
real,
index,
uniqueIndex,
foreignKey,
@@ -1029,164 +1028,6 @@ export const notificationChannels = sqliteTable("notification_channels", {
});
// --- alerts end ---
// --- automations begin ---
export const automations = sqliteTable(
"automations",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
// The whole trigger + steps graph, shaped by AutomationDefinition. Read and
// written as a unit, never queried by its inner structure.
definition: text("definition").notNull(),
definitionVersion: integer("definition_version").notNull().default(1),
concurrencyPolicy: text("concurrency_policy").notNull().default("skip"),
maxRunSeconds: integer("max_run_seconds").notNull().default(300),
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
lastRunAt: text("last_run_at"),
lastRunStatus: text("last_run_status"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// The scheduler sweeps enabled automations for one user at a time.
(table) => [index("idx_automations_user").on(table.userId, table.enabled)],
);
/**
* Durable per-target trigger state. state_key scopes a trigger to what it is
* actually watching ("<hostId>", "<hostId>:/data", "<hostId>:<container>"), so
* a sustained-breach window can track one filesystem rather than a whole host.
* Living in the database rather than memory means cooldowns and dwell windows
* survive a restart.
*/
export const automationTriggerState = sqliteTable(
"automation_trigger_state",
{
id: integer("id").primaryKey({ autoIncrement: true }),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
stateKey: text("state_key").notNull(),
breachStartedAt: text("breach_started_at"),
lastFiredAt: text("last_fired_at"),
lastValue: real("last_value"),
lastObservedState: text("last_observed_state"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_automation_trigger_state_key").on(
table.automationId,
table.stateKey,
),
],
);
export const automationSchedules = sqliteTable(
"automation_schedules",
{
id: integer("id").primaryKey({ autoIncrement: true }),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
cron: text("cron"),
intervalSeconds: integer("interval_seconds"),
timezone: text("timezone"),
nextDueAt: text("next_due_at"),
lastTickAt: text("last_tick_at"),
},
(table) => [
uniqueIndex("idx_automation_schedules_automation").on(table.automationId),
index("idx_automation_schedules_due").on(table.nextDueAt),
],
);
export const automationRuns = sqliteTable(
"automation_runs",
{
id: integer("id").primaryKey({ autoIncrement: true }),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
triggerType: text("trigger_type").notNull(),
triggerContext: text("trigger_context"),
status: text("status").notNull(),
startedAt: text("started_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
finishedAt: text("finished_at"),
durationMs: integer("duration_ms"),
error: text("error"),
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
// Set when one automation invoked another, so a chain can be traced.
parentRunId: integer("parent_run_id"),
},
(table) => [
index("idx_automation_runs_automation").on(
table.automationId,
table.startedAt,
),
index("idx_automation_runs_user").on(table.userId, table.startedAt),
],
);
export const automationRunSteps = sqliteTable(
"automation_run_steps",
{
id: integer("id").primaryKey({ autoIncrement: true }),
runId: integer("run_id")
.notNull()
.references(() => automationRuns.id, { onDelete: "cascade" }),
stepIndex: integer("step_index").notNull(),
stepId: text("step_id").notNull(),
stepType: text("step_type").notNull(),
status: text("status").notNull(),
startedAt: text("started_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
finishedAt: text("finished_at"),
output: text("output"),
error: text("error"),
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
},
(table) => [
index("idx_automation_run_steps_run").on(table.runId, table.stepIndex),
],
);
export const automationChannels = sqliteTable(
"automation_channels",
{
id: integer("id").primaryKey({ autoIncrement: true }),
automationId: integer("automation_id")
.notNull()
.references(() => automations.id, { onDelete: "cascade" }),
channelId: integer("channel_id")
.notNull()
.references(() => notificationChannels.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("idx_automation_channels_pair").on(
table.automationId,
table.channelId,
),
],
);
// --- automations end ---
// --- homepage begin ---
export const homepageItems = sqliteTable(
@@ -1,784 +0,0 @@
import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm";
import {
automationChannels,
automationRunSteps,
automationRuns,
automationSchedules,
automationTriggerState,
automations,
notificationChannels,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
type AutomationRecord = typeof automations.$inferSelect;
type AutomationRunRecord = typeof automationRuns.$inferSelect;
type AutomationRunStepRecord = typeof automationRunSteps.$inferSelect;
type TriggerStateRecord = typeof automationTriggerState.$inferSelect;
type ScheduleRecord = typeof automationSchedules.$inferSelect;
export interface AutomationRow {
id: number;
user_id: string;
name: string;
description: string | null;
enabled: number;
definition: string;
definition_version: number;
concurrency_policy: string;
max_run_seconds: number;
dry_run: number;
last_run_at: string | null;
last_run_status: string | null;
created_at: string;
updated_at: string;
channels: number[];
}
export interface AutomationRunRow {
id: number;
automation_id: number;
user_id: string;
trigger_type: string;
trigger_context: string | null;
status: string;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
error: string | null;
dry_run: number;
parent_run_id: number | null;
automation_name?: string | null;
}
export interface AutomationRunStepRow {
id: number;
run_id: number;
step_index: number;
step_id: string;
step_type: string;
status: string;
started_at: string;
finished_at: string | null;
output: string | null;
error: string | null;
truncated: number;
}
/** The shape the engine loads; camelCase and already parsed where useful. */
export interface AutomationEngineRow {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
export interface TriggerStateRow {
automationId: number;
stateKey: string;
breachStartedAt: string | null;
lastFiredAt: string | null;
lastValue: number | null;
lastObservedState: string | null;
}
export interface DueScheduleRow {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}
export class AutomationRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async list(userId: string): Promise<AutomationRow[]> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(eq(automations.userId, userId))
.orderBy(asc(automations.name));
if (rows.length === 0) return [];
// One query for every automation's channels rather than one per row.
const links = await this.context.drizzle
.select({
automationId: automationChannels.automationId,
channelId: automationChannels.channelId,
})
.from(automationChannels)
.where(
inArray(
automationChannels.automationId,
rows.map((row) => row.id),
),
);
const byAutomation = new Map<number, number[]>();
for (const link of links) {
const list = byAutomation.get(link.automationId) ?? [];
list.push(link.channelId);
byAutomation.set(link.automationId, list);
}
return rows.map((row) => mapAutomationRow(row, byAutomation.get(row.id)));
}
async findForUser(id: number, userId: string): Promise<AutomationRow | null> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)))
.limit(1);
if (!rows[0]) return null;
return mapAutomationRow(rows[0], await this.listChannelIds(id));
}
async findById(id: number): Promise<AutomationEngineRow | null> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(eq(automations.id, id))
.limit(1);
return rows[0] ? mapEngineRow(rows[0]) : null;
}
async create(input: {
userId: string;
name: string;
description?: string | null;
enabled?: boolean;
definition: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
now?: string;
}): Promise<AutomationRow> {
const now = input.now ?? new Date().toISOString();
const [created] = await insertReturning(this.context, automations, {
userId: input.userId,
name: input.name,
description: input.description ?? null,
enabled: input.enabled ?? true,
definition: input.definition,
concurrencyPolicy: input.concurrencyPolicy ?? "skip",
maxRunSeconds: input.maxRunSeconds ?? 300,
dryRun: input.dryRun ?? false,
createdAt: now,
updatedAt: now,
});
const channels = await this.replaceChannels(
created.id,
input.userId,
input.channels ?? [],
);
await this.afterWrite();
return mapAutomationRow(created, channels);
}
async update(
id: number,
userId: string,
input: {
name?: string;
description?: string | null;
enabled?: boolean;
definition?: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
now?: string;
},
): Promise<AutomationRow | null> {
const { channels, now, ...fields } = input;
const values: Record<string, unknown> = { ...fields };
if (Object.keys(values).length > 0) {
values.updatedAt = now ?? new Date().toISOString();
const [updated] = await updateReturning(
this.context,
automations,
values,
and(eq(automations.id, id), eq(automations.userId, userId)),
);
if (!updated) return null;
} else {
const existing = await this.findForUser(id, userId);
if (!existing) return null;
}
if (channels) {
await this.replaceChannels(id, userId, channels);
}
await this.afterWrite();
return this.findForUser(id, userId);
}
async delete(id: number, userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
const deleted = rowsAffected(result) > 0;
if (deleted) await this.afterWrite();
return deleted;
}
async setEnabled(
id: number,
userId: string,
enabled: boolean,
): Promise<boolean> {
const result = await this.context.drizzle
.update(automations)
.set({ enabled, updatedAt: new Date().toISOString() })
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
const changed = rowsAffected(result) > 0;
if (changed) await this.afterWrite();
return changed;
}
/**
* Enabled automations owned by whoever owns the given host. Wildcard targets
* must never reach across users, which is the bug the alert engine shipped
* with.
*/
async listEnabledForUser(userId: string): Promise<AutomationEngineRow[]> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(
and(eq(automations.enabled, true), eq(automations.userId, userId)),
);
return rows.map(mapEngineRow);
}
async listAllEnabled(): Promise<AutomationEngineRow[]> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(eq(automations.enabled, true));
return rows.map(mapEngineRow);
}
// --- trigger state ---
async getTriggerState(
automationId: number,
stateKey: string,
): Promise<TriggerStateRow | null> {
const rows = await this.context.drizzle
.select()
.from(automationTriggerState)
.where(
and(
eq(automationTriggerState.automationId, automationId),
eq(automationTriggerState.stateKey, stateKey),
),
)
.limit(1);
return rows[0] ? mapTriggerStateRow(rows[0]) : null;
}
async upsertTriggerState(input: {
automationId: number;
stateKey: string;
breachStartedAt?: string | null;
lastFiredAt?: string | null;
lastValue?: number | null;
lastObservedState?: string | null;
}): Promise<void> {
const existing = await this.getTriggerState(
input.automationId,
input.stateKey,
);
const updatedAt = new Date().toISOString();
if (existing) {
const values: Record<string, unknown> = { updatedAt };
if (input.breachStartedAt !== undefined)
values.breachStartedAt = input.breachStartedAt;
if (input.lastFiredAt !== undefined)
values.lastFiredAt = input.lastFiredAt;
if (input.lastValue !== undefined) values.lastValue = input.lastValue;
if (input.lastObservedState !== undefined)
values.lastObservedState = input.lastObservedState;
await this.context.drizzle
.update(automationTriggerState)
.set(values)
.where(
and(
eq(automationTriggerState.automationId, input.automationId),
eq(automationTriggerState.stateKey, input.stateKey),
),
);
} else {
await this.context.drizzle.insert(automationTriggerState).values({
automationId: input.automationId,
stateKey: input.stateKey,
breachStartedAt: input.breachStartedAt ?? null,
lastFiredAt: input.lastFiredAt ?? null,
lastValue: input.lastValue ?? null,
lastObservedState: input.lastObservedState ?? null,
updatedAt,
});
}
await this.afterWrite();
}
async clearBreach(automationId: number, stateKey: string): Promise<void> {
await this.context.drizzle
.update(automationTriggerState)
.set({ breachStartedAt: null, updatedAt: new Date().toISOString() })
.where(
and(
eq(automationTriggerState.automationId, automationId),
eq(automationTriggerState.stateKey, stateKey),
),
);
await this.afterWrite();
}
/** Dwell windows the scheduler has to re-check without a fresh sample. */
async listOpenBreaches(): Promise<TriggerStateRow[]> {
const rows = await this.context.drizzle
.select()
.from(automationTriggerState)
.where(sql`${automationTriggerState.breachStartedAt} IS NOT NULL`);
return rows.map(mapTriggerStateRow);
}
// --- schedules ---
async upsertSchedule(input: {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}): Promise<void> {
const existing = await this.context.drizzle
.select({ id: automationSchedules.id })
.from(automationSchedules)
.where(eq(automationSchedules.automationId, input.automationId))
.limit(1);
if (existing[0]) {
await this.context.drizzle
.update(automationSchedules)
.set({
cron: input.cron,
intervalSeconds: input.intervalSeconds,
timezone: input.timezone,
nextDueAt: input.nextDueAt,
})
.where(eq(automationSchedules.automationId, input.automationId));
} else {
await this.context.drizzle.insert(automationSchedules).values(input);
}
await this.afterWrite();
}
async deleteSchedule(automationId: number): Promise<void> {
await this.context.drizzle
.delete(automationSchedules)
.where(eq(automationSchedules.automationId, automationId));
await this.afterWrite();
}
/** Schedules due at or before `now`, joined to their enabled automation. */
async listDueSchedules(now: string): Promise<DueScheduleRow[]> {
const rows = await this.context.drizzle
.select({
automationId: automationSchedules.automationId,
cron: automationSchedules.cron,
intervalSeconds: automationSchedules.intervalSeconds,
timezone: automationSchedules.timezone,
nextDueAt: automationSchedules.nextDueAt,
})
.from(automationSchedules)
.innerJoin(
automations,
eq(automations.id, automationSchedules.automationId),
)
.where(
and(
eq(automations.enabled, true),
lte(automationSchedules.nextDueAt, now),
),
);
return rows;
}
async markScheduleTicked(
automationId: number,
nextDueAt: string | null,
lastTickAt: string,
): Promise<void> {
await this.context.drizzle
.update(automationSchedules)
.set({ nextDueAt, lastTickAt })
.where(eq(automationSchedules.automationId, automationId));
await this.afterWrite();
}
// --- runs ---
async createRun(input: {
automationId: number;
userId: string;
triggerType: string;
triggerContext?: string | null;
status: string;
dryRun?: boolean;
parentRunId?: number | null;
now?: string;
}): Promise<AutomationRunRow> {
const [created] = await insertReturning(this.context, automationRuns, {
automationId: input.automationId,
userId: input.userId,
triggerType: input.triggerType,
triggerContext: input.triggerContext ?? null,
status: input.status,
startedAt: input.now ?? new Date().toISOString(),
dryRun: input.dryRun ?? false,
parentRunId: input.parentRunId ?? null,
});
await this.afterWrite();
return mapRunRow(created);
}
async finishRun(
runId: number,
input: {
status: string;
error?: string | null;
finishedAt?: string;
durationMs?: number | null;
},
): Promise<void> {
const finishedAt = input.finishedAt ?? new Date().toISOString();
await this.context.drizzle
.update(automationRuns)
.set({
status: input.status,
error: input.error ?? null,
finishedAt,
durationMs: input.durationMs ?? null,
})
.where(eq(automationRuns.id, runId));
const run = await this.context.drizzle
.select({
automationId: automationRuns.automationId,
startedAt: automationRuns.startedAt,
})
.from(automationRuns)
.where(eq(automationRuns.id, runId))
.limit(1);
if (run[0]) {
await this.context.drizzle
.update(automations)
.set({ lastRunAt: run[0].startedAt, lastRunStatus: input.status })
.where(eq(automations.id, run[0].automationId));
}
await this.afterWrite();
}
async listRuns(
userId: string,
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
const offset = Math.max(options.offset ?? 0, 0);
const where = options.automationId
? and(
eq(automationRuns.userId, userId),
eq(automationRuns.automationId, options.automationId),
)
: eq(automationRuns.userId, userId);
const rows = await this.context.drizzle
.select({
run: automationRuns,
automationName: automations.name,
})
.from(automationRuns)
.leftJoin(automations, eq(automations.id, automationRuns.automationId))
.where(where)
.orderBy(desc(automationRuns.startedAt), desc(automationRuns.id))
.limit(limit)
.offset(offset);
return rows.map((row) => ({
...mapRunRow(row.run),
automation_name: row.automationName ?? null,
}));
}
async findRunForUser(
runId: number,
userId: string,
): Promise<AutomationRunRow | null> {
const rows = await this.context.drizzle
.select()
.from(automationRuns)
.where(
and(eq(automationRuns.id, runId), eq(automationRuns.userId, userId)),
)
.limit(1);
return rows[0] ? mapRunRow(rows[0]) : null;
}
async countRunningFor(automationId: number): Promise<number> {
const rows = await this.context.drizzle
.select({ id: automationRuns.id })
.from(automationRuns)
.where(
and(
eq(automationRuns.automationId, automationId),
eq(automationRuns.status, "running"),
),
);
return rows.length;
}
// --- run steps ---
async createRunStep(input: {
runId: number;
stepIndex: number;
stepId: string;
stepType: string;
status: string;
now?: string;
}): Promise<number> {
const [created] = await insertReturning(this.context, automationRunSteps, {
runId: input.runId,
stepIndex: input.stepIndex,
stepId: input.stepId,
stepType: input.stepType,
status: input.status,
startedAt: input.now ?? new Date().toISOString(),
});
await this.afterWrite();
return created.id;
}
async finishRunStep(
stepRowId: number,
input: {
status: string;
output?: string | null;
error?: string | null;
truncated?: boolean;
finishedAt?: string;
},
): Promise<void> {
await this.context.drizzle
.update(automationRunSteps)
.set({
status: input.status,
output: input.output ?? null,
error: input.error ?? null,
truncated: input.truncated ?? false,
finishedAt: input.finishedAt ?? new Date().toISOString(),
})
.where(eq(automationRunSteps.id, stepRowId));
await this.afterWrite();
}
async listRunSteps(runId: number): Promise<AutomationRunStepRow[]> {
const rows = await this.context.drizzle
.select()
.from(automationRunSteps)
.where(eq(automationRunSteps.runId, runId))
.orderBy(asc(automationRunSteps.stepIndex));
return rows.map(mapRunStepRow);
}
/**
* Trims run history. Called from the scheduler's daily sweep rather than on
* every write, which is what made the alert engine's pruning expensive.
*/
async pruneRunsOlderThan(days: number): Promise<number> {
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
const result = await this.context.drizzle
.delete(automationRuns)
.where(lt(automationRuns.startedAt, cutoff));
const deleted = rowsAffected(result);
if (deleted > 0) await this.afterWrite();
return deleted;
}
/** Marks runs left behind by a crash so they do not block concurrency. */
async failStaleRunningRuns(olderThanIso: string): Promise<number> {
const result = await this.context.drizzle
.update(automationRuns)
.set({
status: "failed",
error: "Interrupted by a server restart",
finishedAt: new Date().toISOString(),
})
.where(
and(
eq(automationRuns.status, "running"),
lt(automationRuns.startedAt, olderThanIso),
),
);
const affected = rowsAffected(result);
if (affected > 0) await this.afterWrite();
return affected;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(automations)
.where(eq(automations.userId, userId));
const deleted = rowsAffected(result);
if (deleted > 0) await this.afterWrite();
return deleted;
}
private async listChannelIds(automationId: number): Promise<number[]> {
const rows = await this.context.drizzle
.select({ channelId: automationChannels.channelId })
.from(automationChannels)
.where(eq(automationChannels.automationId, automationId));
return rows.map((row) => row.channelId);
}
private async replaceChannels(
automationId: number,
userId: string,
channelIds: number[],
): Promise<number[]> {
await this.context.drizzle
.delete(automationChannels)
.where(eq(automationChannels.automationId, automationId));
if (channelIds.length === 0) return [];
// One lookup for the whole set instead of a query per channel.
const owned = await this.context.drizzle
.select({ id: notificationChannels.id })
.from(notificationChannels)
.where(
and(
eq(notificationChannels.userId, userId),
inArray(notificationChannels.id, channelIds),
),
);
const linked = owned.map((row) => row.id);
if (linked.length > 0) {
await this.context.drizzle
.insert(automationChannels)
.values(linked.map((channelId) => ({ automationId, channelId })));
}
return linked;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function mapAutomationRow(
row: AutomationRecord,
channels: number[] = [],
): AutomationRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
description: row.description ?? null,
enabled: row.enabled ? 1 : 0,
definition: row.definition,
definition_version: row.definitionVersion,
concurrency_policy: row.concurrencyPolicy,
max_run_seconds: row.maxRunSeconds,
dry_run: row.dryRun ? 1 : 0,
last_run_at: row.lastRunAt ?? null,
last_run_status: row.lastRunStatus ?? null,
created_at: row.createdAt,
updated_at: row.updatedAt,
channels,
};
}
function mapEngineRow(row: AutomationRecord): AutomationEngineRow {
return {
id: row.id,
userId: row.userId,
name: row.name,
enabled: !!row.enabled,
definition: row.definition,
concurrencyPolicy: row.concurrencyPolicy,
maxRunSeconds: row.maxRunSeconds,
dryRun: !!row.dryRun,
};
}
function mapTriggerStateRow(row: TriggerStateRecord): TriggerStateRow {
return {
automationId: row.automationId,
stateKey: row.stateKey,
breachStartedAt: row.breachStartedAt ?? null,
lastFiredAt: row.lastFiredAt ?? null,
lastValue: row.lastValue ?? null,
lastObservedState: row.lastObservedState ?? null,
};
}
function mapRunRow(row: AutomationRunRecord): AutomationRunRow {
return {
id: row.id,
automation_id: row.automationId,
user_id: row.userId,
trigger_type: row.triggerType,
trigger_context: row.triggerContext ?? null,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
duration_ms: row.durationMs ?? null,
error: row.error ?? null,
dry_run: row.dryRun ? 1 : 0,
parent_run_id: row.parentRunId ?? null,
};
}
function mapRunStepRow(row: AutomationRunStepRecord): AutomationRunStepRow {
return {
id: row.id,
run_id: row.runId,
step_index: row.stepIndex,
step_id: row.stepId,
step_type: row.stepType,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
output: row.output ?? null,
error: row.error ?? null,
truncated: row.truncated ? 1 : 0,
};
}
@@ -6,7 +6,6 @@ import type { DatabaseContext } from "./database-context.js";
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
import { AiRepository } from "./ai-repository.js";
import { NotificationChannelRepository } from "./notification-channel-repository.js";
import { AutomationRepository } from "./automation-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
import { CredentialRepository } from "./credential-repository.js";
@@ -155,13 +154,6 @@ export function createCurrentNotificationChannelRepository(): NotificationChanne
);
}
export function createCurrentAutomationRepository(): AutomationRepository {
return new AutomationRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("automation_repository_write"),
);
}
export function createCurrentApiKeyRepository(): ApiKeyRepository {
return new ApiKeyRepository(
createCurrentRepositoryContext(),
+5 -8
View File
@@ -14,7 +14,7 @@ import {
pickResolvedPassword,
pickResolvedUsername,
} from "../../hosts/credential-username.js";
import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js";
import { emitInternalEvent } from "../../hosts/internal-events.js";
import {
createCurrentCredentialRepository,
createCurrentOpksshTokenRepository,
@@ -534,12 +534,9 @@ router.post(
success: true,
});
notifyAutomationInternalEvent(
"host_added",
userId,
createdHost.id as number,
{ name: String(name ?? ip) },
);
emitInternalEvent("host_added", userId, createdHost.id as number, {
name: String(name ?? ip),
});
res.json(stripSensitiveFields(resolvedHost));
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
@@ -2483,7 +2480,7 @@ router.delete(
success: true,
});
notifyAutomationInternalEvent("host_deleted", userId, numericHostId, {
emitInternalEvent("host_deleted", userId, numericHostId, {
name: hostToDelete.name ?? hostToDelete.ip,
});
-35
View File
@@ -1,35 +0,0 @@
/**
* One-line hand-off from any hosts feature to anything listening for a generic
* named event (not tied to metrics polling).
*
* This now publishes onto the ctx.events bus rather than reaching into the
* automations engine directly. The two properties callers relied on are
* unchanged: it is fire-and-forget, and it creates no static edge to the
* automations layer (which reads repositories that several hosts modules also
* pull in, so a static import would close a cycle). The automations engine
* subscribes to the bus at start-up instead -- see automations/triggers.ts.
*/
import { pluginEvents, TOPICS } from "../plugins/events.js";
export interface InternalEventPayload {
event: string;
userId: string;
hostId?: number;
details?: Record<string, unknown>;
}
export function notifyAutomationInternalEvent(
event: string,
userId: string,
hostId?: number,
details?: Record<string, unknown>,
): void {
if (!userId) return;
pluginEvents.emit(TOPICS.internalEvent, {
event,
userId,
hostId,
details,
} satisfies InternalEventPayload);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* One-line hand-off from any core feature to anything listening for a generic
* named event (host_deleted, user_login, ...). Fire-and-forget: it publishes
* onto the plugin event bus under "internal.event" and never waits on a
* listener. Plugins subscribe through ctx.events.
*/
import { pluginEvents, TOPICS } from "../plugins/events.js";
export interface InternalEventPayload {
event: string;
userId: string;
hostId?: number;
details?: Record<string, unknown>;
}
export function emitInternalEvent(
event: string,
userId: string,
hostId?: number,
details?: Record<string, unknown>,
): void {
if (!userId) return;
pluginEvents.emit(TOPICS.internalEvent, {
event,
userId,
hostId,
details,
} satisfies InternalEventPayload);
}
+150
View File
@@ -0,0 +1,150 @@
/**
* ctx.notify and ctx.fetch: core's notification channels and core's guarded
* outbound HTTP. Channels belong to a user, so notify always runs as the
* acting user and never sees a channel's config.
*/
import type {
PluginFetch,
PluginNotify,
PluginNotifyResult,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
audit: AuditFn;
}
function actingUser(): string {
const actor = getActor();
if (!actor) {
throw new Error(
"ctx.notify needs an acting user: call it inside a request or ctx.asUser",
);
}
return actor;
}
/** Runs fn behind the capability check, with an audit line either way. */
async function audited<T>(
deps: Deps,
capability: string,
action: string,
details: string,
fn: () => Promise<T>,
): Promise<T> {
try {
await assertCapability(
deps.manifest.id,
capability,
deps.manifest.capabilities,
);
const result = await fn();
await deps.audit(action, details, { success: true });
return result;
} catch (error) {
await deps.audit(action, details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
export function createPluginNotify(deps: Deps): PluginNotify {
const listOwn = async (userId: string) => {
const { createCurrentNotificationChannelRepository } =
await import("../database/repositories/factory.js");
return createCurrentNotificationChannelRepository().listNotificationChannels(
userId,
);
};
return {
channels: () =>
audited(
deps,
"notify:send",
"notify_channels",
"listed channels",
async () => {
const rows = await listOwn(actingUser());
return rows.map((row) => ({
id: row.id,
name: row.name,
type: row.type,
enabled: !!row.enabled,
}));
},
),
send: (channelIds, notification) =>
audited(
deps,
"notify:send",
"notify_send",
`${channelIds.length} channel(s)`,
async () => {
const rows = await listOwn(actingUser());
const { deliverNotification } =
await import("../utils/notification-sender.js");
const result: PluginNotifyResult = { delivered: 0, failures: [] };
for (const row of rows) {
if (!channelIds.includes(row.id) || !row.enabled) continue;
try {
await deliverNotification(row, {
title: notification.title,
body: notification.body,
severity: notification.severity ?? "warning",
context: notification.context,
});
result.delivered++;
} catch (error) {
result.failures.push({
channelId: row.id,
name: row.name,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
},
),
};
}
export function createPluginFetch(deps: Deps): PluginFetch {
return (url, init = {}) =>
audited(deps, "network:outbound", "fetch", "outbound request", async () => {
const { safeOutboundFetch } =
await import("../utils/safe-outbound-fetch.js");
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
Math.max(init.timeoutMs ?? 30_000, 1),
);
try {
return await safeOutboundFetch(
url,
{
method: init.method ?? "GET",
headers: init.headers,
body: init.body,
signal: controller.signal,
},
(init.allowPrivateHosts ?? []).map((host) => host.toLowerCase()),
);
} finally {
clearTimeout(timer);
}
});
}
+3
View File
@@ -54,6 +54,7 @@ import {
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -730,6 +731,8 @@ export function createPluginContext(
),
),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
notify: createPluginNotify({ manifest, audit: auditCall }),
fetch: createPluginFetch({ manifest, audit: auditCall }),
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
desktop: {
+6 -11
View File
@@ -1,19 +1,14 @@
/**
* The process-wide event bus behind ctx.events.
*
* This formalises the ad-hoc publish paths that already existed:
* - hosts/automation-events.ts (generic named internal events)
* - hosts/host-session-status.ts (the "host.session.status" topic)
*
* Each of those lazily imported the automations engine and swallowed errors,
* for two reasons that still apply and are preserved here:
* Core publishes here (hosts/internal-events.ts for generic named events,
* hosts/host-session-status.ts for "host.session.status", and the topics in
* TOPICS below) and plugins subscribe through ctx.events. Two rules:
*
* 1. Fire-and-forget. A failing subscriber must never disturb the caller. A
* metrics poll or a host delete does not fail because an automation threw.
* 2. No static import of the automations layer. It reads repositories, which
* several hosts modules also pull in, so a static edge would close a
* cycle. Subscribers register themselves here instead of being reached
* into.
* metrics poll or a host delete does not fail because a listener threw.
* 2. No static import of a subscriber. Subscribers register themselves here
* instead of being reached into, so core never names a plugin.
*
* Topics are dotted strings. The internal ones the server itself publishes are
* listed in TOPICS; a plugin may emit and subscribe to any topic, but only
+1 -1
View File
@@ -6,7 +6,7 @@
*
* The heavy dependencies are imported lazily inside each function rather than
* at module scope: several pull in the repository layer, and a static import
* here would close the cycle hosts/automation-events.ts documents.
* here would close an import cycle through the repositories.
*/
import { pluginLogger } from "../utils/logger.js";
+4 -1
View File
@@ -38,7 +38,10 @@ export type { PluginSettingsScope };
* to it means deciding that every plugin holding settings:read-core may see
* that value, so it is a review decision rather than a convenience.
*/
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = ["app_name"];
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
"app_name",
"notification_private_endpoint_allowlist",
];
type ChangeListener = (value: unknown) => void;
@@ -59,8 +59,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
vi.mock("../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
@@ -42,8 +42,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
vi.mock("../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
@@ -22,8 +22,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(),
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
vi.mock("../../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
vi.mock("../../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("@simplewebauthn/server", () => ({
generateAuthenticationOptions: vi
@@ -90,15 +90,15 @@ describe("declared dependencies", () => {
}
});
// plugins/ai/src/backend/tools/executor.ts imports automations' routes
// directly, so the loader has to start automations first. B18 replaces the
// import with a service call and this becomes optional.
it("records ai's dependency on automations", () => {
// ai reaches automations through the automations.access service, so it
// keeps working when automations is off.
it("keeps ai's use of automations optional", () => {
if (!ids.includes("ai")) return;
const { manifest } = parseManifest(readManifest("ai"));
expect(manifest?.dependencies?.automations).toBeTruthy();
expect(manifest?.dependencies?.automations).toBeUndefined();
expect(manifest?.optionalDependencies?.automations).toBeTruthy();
});
});
@@ -0,0 +1,184 @@
/**
* ctx.notify and ctx.fetch: each is gated on its capability and audited,
* notify only ever reaches the acting user's own channels, and fetch goes
* through the SSRF guard with only the hosts the caller listed.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
channels: new Map<
string,
Array<{
id: number;
name: string;
type: string;
config: string;
enabled: number;
}>
>(),
delivered: [] as Array<{ channelId: number; title: string }>,
failChannel: null as number | null,
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentNotificationChannelRepository: () => ({
listNotificationChannels: async (userId: string) =>
state.channels.get(userId) ?? [],
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/notification-sender.js", () => ({
deliverNotification: async (
channel: { id: number },
notification: { title: string },
) => {
if (channel.id === state.failChannel) throw new Error("HTTP 500");
state.delivered.push({ channelId: channel.id, title: notification.title });
},
}));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: async (
url: string,
_init: RequestInit,
allowlist: readonly string[],
) => {
state.fetchCalls.push({ url, allowlist });
return new Response("ok", { status: 200 });
},
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
grants.set("demo", capabilities);
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
return createPluginContext(manifest, handle);
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.channels.clear();
state.delivered.length = 0;
state.failChannel = null;
state.fetchCalls.length = 0;
state.channels.set("alice", [
{ id: 1, name: "ops", type: "webhook", config: "{}", enabled: 1 },
{ id: 2, name: "muted", type: "ntfy", config: "{}", enabled: 0 },
{ id: 3, name: "pager", type: "discord", config: "{}", enabled: 1 },
]);
state.channels.set("bob", [
{ id: 9, name: "bob's", type: "webhook", config: "{}", enabled: 1 },
]);
});
describe("ctx.notify", () => {
it("refuses without notify:send and audits the refusal", async () => {
const ctx = contextFor([]);
await expect(
runAsActor("alice", "request", () => ctx.notify.channels()),
).rejects.toThrow(/notify:send/);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_notify_channels",
success: false,
});
});
it("refuses a call with no acting user", async () => {
const ctx = contextFor(["notify:send"]);
await expect(
ctx.notify.send([1], { title: "t", body: "b" }),
).rejects.toThrow(/acting user/);
});
it("lists the actor's channels without their config", async () => {
const ctx = contextFor(["notify:send"]);
const channels = await runAsActor("alice", "request", () =>
ctx.notify.channels(),
);
expect(channels).toEqual([
{ id: 1, name: "ops", type: "webhook", enabled: true },
{ id: 2, name: "muted", type: "ntfy", enabled: false },
{ id: 3, name: "pager", type: "discord", enabled: true },
]);
});
it("sends only to the actor's enabled channels and reports failures", async () => {
const ctx = contextFor(["notify:send"]);
state.failChannel = 3;
const result = await runAsActor("alice", "request", () =>
ctx.notify.send([1, 2, 3, 9], { title: "Disk", body: "full" }),
);
expect(state.delivered).toEqual([{ channelId: 1, title: "Disk" }]);
expect(result).toEqual({
delivered: 1,
failures: [{ channelId: 3, name: "pager", error: "HTTP 500" }],
});
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_notify_send",
success: true,
});
});
});
describe("ctx.fetch", () => {
it("refuses without network:outbound", async () => {
const ctx = contextFor([]);
await expect(ctx.fetch("https://example.com")).rejects.toThrow(
/network:outbound/,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_fetch",
success: false,
});
});
it("goes through the SSRF guard with the listed private hosts only", async () => {
const ctx = contextFor(["network:outbound"]);
const response = await ctx.fetch("https://ntfy.lan/topic", {
method: "POST",
body: "hi",
allowPrivateHosts: ["NTFY.LAN"],
});
expect(await response.text()).toBe("ok");
expect(state.fetchCalls).toEqual([
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
]);
});
});
+8 -8
View File
@@ -108,13 +108,13 @@ describe("hosts publishers route through the bus", () => {
pluginEvents.clear();
});
it("notifyAutomationInternalEvent emits internal.event", async () => {
const { notifyAutomationInternalEvent } =
await import("../../hosts/automation-events.js");
it("emitInternalEvent emits internal.event", async () => {
const { emitInternalEvent } =
await import("../../hosts/internal-events.js");
const listener = vi.fn();
pluginEvents.on(TOPICS.internalEvent, listener);
notifyAutomationInternalEvent("host_deleted", "user-1", 42, { a: 1 });
emitInternalEvent("host_deleted", "user-1", 42, { a: 1 });
expect(listener).toHaveBeenCalledWith({
event: "host_deleted",
@@ -124,13 +124,13 @@ describe("hosts publishers route through the bus", () => {
});
});
it("notifyAutomationInternalEvent ignores a missing userId", async () => {
const { notifyAutomationInternalEvent } =
await import("../../hosts/automation-events.js");
it("emitInternalEvent ignores a missing userId", async () => {
const { emitInternalEvent } =
await import("../../hosts/internal-events.js");
const listener = vi.fn();
pluginEvents.on(TOPICS.internalEvent, listener);
notifyAutomationInternalEvent("host_deleted", "");
emitInternalEvent("host_deleted", "");
expect(listener).not.toHaveBeenCalled();
});
+144
View File
@@ -140,3 +140,147 @@ export async function sendNotification(
});
}
}
export interface ChannelNotification {
title: string;
body: string;
severity: "info" | "warning" | "critical";
context?: {
hostId?: number;
hostName?: string;
sourceId?: number | string;
sourceName?: string;
triggerType?: string;
value?: unknown;
threshold?: unknown;
};
}
const NTFY_TAGS: Record<string, string> = {
info: "information_source",
warning: "warning",
critical: "rotating_light",
};
const DISCORD_COLORS: Record<string, number> = {
info: 3066993,
warning: 16753920,
critical: 15158332,
};
/**
* Sends one notification to one channel and throws on failure, so the caller
* can report which channel failed. Private and loopback targets need both the
* channel's allowPrivateNetwork opt-in and an exact host in the admin
* allowlist.
*/
export async function deliverNotification(
channel: { id: number; type: string; config: string },
notification: ChannelNotification,
): Promise<void> {
let config: Record<string, unknown>;
try {
config = JSON.parse(channel.config) as Record<string, unknown>;
} catch {
throw new Error("Channel configuration is not valid JSON");
}
const allowlist =
config.allowPrivateNetwork === true
? await readNotificationPrivateAllowlist()
: [];
const post = async (
url: string,
init: { method: string; headers: Record<string, string>; body: string },
) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
const response = await safeOutboundFetch(
url,
{ ...init, signal: controller.signal },
allowlist,
);
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
} finally {
clearTimeout(timer);
}
};
const url = typeof config.url === "string" ? config.url.trim() : "";
if (!url) throw new Error("Channel is missing a URL");
const context = notification.context ?? {};
switch (channel.type) {
case "webhook": {
const headers =
config.headers && typeof config.headers === "object"
? (config.headers as Record<string, string>)
: {};
return post(url, {
method: config.method === "PUT" ? "PUT" : "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({
title: notification.title,
hostName: context.hostName,
hostId: context.hostId,
ruleName: context.sourceName ?? notification.title,
ruleId: context.sourceId,
triggerType: context.triggerType,
value: context.value,
threshold: context.threshold,
message: notification.body,
severity: notification.severity,
timestamp: new Date().toISOString(),
}),
});
}
case "ntfy": {
const topic = typeof config.topic === "string" ? config.topic.trim() : "";
if (!topic) throw new Error("ntfy channel is missing a topic");
const headers: Record<string, string> = {
Title: notification.title || "Termix",
Priority: String(NTFY_PRIORITY[notification.severity] ?? 3),
Tags: NTFY_TAGS[notification.severity] ?? "information_source",
};
if (typeof config.token === "string" && config.token) {
headers.Authorization = `Bearer ${config.token}`;
}
return post(`${url.replace(/\/$/, "")}/${topic}`, {
method: "POST",
headers,
body: notification.body || notification.title,
});
}
case "discord": {
const payload: Record<string, unknown> = {
embeds: [
{
title: notification.title || "Termix",
description: notification.body || undefined,
color: DISCORD_COLORS[notification.severity] ?? 3447003,
timestamp: new Date().toISOString(),
},
],
};
if (typeof config.username === "string" && config.username) {
payload.username = config.username;
}
if (typeof config.avatar_url === "string" && config.avatar_url) {
payload.avatar_url = config.avatar_url;
}
return post(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
default:
throw new Error(`Unsupported channel type: ${channel.type}`);
}
}
-286
View File
@@ -1,286 +0,0 @@
/**
* Shared automation model used by both the backend engine and the editor UI.
* An automation is one trigger plus an ordered list of steps. The whole
* definition is stored as JSON on the automations row, so this file is the
* only contract describing that blob.
*/
export const AUTOMATION_DEFINITION_VERSION = 1;
/** Which hosts a trigger watches or a step acts on. */
export type HostSelector =
| { kind: "host"; hostId: number }
| { kind: "hosts"; hostIds: number[] }
| { kind: "fleet"; fleetId: number }
| { kind: "all" }
/** The host that produced the event. Only valid for host-scoped triggers. */
| { kind: "trigger" };
/**
* A metric to compare against. Paths mirror the shape returned by
* collectMetrics(). mount/iface pick one entry out of a per-instance list so a
* rule can watch a single filesystem rather than the aggregate.
*/
export type MetricPath =
| { path: "cpu.percent" }
| { path: "cpu.load1" }
| { path: "cpu.load5" }
| { path: "cpu.load15" }
| { path: "memory.percent" }
| { path: "memory.usedGiB" }
| { path: "disk.percent"; mount?: string }
| { path: "disk.availableBytes"; mount?: string }
| { path: "temperature.highestCelsius" }
| { path: "uptime.seconds" }
| { path: "processes.total" }
| { path: "network.rxBytes"; iface?: string }
| { path: "network.txBytes"; iface?: string }
| { path: "network.rxRateBps"; iface?: string }
| { path: "network.txRateBps"; iface?: string };
export type Operator =
| ">"
| "<"
| ">="
| "<="
| "=="
| "!="
| "contains"
| "not_contains"
| "changed";
export type Severity = "info" | "warning" | "critical";
export type HostStatusState = "online" | "offline";
export type HealthCheckState = "failing" | "recovered";
export type DockerEventKind = "exited" | "started" | "unhealthy" | "restarting";
export type InternalEventKind =
| "user_login"
| "host_added"
| "host_deleted"
| "tunnel_disconnected"
| "automation_failed";
export type Trigger =
| {
kind: "metric_threshold";
hostSelector: HostSelector;
metric: MetricPath;
operator: Operator;
value: number;
/** Sustained breach window before firing. 0 fires immediately. */
forSeconds?: number;
cooldownMinutes: number;
severity?: Severity;
}
| {
kind: "host_status";
hostSelector: HostSelector;
to: HostStatusState;
cooldownMinutes: number;
}
| {
kind: "health_check";
hostSelector: HostSelector;
checkId?: string;
to: HealthCheckState;
cooldownMinutes: number;
}
| {
kind: "schedule";
/** Five field cron. Ignored when intervalSeconds is set. */
cron?: string;
intervalSeconds?: number;
timezone?: string;
}
| {
kind: "docker_event";
hostSelector: HostSelector;
container?: string;
event: DockerEventKind;
cooldownMinutes: number;
}
| {
kind: "internal_event";
event: InternalEventKind;
hostSelector?: HostSelector;
cooldownMinutes: number;
}
| {
kind: "webhook";
/** Only the hash is persisted. The raw token is shown once on creation. */
tokenHash: string;
};
export type TriggerKind = Trigger["kind"];
/** A comparison used by `if` steps, evaluated against the run context. */
export interface Condition {
/** Template string, e.g. "{{steps.check.stdout}}" or "{{trigger.value}}". */
left: string;
operator: Operator;
/** Template string. Compared numerically when both sides parse as numbers. */
right?: string;
}
export type StepErrorPolicy = "stop" | "continue" | "branch";
interface StepBase {
/** Stable across edits so run history survives reordering. */
id: string;
name?: string;
enabled?: boolean;
onError?: StepErrorPolicy;
timeoutMs?: number;
}
export type Step =
| (StepBase & {
type: "notify";
channelIds: number[];
title?: string;
body?: string;
severity?: Severity;
})
| (StepBase & {
type: "http";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
url: string;
headers?: Record<string, string>;
body?: string;
/** Opt in to LAN/private addresses. Off by default, see safe-outbound-fetch. */
allowPrivateNetwork?: boolean;
})
| (StepBase & {
type: "run_snippet";
snippetId: number;
hostSelector: HostSelector;
inputValues?: Record<string, string>;
elevated?: boolean;
})
| (StepBase & {
type: "run_command";
command: string;
hostSelector: HostSelector;
elevated?: boolean;
})
| (StepBase & {
type: "docker";
action: "start" | "stop" | "restart";
container: string;
hostSelector: HostSelector;
})
| (StepBase & {
type: "tunnel";
action: "connect" | "disconnect";
tunnelName: string;
})
| (StepBase & { type: "wol"; hostId: number })
| (StepBase & { type: "wait"; seconds: number })
| (StepBase & { type: "set_var"; name: string; value: string })
| (StepBase & {
type: "if";
condition: Condition;
then: Step[];
else?: Step[];
})
| (StepBase & { type: "run_automation"; automationId: number })
| (StepBase & { type: "stop"; status?: "success" | "failed" });
export type StepType = Step["type"];
export interface AutomationDefinition {
version: number;
trigger: Trigger;
steps: Step[];
}
export type ConcurrencyPolicy = "skip" | "queue" | "allow";
export type RunStatus =
"running" | "success" | "failed" | "timeout" | "skipped" | "cancelled";
export type StepStatus =
"pending" | "running" | "success" | "failed" | "skipped";
export interface Automation {
id: number;
userId: string;
name: string;
description: string | null;
enabled: boolean;
definition: AutomationDefinition;
concurrencyPolicy: ConcurrencyPolicy;
maxRunSeconds: number;
dryRun: boolean;
lastRunAt: string | null;
lastRunStatus: RunStatus | null;
createdAt: string;
updatedAt: string;
}
export interface AutomationRun {
id: number;
automationId: number;
userId: string;
triggerType: TriggerKind | "manual";
triggerContext: Record<string, unknown> | null;
status: RunStatus;
startedAt: string;
finishedAt: string | null;
durationMs: number | null;
error: string | null;
dryRun: boolean;
parentRunId: number | null;
}
export interface AutomationRunStep {
id: number;
runId: number;
stepIndex: number;
stepId: string;
stepType: StepType;
status: StepStatus;
startedAt: string;
finishedAt: string | null;
output: string | null;
error: string | null;
truncated: boolean;
}
/** Defaults applied when an automation does not override them. */
export const DEFAULT_MAX_RUN_SECONDS = 300;
export const DEFAULT_STEP_TIMEOUT_MS = 60_000;
export const DEFAULT_COOLDOWN_MINUTES = 15;
/** Guards run_automation against direct and mutual recursion. */
export const MAX_AUTOMATION_DEPTH = 5;
/** Step output beyond this is truncated before it reaches the database. */
export const MAX_STEP_OUTPUT_BYTES = 32_768;
export const OPERATOR_LABELS: Record<Operator, string> = {
">": "greater than",
"<": "less than",
">=": "greater than or equal to",
"<=": "less than or equal to",
"==": "equals",
"!=": "does not equal",
contains: "contains",
not_contains: "does not contain",
changed: "changed",
};
/** Steps that reach outside Termix and are therefore stubbed in a dry run. */
export const SIDE_EFFECTING_STEP_TYPES: readonly StepType[] = [
"notify",
"http",
"run_snippet",
"run_command",
"docker",
"tunnel",
"wol",
];
export function isSideEffectingStep(type: StepType): boolean {
return SIDE_EFFECTING_STEP_TYPES.includes(type);
}
+10
View File
@@ -146,3 +146,13 @@ export {
parseCustomKeybindings,
} from "@/api/open-tabs-api";
export { setHostAutoTmux } from "@/api/host-terminal-config-api";
// Notification channels are core (ctx.notify on the backend). A plugin that
// lets users pick or manage them shows core's own dialog.
export { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
export {
getNotificationChannels,
deleteNotificationChannel,
testNotificationChannel,
type NotificationChannel,
} from "@/api/notification-channels-api";