diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 1e20e901..4e1df237 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -19,7 +19,7 @@ import { type StellarReleasePaymentFn, } from "../payment"; import { agentsRouter } from "./routes/agents"; -import { healthRouter } from "./routes/health"; +import { healthRouter, getMigrationsHandler } from "./routes/health"; import { createStatsRouter } from "./routes/stats"; import { createTasksRouter } from "./routes/tasks"; import { rateLimitMiddleware, registerRateLimitMiddleware } from "./middleware/rateLimit"; @@ -96,8 +96,10 @@ export function createApp(opts: AppOptions = {}): { heartbeatService.start(); } - // ── Health routes ─────────────────────────────────────────────────────────── + // ── Health & Migration routes ─────────────────────────────────────────────── app.use("/health", healthRouter); + app.get("/migrations", getMigrationsHandler); + // ── Stats routes ─────────────────────────────────────────────────────────── app.use("/api/stats", createStatsRouter(getTaskDb())); diff --git a/backend/src/api/routes/health.ts b/backend/src/api/routes/health.ts index 6d2b8090..b784b01e 100644 --- a/backend/src/api/routes/health.ts +++ b/backend/src/api/routes/health.ts @@ -1,5 +1,8 @@ import { Router, Request, Response } from "express"; import { getConfig } from "../../config"; +import { getTaskDb } from "../../db/tasks"; +import { getMigrationStatus } from "../../db/migrations"; + const router = Router(); @@ -122,6 +125,42 @@ router.get("/ready", async (_req: Request, res: Response) => { res.status(allOk ? 200 : 500).json({ status: allOk ? "ok" : "error", checks }); }); +/** + * @openapi + * /migrations: + * get: + * summary: Database migration status + * operationId: getMigrationsStatus + * description: Returns the list of available database migrations and their application status. + * tags: [Migrations] + * security: [] + * responses: + * 200: + * description: List of migration statuses + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * migrations: + * type: array + */ +export function getMigrationsHandler(_req: Request, res: Response): void { + try { + const db = getTaskDb(); + const migrations = getMigrationStatus(db); + res.json({ status: "ok", migrations }); + } catch (error) { + res.status(500).json({ status: "error", error: String(error) }); + } +} + + +router.get("/migrations", getMigrationsHandler); + + async function checkVenice(apiKey: string): Promise<"ok" | "unreachable"> { try { const ctrl = new AbortController(); diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 6fc37a7a..ca1fa74d 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -15,6 +15,13 @@ const envSchema = z.object({ NPM_PACKAGE_VERSION: z.string().default(pkg.version ?? "0.1.0"), GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().positive().default(30), + // ── Database Migrations ─────────────────────────────────────────────────── + /** Whether to automatically run database migrations on server startup. Default: true. */ + AUTO_MIGRATE: z + .string() + .optional() + .transform((val) => val === undefined || val === "" || val === "true" || val === "1"), + // ── Input validation ──────────────────────────────────────────────────────── /** Maximum allowed length (characters) for a task prompt. Default: 10 000. */ MAX_PROMPT_LENGTH: z.coerce.number().int().positive().default(10_000), diff --git a/backend/src/db/migrations/001_add_stats_indexes.sql b/backend/src/db/migrations/001_add_stats_indexes.sql index 12d435fb..36b127e9 100644 --- a/backend/src/db/migrations/001_add_stats_indexes.sql +++ b/backend/src/db/migrations/001_add_stats_indexes.sql @@ -1,3 +1,29 @@ -- Add indexes to speed up dashboard and analytics queries +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + prompt TEXT NOT NULL, + walletPublicKey TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'queued', + dagJson TEXT NOT NULL DEFAULT '[]', + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS payments ( + taskId TEXT NOT NULL, + nodeId TEXT NOT NULL, + balanceId TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'locked', + amountStroops TEXT NOT NULL, + txHash TEXT, + PRIMARY KEY (taskId, nodeId) +); + CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks ("createdAt"); CREATE INDEX IF NOT EXISTS idx_payments_status ON payments (status); + + +-- DOWN +DROP INDEX IF EXISTS idx_tasks_created_at; +DROP INDEX IF EXISTS idx_payments_status; + diff --git a/backend/src/db/migrations/002_create_tasks_table.sql b/backend/src/db/migrations/002_create_tasks_table.sql index 699f0ae3..978c9196 100644 --- a/backend/src/db/migrations/002_create_tasks_table.sql +++ b/backend/src/db/migrations/002_create_tasks_table.sql @@ -19,3 +19,9 @@ CREATE TABLE IF NOT EXISTS task_events ( ); CREATE INDEX IF NOT EXISTS idx_task_events_taskId ON task_events (taskId); + +-- DOWN +DROP INDEX IF EXISTS idx_task_events_taskId; +DROP TABLE IF EXISTS task_events; +DROP TABLE IF EXISTS tasks; + diff --git a/backend/src/db/migrations/003_create_schema_migrations.sql b/backend/src/db/migrations/003_create_schema_migrations.sql new file mode 100644 index 00000000..082f8c2f --- /dev/null +++ b/backend/src/db/migrations/003_create_schema_migrations.sql @@ -0,0 +1,10 @@ +-- Migration 003: create schema_migrations table +CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- DOWN +DROP TABLE IF EXISTS schema_migrations; diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts new file mode 100644 index 00000000..51c1f143 --- /dev/null +++ b/backend/src/db/migrations/index.ts @@ -0,0 +1,73 @@ +import type Database from "better-sqlite3"; +import { + runUp, + runDown, + getStatus, + loadMigrations, + parseMigrationSql, + type Migration, + type MigrationStatus, + type MigrationResult, + type MigrationOptions, + type RollbackOptions, +} from "./runner"; +import { + getAppliedMigrations, + recordMigration, + removeMigration, + isApplied, + ensureMigrationsTable, + type AppliedMigration, +} from "./tracker"; + +/** + * Runs all pending migrations (UP) on the database. + */ +export function runMigrations( + db: Database.Database, + options: MigrationOptions = {}, +): MigrationResult { + return runUp(db, options); +} + +/** + * Rolls back applied migrations (DOWN) on the database. + */ +export function rollbackMigrations( + db: Database.Database, + options: RollbackOptions = {}, +): MigrationResult { + return runDown(db, options); +} + +/** + * Gets the current status of all migrations on the database. + */ +export function getMigrationStatus( + db: Database.Database, + options: { migrationsDir?: string } = {}, +): MigrationStatus[] { + return getStatus(db, options.migrationsDir); +} + +export { + runUp, + runDown, + getStatus, + loadMigrations, + parseMigrationSql, + getAppliedMigrations, + recordMigration, + removeMigration, + isApplied, + ensureMigrationsTable, +}; + +export type { + Migration, + MigrationStatus, + MigrationResult, + MigrationOptions, + RollbackOptions, + AppliedMigration, +}; diff --git a/backend/src/db/migrations/runner.test.ts b/backend/src/db/migrations/runner.test.ts new file mode 100644 index 00000000..6bbbdd41 --- /dev/null +++ b/backend/src/db/migrations/runner.test.ts @@ -0,0 +1,232 @@ +import Database from "better-sqlite3"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import request from "supertest"; +import { + runMigrations, + rollbackMigrations, + getMigrationStatus, + loadMigrations, + parseMigrationSql, + getAppliedMigrations, + isApplied, +} from "./index"; +import { createApp } from "../../api/app"; + +describe("Database Migrations System", () => { + let db: Database.Database; + let tempDir: string; + + beforeEach(() => { + db = new Database(":memory:"); + + // Create a temp directory for test migration SQL files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "migration-test-")); + + // Create 001_create_users.sql + fs.writeFileSync( + path.join(tempDir, "001_create_users.sql"), + `-- UP +CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); +-- DOWN +DROP TABLE IF EXISTS users; +`, + ); + + // Create 002_create_posts.sql + fs.writeFileSync( + path.join(tempDir, "002_create_posts.sql"), + `-- UP +CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, user_id INTEGER); +-- DOWN +DROP TABLE IF EXISTS posts; +`, + ); + + // Create 003_add_index.sql + fs.writeFileSync( + path.join(tempDir, "003_add_index.sql"), + `-- UP +CREATE INDEX idx_posts_user_id ON posts(user_id); +-- DOWN +DROP INDEX IF EXISTS idx_posts_user_id; +`, + ); + }); + + afterEach(() => { + db.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("SQL File Parsing & Discovery", () => { + it("should parse UP and DOWN sections from SQL migration files", () => { + const sqlContent = `-- Header +CREATE TABLE dummy (id INT); + +-- DOWN +DROP TABLE dummy; +`; + const parsed = parseMigrationSql(sqlContent); + expect(parsed.up).toContain("CREATE TABLE dummy"); + expect(parsed.down).toContain("DROP TABLE dummy"); + }); + + it("should discover and load migrations in alphabetical/numeric order", () => { + const migrations = loadMigrations(tempDir); + expect(migrations.length).toBe(3); + expect(migrations[0].version).toBe("001"); + expect(migrations[1].version).toBe("002"); + expect(migrations[2].version).toBe("003"); + }); + }); + + describe("Version Tracking & Schema Initialization", () => { + it("should initialize schema_migrations table and track applied migrations", () => { + const statusBefore = getMigrationStatus(db, { migrationsDir: tempDir }); + expect(statusBefore.length).toBe(3); + expect(statusBefore.every((s) => !s.applied)).toBe(true); + + const result = runMigrations(db, { migrationsDir: tempDir }); + expect(result.migrated).toEqual(["001", "002", "003"]); + + const applied = getAppliedMigrations(db); + expect(applied.length).toBe(3); + expect(applied.map((a) => a.version)).toEqual(["001", "002", "003"]); + + expect(isApplied(db, "001")).toBe(true); + expect(isApplied(db, "002")).toBe(true); + expect(isApplied(db, "003")).toBe(true); + + // Verify actual tables created in sqlite + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() + .map((r: any) => r.name); + expect(tables).toContain("users"); + expect(tables).toContain("posts"); + expect(tables).toContain("schema_migrations"); + }); + }); + + describe("Idempotency", () => { + it("should be safe to run migrations multiple times without error or duplicates", () => { + const run1 = runMigrations(db, { migrationsDir: tempDir }); + expect(run1.migrated).toEqual(["001", "002", "003"]); + + const run2 = runMigrations(db, { migrationsDir: tempDir }); + expect(run2.migrated).toEqual([]); + + const applied = getAppliedMigrations(db); + expect(applied.length).toBe(3); + }); + }); + + describe("Dry-Run Mode", () => { + it("should report migrations to be applied without executing them in dry-run mode", () => { + const result = runMigrations(db, { migrationsDir: tempDir, dryRun: true }); + expect(result.dryRun).toBe(true); + expect(result.migrated).toEqual(["001", "002", "003"]); + + // Verify tables were NOT created + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() + .map((r: any) => r.name); + expect(tables).not.toContain("users"); + expect(tables).not.toContain("posts"); + + // Verify no records in schema_migrations + const applied = getAppliedMigrations(db); + expect(applied.length).toBe(0); + }); + + it("should report migrations to be rolled back without executing in dry-run mode", () => { + runMigrations(db, { migrationsDir: tempDir }); + + const rollbackResult = rollbackMigrations(db, { + migrationsDir: tempDir, + steps: 2, + dryRun: true, + }); + + expect(rollbackResult.dryRun).toBe(true); + expect(rollbackResult.rolledBack).toEqual(["003", "002"]); + + // Verify tables still exist + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() + .map((r: any) => r.name); + expect(tables).toContain("users"); + expect(tables).toContain("posts"); + + const applied = getAppliedMigrations(db); + expect(applied.length).toBe(3); + }); + }); + + describe("Rollback Support (DOWN Migrations)", () => { + it("should roll back migrations by steps", () => { + runMigrations(db, { migrationsDir: tempDir }); + + // Rollback 1 step (003) + const res1 = rollbackMigrations(db, { migrationsDir: tempDir, steps: 1 }); + expect(res1.rolledBack).toEqual(["003"]); + expect(isApplied(db, "003")).toBe(false); + expect(isApplied(db, "002")).toBe(true); + + // Rollback 2 steps (002, 001) + const res2 = rollbackMigrations(db, { migrationsDir: tempDir, steps: 2 }); + expect(res2.rolledBack).toEqual(["002", "001"]); + expect(isApplied(db, "002")).toBe(false); + expect(isApplied(db, "001")).toBe(false); + + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() + .map((r: any) => r.name); + expect(tables).not.toContain("users"); + expect(tables).not.toContain("posts"); + }); + + it("should roll back down to target version", () => { + runMigrations(db, { migrationsDir: tempDir }); + + // Target version "001": rollback 003 and 002 so state is at 001 + const res = rollbackMigrations(db, { migrationsDir: tempDir, targetVersion: "001" }); + expect(res.rolledBack).toEqual(["003", "002"]); + expect(isApplied(db, "001")).toBe(true); + expect(isApplied(db, "002")).toBe(false); + expect(isApplied(db, "003")).toBe(false); + }); + }); + + describe("Default Workspace Migrations", () => { + it("should successfully run existing project migrations (001, 002, 003)", () => { + const realMigrationsDir = path.join(__dirname); + const result = runMigrations(db, { migrationsDir: realMigrationsDir }); + expect(result.migrated).toContain("001"); + expect(result.migrated).toContain("002"); + expect(result.migrated).toContain("003"); + + const status = getMigrationStatus(db, { migrationsDir: realMigrationsDir }); + expect(status.every((s) => s.applied)).toBe(true); + }); + }); + + describe("GET /migrations API Endpoint", () => { + it("should return migration status via HTTP GET /migrations", async () => { + const { httpServer, close } = createApp(); + try { + const res = await request(httpServer).get("/migrations"); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + expect(Array.isArray(res.body.migrations)).toBe(true); + } finally { + close(); + } + }); + }); +}); diff --git a/backend/src/db/migrations/runner.ts b/backend/src/db/migrations/runner.ts new file mode 100644 index 00000000..f7bd1adc --- /dev/null +++ b/backend/src/db/migrations/runner.ts @@ -0,0 +1,261 @@ +import fs from "fs"; +import path from "path"; +import type Database from "better-sqlite3"; +import { + ensureMigrationsTable, + getAppliedMigrations, + recordMigration, + removeMigration, + isApplied, +} from "./tracker"; + +export interface Migration { + version: string; + name: string; + filename: string; + up: string; + down: string; +} + +export interface MigrationStatus { + version: string; + name: string; + filename: string; + applied: boolean; + appliedAt?: string; +} + +export interface MigrationResult { + migrated: string[]; + rolledBack: string[]; + pending: string[]; + dryRun: boolean; +} + +export interface MigrationOptions { + migrationsDir?: string; + targetVersion?: string; + dryRun?: boolean; +} + +export interface RollbackOptions { + migrationsDir?: string; + steps?: number; + targetVersion?: string; + dryRun?: boolean; +} + +/** + * Splits SQL migration content into UP and DOWN statements using `-- DOWN` comments. + */ +export function parseMigrationSql(content: string): { up: string; down: string } { + const downMarker = /^--\s*(-{2}\s*)?DOWN\b/im; + const match = content.match(downMarker); + if (match && match.index !== undefined) { + const up = content.substring(0, match.index).trim(); + const downIndex = match.index + match[0].length; + const down = content.substring(downIndex).trim(); + return { up, down }; + } + return { up: content.trim(), down: "" }; +} + +/** + * Discovers and loads all .sql migration files from a directory, sorted by filename. + */ +export function loadMigrations(dirPath?: string): Migration[] { + const directory = dirPath ?? __dirname; + if (!fs.existsSync(directory)) { + return []; + } + + const files = fs + .readdirSync(directory) + .filter((file) => file.endsWith(".sql")) + .sort((a, b) => a.localeCompare(b)); + + return files.map((filename) => { + const filePath = path.join(directory, filename); + const content = fs.readFileSync(filePath, "utf-8"); + const { up, down } = parseMigrationSql(content); + + // Extract version prefix (e.g., "001" from "001_add_stats_indexes.sql") + const match = filename.match(/^(\d+|[a-zA-Z0-9_\-]+?)(?:_|\.sql)/); + const version = match ? match[1] : filename.replace(/\.sql$/, ""); + + return { + version, + name: filename, + filename, + up, + down, + }; + }); +} + +/** + * Gets the status of all available migrations against the database. + */ +export function getStatus(db: Database.Database, migrationsDir?: string): MigrationStatus[] { + ensureMigrationsTable(db); + const migrations = loadMigrations(migrationsDir); + const appliedList = getAppliedMigrations(db); + + const appliedMap = new Map(); + for (const item of appliedList) { + appliedMap.set(item.version, item.applied_at); + } + + return migrations.map((m) => ({ + version: m.version, + name: m.name, + filename: m.filename, + applied: appliedMap.has(m.version), + appliedAt: appliedMap.get(m.version), + })); +} + +/** + * Runs pending migrations in order (UP). + */ +export function runUp(db: Database.Database, options: MigrationOptions = {}): MigrationResult { + ensureMigrationsTable(db); + const { migrationsDir, targetVersion, dryRun = false } = options; + const migrations = loadMigrations(migrationsDir); + + const pending: Migration[] = []; + for (const m of migrations) { + if (!isApplied(db, m.version)) { + pending.push(m); + if ( + targetVersion && + (m.version === targetVersion || m.filename === targetVersion || m.name === targetVersion) + ) { + break; + } + } + } + + if (dryRun) { + return { + migrated: pending.map((m) => m.version), + rolledBack: [], + pending: migrations + .filter((m) => !isApplied(db, m.version) && !pending.includes(m)) + .map((m) => m.version), + dryRun: true, + }; + } + + const migrated: string[] = []; + for (const m of pending) { + if (isApplied(db, m.version)) { + continue; + } + + const runTx = db.transaction(() => { + if (m.up.length > 0) { + db.exec(m.up); + } + recordMigration(db, m.version, m.name); + }); + + runTx(); + migrated.push(m.version); + } + + const remainingPending = migrations + .filter((m) => !isApplied(db, m.version)) + .map((m) => m.version); + + return { + migrated, + rolledBack: [], + pending: remainingPending, + dryRun: false, + }; +} + +/** + * Rolls back applied migrations in reverse order (DOWN). + */ +export function runDown(db: Database.Database, options: RollbackOptions = {}): MigrationResult { + ensureMigrationsTable(db); + const { migrationsDir, steps = 1, targetVersion, dryRun = false } = options; + + const allMigrations = loadMigrations(migrationsDir); + const migrationMap = new Map(); + for (const m of allMigrations) { + migrationMap.set(m.version, m); + } + + const appliedList = getAppliedMigrations(db); + // Reverse applied migrations so latest is first + const reversedApplied = [...appliedList].reverse(); + + let toRollback: { version: string; name: string; downSql: string }[] = []; + + if (targetVersion) { + // Rollback down to (excluding) targetVersion, or rollback targetVersion and all later? + // Standard convention: rollback all migrations until targetVersion is the current version. + for (const app of reversedApplied) { + if ( + app.version === targetVersion || + app.name === targetVersion + ) { + break; + } + const def = migrationMap.get(app.version); + toRollback.push({ + version: app.version, + name: app.name, + downSql: def ? def.down : "", + }); + } + } else { + // Take `steps` migrations + const count = Math.min(steps, reversedApplied.length); + const slice = reversedApplied.slice(0, count); + toRollback = slice.map((app) => { + const def = migrationMap.get(app.version); + return { + version: app.version, + name: app.name, + downSql: def ? def.down : "", + }; + }); + } + + if (dryRun) { + return { + migrated: [], + rolledBack: toRollback.map((r) => r.version), + pending: [], + dryRun: true, + }; + } + + const rolledBack: string[] = []; + for (const r of toRollback) { + const rollbackTx = db.transaction(() => { + if (r.downSql.length > 0) { + db.exec(r.downSql); + } + removeMigration(db, r.version); + }); + + rollbackTx(); + rolledBack.push(r.version); + } + + const remainingPending = allMigrations + .filter((m) => !isApplied(db, m.version)) + .map((m) => m.version); + + return { + migrated: [], + rolledBack, + pending: remainingPending, + dryRun: false, + }; +} diff --git a/backend/src/db/migrations/tracker.ts b/backend/src/db/migrations/tracker.ts new file mode 100644 index 00000000..57615272 --- /dev/null +++ b/backend/src/db/migrations/tracker.ts @@ -0,0 +1,63 @@ +import type Database from "better-sqlite3"; + +export interface AppliedMigration { + id?: number; + version: string; + name: string; + applied_at: string; +} + +/** + * Ensures the `schema_migrations` table exists in the database. + */ +export function ensureMigrationsTable(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); +} + +/** + * Retrieves all applied migrations from the database, sorted by version. + */ +export function getAppliedMigrations(db: Database.Database): AppliedMigration[] { + ensureMigrationsTable(db); + const rows = db + .prepare("SELECT id, version, name, applied_at FROM schema_migrations ORDER BY version ASC, id ASC") + .all() as AppliedMigration[]; + return rows; +} + +/** + * Records an applied migration in the `schema_migrations` table. + */ +export function recordMigration(db: Database.Database, version: string, name: string): void { + ensureMigrationsTable(db); + db.prepare(` + INSERT INTO schema_migrations (version, name, applied_at) + VALUES (?, ?, ?) + `).run(version, name, new Date().toISOString()); +} + +/** + * Removes a migration record from the `schema_migrations` table during rollback. + */ +export function removeMigration(db: Database.Database, version: string): void { + ensureMigrationsTable(db); + db.prepare("DELETE FROM schema_migrations WHERE version = ?").run(version); +} + +/** + * Checks if a specific migration version has already been applied. + */ +export function isApplied(db: Database.Database, version: string): boolean { + ensureMigrationsTable(db); + const row = db + .prepare("SELECT 1 FROM schema_migrations WHERE version = ?") + .get(version); + return Boolean(row); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index b72e8d6d..fecd52be 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,7 @@ import { AgentCleanupService } from "./services/agentCleanup"; import { createTaskDb, getTaskDb, closeTaskDb } from "./db/tasks"; import { createAgentDb, getAgentDb, closeAgentDb } from "./db/agents"; import { closeDb } from "./db/index"; +import { runMigrations } from "./db/migrations"; async function main() { // ── Validate env config at startup ────────────────────────────────────────── @@ -20,6 +21,15 @@ async function main() { console.log("[ai-net-backend] Starting server..."); + // Run automated database migrations if enabled + if (config.AUTO_MIGRATE !== false) { + console.log("[ai-net-backend] Running database migrations..."); + const migrationResult = runMigrations(getTaskDb()); + console.log( + `[ai-net-backend] Migrations complete: ${migrationResult.migrated.length} applied.`, + ); + } + try { // Start agent sync startAgentSync();