feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

This commit is contained in:
LukeGus committed 2026-09-23 11:24:16 -05:00
1 parent d2ff5fc78f
commit 3b2c849514
59 files changed
+31807 -2977

No files matched your search

-81
View File
@@ -2664,87 +2664,6 @@ const migrateSchema = () => {
}
// --- homepage end ---
// --- fleets begin ---
try {
sqlite.prepare("SELECT id FROM fleets LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS fleets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
color TEXT,
icon TEXT,
tag_rules TEXT,
sync_id TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create fleets table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM fleet_members LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS fleet_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fleet_id INTEGER NOT NULL REFERENCES fleets(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
sqlite.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fleet_members_fleet_host ON fleet_members(fleet_id, host_id)",
);
} catch (createError) {
databaseLogger.warn("Failed to create fleet_members table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM fleet_inventory LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS fleet_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
os_pretty_name TEXT,
kernel TEXT,
architecture TEXT,
hostname TEXT,
uptime_seconds INTEGER,
ip TEXT,
package_manager TEXT,
collected_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
sqlite.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fleet_inventory_host ON fleet_inventory(host_id, user_id)",
);
} catch (createError) {
databaseLogger.warn("Failed to create fleet_inventory table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- fleets end ---
// --- sync begin ---
// Stable per-row identity used to match rows across two independently-
// seeded databases (the embedded desktop backend and a connected remote
@@ -168,23 +168,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "host_id",
},
// Fleets.
{
name: "idx_fleet_members_fleet",
table: "fleet_members",
columns: "fleet_id",
},
{
name: "idx_fleet_members_host",
table: "fleet_members",
columns: "host_id",
},
{
name: "idx_fleet_inventory_user",
table: "fleet_inventory",
columns: "user_id",
},
// Automations.
{
name: "idx_automations_user",
-75
View File
@@ -1616,81 +1616,6 @@ export const homepageLayouts = mysqlTable("homepage_layouts", {
});
// --- homepage end ---
// --- fleets begin ---
export const fleets = mysqlTable("fleets", {
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"),
color: text("color"),
icon: text("icon"),
// JSON array of { tag: string } rules, unioned with static fleetMembers at
// resolution time. Kept to tag-equality matching for v1.
tagRules: text("tag_rules"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const fleetMembers = mysqlTable(
"fleet_members",
{
id: int("id").autoincrement().primaryKey(),
fleetId: int("fleet_id")
.notNull()
.references(() => fleets.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
addedAt: text("added_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// fleet_id leads the unique pair, so listing a fleet's hosts is already
// served. Finding the fleets a host belongs to starts from host_id.
(table) => [
uniqueIndex("idx_fleet_members_fleet_host").on(table.fleetId, table.hostId),
index("idx_fleet_members_host").on(table.hostId),
],
);
// Latest-only inventory snapshot per host, overwritten on each refresh - no
// historical log, matching the "latest snapshot only" scope decision.
export const fleetInventory = mysqlTable(
"fleet_inventory",
{
id: int("id").autoincrement().primaryKey(),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
osPrettyName: text("os_pretty_name"),
kernel: text("kernel"),
architecture: text("architecture"),
hostname: text("hostname"),
uptimeSeconds: int("uptime_seconds"),
ip: text("ip"),
packageManager: text("package_manager"),
collectedAt: text("collected_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// host_id leads the unique pair; a user's whole inventory is read by user_id.
(table) => [
uniqueIndex("idx_fleet_inventory_host").on(table.hostId, table.userId),
index("idx_fleet_inventory_user").on(table.userId),
],
);
// --- fleets end ---
// --- sync begin ---
-75
View File
@@ -1617,81 +1617,6 @@ export const homepageLayouts = pgTable("homepage_layouts", {
});
// --- homepage end ---
// --- fleets begin ---
export const fleets = pgTable("fleets", {
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"),
color: text("color"),
icon: text("icon"),
// JSON array of { tag: string } rules, unioned with static fleetMembers at
// resolution time. Kept to tag-equality matching for v1.
tagRules: text("tag_rules"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const fleetMembers = pgTable(
"fleet_members",
{
id: serial("id").primaryKey(),
fleetId: integer("fleet_id")
.notNull()
.references(() => fleets.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
addedAt: text("added_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// fleet_id leads the unique pair, so listing a fleet's hosts is already
// served. Finding the fleets a host belongs to starts from host_id.
(table) => [
uniqueIndex("idx_fleet_members_fleet_host").on(table.fleetId, table.hostId),
index("idx_fleet_members_host").on(table.hostId),
],
);
// Latest-only inventory snapshot per host, overwritten on each refresh - no
// historical log, matching the "latest snapshot only" scope decision.
export const fleetInventory = pgTable(
"fleet_inventory",
{
id: serial("id").primaryKey(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
osPrettyName: text("os_pretty_name"),
kernel: text("kernel"),
architecture: text("architecture"),
hostname: text("hostname"),
uptimeSeconds: integer("uptime_seconds"),
ip: text("ip"),
packageManager: text("package_manager"),
collectedAt: text("collected_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// host_id leads the unique pair; a user's whole inventory is read by user_id.
(table) => [
uniqueIndex("idx_fleet_inventory_host").on(table.hostId, table.userId),
index("idx_fleet_inventory_user").on(table.userId),
],
);
// --- fleets end ---
// --- sync begin ---
-75
View File
@@ -1615,81 +1615,6 @@ export const homepageLayouts = sqliteTable("homepage_layouts", {
});
// --- homepage end ---
// --- fleets begin ---
export const fleets = sqliteTable("fleets", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
color: text("color"),
icon: text("icon"),
// JSON array of { tag: string } rules, unioned with static fleetMembers at
// resolution time. Kept to tag-equality matching for v1.
tagRules: text("tag_rules"),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const fleetMembers = sqliteTable(
"fleet_members",
{
id: integer("id").primaryKey({ autoIncrement: true }),
fleetId: integer("fleet_id")
.notNull()
.references(() => fleets.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
addedAt: text("added_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// fleet_id leads the unique pair, so listing a fleet's hosts is already
// served. Finding the fleets a host belongs to starts from host_id.
(table) => [
uniqueIndex("idx_fleet_members_fleet_host").on(table.fleetId, table.hostId),
index("idx_fleet_members_host").on(table.hostId),
],
);
// Latest-only inventory snapshot per host, overwritten on each refresh - no
// historical log, matching the "latest snapshot only" scope decision.
export const fleetInventory = sqliteTable(
"fleet_inventory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
osPrettyName: text("os_pretty_name"),
kernel: text("kernel"),
architecture: text("architecture"),
hostname: text("hostname"),
uptimeSeconds: integer("uptime_seconds"),
ip: text("ip"),
packageManager: text("package_manager"),
collectedAt: text("collected_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// host_id leads the unique pair; a user's whole inventory is read by user_id.
(table) => [
uniqueIndex("idx_fleet_inventory_host").on(table.hostId, table.userId),
index("idx_fleet_inventory_user").on(table.userId),
],
);
// --- fleets end ---
// --- sync begin ---
@@ -15,8 +15,6 @@ import { CredentialRepository } from "./credential-repository.js";
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
import { FleetRepository } from "./fleet-repository.js";
import { FleetInventoryRepository } from "./fleet-inventory-repository.js";
import { HomepageItemRepository } from "./homepage-item-repository.js";
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js";
@@ -239,20 +237,6 @@ export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmar
);
}
export function createCurrentFleetRepository(): FleetRepository {
return new FleetRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("fleet_repository_write"),
);
}
export function createCurrentFleetInventoryRepository(): FleetInventoryRepository {
return new FleetInventoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("fleet_inventory_repository_write"),
);
}
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
return new HomepageItemRepository(
createCurrentRepositoryContext(),
@@ -1,90 +0,0 @@
import { and, eq, inArray } from "drizzle-orm";
import { fleetInventory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning, updateReturning } from "./returning.js";
export type FleetInventoryRecord = typeof fleetInventory.$inferSelect;
export interface FleetInventoryInput {
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
}
export class FleetInventoryRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async upsert(
userId: string,
hostId: number,
input: FleetInventoryInput,
now = new Date().toISOString(),
): Promise<FleetInventoryRecord> {
const existing = await this.findOne(userId, hostId);
if (existing) {
const [updated] = await updateReturning(
this.context,
fleetInventory,
{ ...input, collectedAt: now },
eq(fleetInventory.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await insertReturning(this.context, fleetInventory, {
userId,
hostId,
...input,
collectedAt: now,
});
await this.afterWrite();
return created;
}
async findOne(
userId: string,
hostId: number,
): Promise<FleetInventoryRecord | null> {
const rows = await this.context.drizzle
.select()
.from(fleetInventory)
.where(
and(
eq(fleetInventory.userId, userId),
eq(fleetInventory.hostId, hostId),
),
)
.limit(1);
return rows[0] ?? null;
}
async listForHosts(
userId: string,
hostIds: number[],
): Promise<FleetInventoryRecord[]> {
if (hostIds.length === 0) return [];
return this.context.drizzle
.select()
.from(fleetInventory)
.where(
and(
eq(fleetInventory.userId, userId),
inArray(fleetInventory.hostId, hostIds),
),
);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,235 +0,0 @@
import { and, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { fleets, fleetMembers, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type FleetRecord = typeof fleets.$inferSelect;
export type FleetMemberHostRecord = typeof hosts.$inferSelect;
export interface FleetCreateInput {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
export interface FleetUpdateInput {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
function parseTagRules(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((t): t is string => typeof t === "string")
: [];
} catch {
return [];
}
}
export class FleetRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUser(userId: string): Promise<FleetRecord[]> {
return this.context.drizzle
.select()
.from(fleets)
.where(eq(fleets.userId, userId));
}
async findById(userId: string, fleetId: number): Promise<FleetRecord | null> {
const rows = await this.context.drizzle
.select()
.from(fleets)
.where(and(eq(fleets.id, fleetId), eq(fleets.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async create(
userId: string,
input: FleetCreateInput,
now = new Date().toISOString(),
): Promise<FleetRecord> {
const [created] = await insertReturning(this.context, fleets, {
userId,
name: input.name,
description: input.description ?? null,
color: input.color ?? null,
icon: input.icon ?? null,
tagRules: input.tagRules ? JSON.stringify(input.tagRules) : null,
syncId: randomUUID(),
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
}
async update(
userId: string,
fleetId: number,
input: FleetUpdateInput,
now = new Date().toISOString(),
): Promise<FleetRecord | null> {
const existing = await this.findById(userId, fleetId);
if (!existing) return null;
const [updated] = await updateReturning(
this.context,
fleets,
{
name: input.name ?? existing.name,
description:
input.description === undefined
? existing.description
: input.description,
color: input.color === undefined ? existing.color : input.color,
icon: input.icon === undefined ? existing.icon : input.icon,
tagRules:
input.tagRules === undefined
? existing.tagRules
: JSON.stringify(input.tagRules),
updatedAt: now,
},
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
);
await this.afterWrite();
return updated ?? null;
}
async delete(userId: string, fleetId: number): Promise<boolean> {
const deleted = await deleteReturning(
this.context,
fleets,
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
);
if (deleted.length > 0) {
await this.afterWrite();
return true;
}
return false;
}
async addMember(
fleetId: number,
hostId: number,
now = new Date().toISOString(),
): Promise<void> {
const existing = await this.context.drizzle
.select({ id: fleetMembers.id })
.from(fleetMembers)
.where(
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
)
.limit(1);
if (existing.length > 0) return;
await this.context.drizzle.insert(fleetMembers).values({
fleetId,
hostId,
addedAt: now,
});
await this.afterWrite();
}
async removeMember(fleetId: number, hostId: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(fleetMembers)
.where(
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
);
const affected = rowsAffected(result) > 0;
if (affected) await this.afterWrite();
return affected;
}
async listStaticMemberIds(fleetId: number): Promise<number[]> {
const rows = await this.context.drizzle
.select({ hostId: fleetMembers.hostId })
.from(fleetMembers)
.where(eq(fleetMembers.fleetId, fleetId));
return rows.map((r) => r.hostId);
}
/**
* Effective membership = static fleetMembers rows union hosts whose
* comma-separated tags string intersects any tag in the fleet's tagRules.
* Both sets are scoped to the fleet owner's hosts and deduplicated by id.
*/
async listEffectiveMembers(
userId: string,
fleetId: number,
): Promise<FleetMemberHostRecord[]> {
const fleet = await this.findById(userId, fleetId);
if (!fleet) return [];
const staticIds = await this.listStaticMemberIds(fleetId);
const tagRules = parseTagRules(fleet.tagRules);
const ownedHosts = await this.context.drizzle
.select()
.from(hosts)
.where(eq(hosts.userId, userId));
const byId = new Map<number, FleetMemberHostRecord>();
for (const host of ownedHosts) {
if (staticIds.includes(host.id)) {
byId.set(host.id, host);
continue;
}
if (tagRules.length === 0) continue;
const hostTags = (host.tags ?? "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (hostTags.some((tag) => tagRules.includes(tag))) {
byId.set(host.id, host);
}
}
// Static members can reference hosts the fleet owner no longer owns
// (rare, but the FK cascade only fires on host delete, not transfer) -
// filter those out rather than surface a partial/foreign row.
return Array.from(byId.values());
}
async deleteByUserId(userId: string): Promise<number> {
const userFleets = await this.listByUser(userId);
const result = await this.context.drizzle
.delete(fleets)
.where(eq(fleets.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return userFleets.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}