mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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