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

-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,
});