import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { Context, Effect, Layer, ManagedRuntime } from "effect"; import { createHash } from "node:crypto"; import * as fs from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import embeddedMigrations from "./embedded-migrations.gen"; import { importLegacySecrets, moveAsidePreScopeDb, readLegacySecrets, type LegacySecret, } from "./db-upgrade"; import { migrateLegacyConnections } from "./migrate-connections"; import { Scope, ScopeId, collectSchemas, createExecutor, } from "@executor-js/sdk"; import { makeSqliteAdapter, makeSqliteBlobStore, } from "@executor-js/storage-file"; import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import { makeFileConfigSink, type ConfigFileSink } from "@executor-js/config"; import * as executorSchema from "./executor-schema"; import { syncFromConfig, resolveConfigPath } from "./config-sync"; import { openApiPlugin } from "@executor-js/plugin-openapi"; import { mcpPlugin } from "@executor-js/plugin-mcp"; import { googleDiscoveryPlugin } from "@executor-js/plugin-google-discovery"; import { graphqlPlugin } from "@executor-js/plugin-graphql"; import { keychainPlugin } from "@executor-js/plugin-keychain"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { onepasswordPlugin } from "@executor-js/plugin-onepassword"; // In dev mode the drizzle folder sits next to the source tree. In a compiled // binary the files are inlined via the build-time gen module below, and we // extract them to a tmpdir at boot so drizzle's `migrate()` — which only // accepts a folder path — can read them. const resolveMigrationsFolder = (): string => { if (!embeddedMigrations) { return join(import.meta.dirname, "../../drizzle"); } const dir = fs.mkdtempSync(join(tmpdir(), "executor-migrations-")); for (const [rel, content] of Object.entries(embeddedMigrations)) { const target = join(dir, rel); fs.mkdirSync(dirname(target), { recursive: true }); fs.writeFileSync(target, content); } return dir; }; const MIGRATIONS_FOLDER = resolveMigrationsFolder(); interface ResolvedDb { readonly path: string; readonly legacySecrets: readonly LegacySecret[]; } const resolveDbPath = (): ResolvedDb => { const dataDir = process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor"); fs.mkdirSync(dataDir, { recursive: true }); const dbPath = `${dataDir}/data.db`; // DBs written by pre-scope-refactor versions of the CLI have a schema // the current drizzle migration can't be applied on top of. Before we // move it aside, pull the `secret` routing rows so non-enumerating // providers (keychain) stay reachable after the fresh DB is created. const legacySecrets = readLegacySecrets(dbPath); const backup = moveAsidePreScopeDb(dbPath); if (backup) { console.warn( `[executor] Pre-scope database detected; moved to ${backup}. ` + `Sources and tool catalogs will need to be re-added` + (legacySecrets.length > 0 ? ` (${legacySecrets.length} secret routing row(s) preserved).` : "."), ); } return { path: dbPath, legacySecrets }; }; // Hash suffix disambiguates same-basename folders so two projects with // identical directory names can't collide on the same scope id. const makeScopeId = (cwd: string): string => { const folder = basename(cwd) || cwd; const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 8); return `${folder}-${hash}`; }; const createLocalPlugins = (configFile: ConfigFileSink) => [ openApiPlugin({ configFile }), mcpPlugin({ dangerouslyAllowStdioMCP: true, configFile }), googleDiscoveryPlugin(), graphqlPlugin({ configFile }), keychainPlugin(), fileSecretsPlugin(), onepasswordPlugin(), ] as const; type LocalPlugins = ReturnType; class LocalExecutorTag extends Context.Service< LocalExecutorTag, Effect.Success>> >()("@executor-js/local/Executor") {} export type LocalExecutor = Context.Service.Shape; const createLocalExecutorLayer = () => { const { path: dbPath, legacySecrets } = resolveDbPath(); return Layer.effect(LocalExecutorTag)(Effect.gen(function* () { const sqlite = yield* Effect.acquireRelease( Effect.sync(() => new Database(dbPath)), (conn) => Effect.sync(() => conn.close()), ); sqlite.exec("PRAGMA journal_mode = WAL"); const db = drizzle(sqlite, { schema: executorSchema }); migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); const scopeId = makeScopeId(cwd); // Reinstate pre-scope secret routing rows once migrations have // created the new `secret` table. INSERT OR IGNORE makes this // safe across reboots and on fresh installs (no-op when there's // nothing to import). if (legacySecrets.length > 0) { importLegacySecrets(sqlite, scopeId, legacySecrets); } // Upgrade pre-connection openapi/mcp/google-discovery rows onto the // new Connection pointer shape. Idempotent + no-op when there's // nothing to migrate. yield* Effect.promise(() => migrateLegacyConnections(sqlite)); const configPath = resolveConfigPath(cwd); const configFile = makeFileConfigSink({ path: configPath, fsLayer: NodeFileSystem.layer, }); const plugins = createLocalPlugins(configFile); const schema = collectSchemas(plugins); const adapter = makeSqliteAdapter({ db, schema }); const blobs = makeSqliteBlobStore({ db }); const scope = new Scope({ id: ScopeId.make(scopeId), name: cwd, createdAt: new Date(), }); const executor = yield* createExecutor({ scopes: [scope], adapter, blobs, plugins, onElicitation: "accept-all", }); // Sync sources from executor.jsonc (idempotent — plugins upsert). // Runs after plugins are wired so sources added here round-trip // back through configFile — harmless because the file already // contains them. yield* syncFromConfig(executor, configPath); return executor; }), ); }; export const createExecutorHandle = async () => { const layer = createLocalExecutorLayer(); const runtime = ManagedRuntime.make(layer); const executor = await runtime.runPromise(LocalExecutorTag.asEffect()); return { executor, dispose: async () => { await Effect.runPromise(executor.close()).catch(() => undefined); await runtime.dispose().catch(() => undefined); }, }; }; export type ExecutorHandle = Awaited>; let sharedHandlePromise: ReturnType | null = null; const loadSharedHandle = () => { if (!sharedHandlePromise) { sharedHandlePromise = createExecutorHandle(); } return sharedHandlePromise; }; export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor); export const disposeExecutor = async (): Promise => { const currentHandlePromise = sharedHandlePromise; sharedHandlePromise = null; const handle = await currentHandlePromise?.catch(() => null); await handle?.dispose().catch(() => undefined); }; export const reloadExecutor = () => { disposeExecutor(); return getExecutor(); };