378 lines · 13.5 KB · TypeScript
RawDownload
import { Database } from "bun:sqlite";import { drizzle } from "drizzle-orm/bun-sqlite";import { migrate } from "drizzle-orm/bun-sqlite/migrator";import { Context, Data, Effect, Layer, ManagedRuntime, Schema } 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 { Scope, ScopeId, type AnyPlugin, collectSchemas, createExecutor } from "@executor-js/sdk";import { makeSqliteAdapter, makeSqliteBlobStore } from "@executor-js/storage-file";import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";import { loadPluginsFromJsonc, makeFileConfigSink } from "@executor-js/config";import * as executorSchema from "./executor-schema"; import { syncFromConfig, resolveConfigPath } from "./config-sync";import executorConfig from "../../executor.config"; // 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 dataDir: 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, dataDir, 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}`;}; // Plugins reach the host through two doors that compose://   - `executor.config.ts`'s static tuple (typed at TS compile time)//   - `executor.jsonc#plugins` (loaded via jiti at boot)// We concatenate the two and widen the result to `readonly AnyPlugin[]`.// The frontend's typed atom client still resolves correctly because// each plugin imports its own group from `${pkg}/shared`.type LocalPlugins = readonly AnyPlugin[]; interface LocalExecutorBundle {  readonly executor: Effect.Success<ReturnType<typeof createExecutor<LocalPlugins>>>;  readonly plugins: LocalPlugins;} class LocalExecutorTag extends Context.Service<LocalExecutorTag, LocalExecutorBundle>()(  "@executor-js/local/Executor",) {} export type LocalExecutor = LocalExecutorBundle["executor"]; export class LocalDatabaseSchemaTooNew extends Data.TaggedError("LocalDatabaseSchemaTooNew")<{  readonly message: string;  readonly dbPath: string;  readonly appliedMigrationCount: number;  readonly knownMigrationCount: number;}> {} export class LocalDatabaseMigrationHistoryMismatch extends Data.TaggedError(  "LocalDatabaseMigrationHistoryMismatch",)<{  readonly message: string;  readonly dbPath: string;  readonly migrationIndex: number;  readonly appliedHash: string | undefined;  readonly knownHash: string | undefined;}> {} class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{  readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime";  readonly cause: unknown;}> {} const ignorePromiseFailure = (  operation: LocalExecutorDisposeError["operation"],  try_: () => Promise<unknown>,) =>  Effect.runPromise(    Effect.ignore(      Effect.tryPromise({        try: try_,        catch: (cause) => new LocalExecutorDisposeError({ operation, cause }),      }),    ),  ); const handleOrNull = (promise: ReturnType<typeof createExecutorHandle>) =>  Effect.runPromise(    Effect.tryPromise({      try: () => promise,      catch: (cause) => new LocalExecutorDisposeError({ operation: "createHandle", cause }),    }).pipe(      Effect.catch(() =>        Effect.succeed<Awaited<ReturnType<typeof createExecutorHandle>> | null>(null),      ),    ),  ); export const drizzleMigrationsTableExists = (sqlite: Database): boolean => {  const row = sqlite    .query<{ name: string }, [string]>(      "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",    )    .get("__drizzle_migrations");   return row != null;}; export const readAppliedDrizzleMigrationHashes = (sqlite: Database): ReadonlyArray<string> => {  if (!drizzleMigrationsTableExists(sqlite)) return [];   // Drizzle inserts one row per applied migration. `id` is the stable  // application order; `created_at` comes from migration metadata and can tie.  return sqlite    .query<{ hash: string }, []>("SELECT hash FROM __drizzle_migrations ORDER BY id ASC")    .all()    .map((row) => row.hash);}; const DrizzleJournal = Schema.Struct({  entries: Schema.Array(    Schema.Struct({      idx: Schema.Number,      tag: Schema.String,    }),  ),}); const decodeDrizzleJournal = Schema.decodeUnknownSync(Schema.fromJsonString(DrizzleJournal)); export const readBundledDrizzleMigrationHashes = (  migrationsFolder: string,): ReadonlyArray<string> => {  // Keep this in sync with drizzle-orm/src/migrator.ts: Drizzle hashes the raw  // migration file contents before splitting on statement breakpoints.  const journal = decodeDrizzleJournal(    fs.readFileSync(join(migrationsFolder, "meta", "_journal.json")).toString(),  );   return [...journal.entries]    .sort((left, right) => left.idx - right.idx)    .map((entry) => {      const query = fs.readFileSync(join(migrationsFolder, `${entry.tag}.sql`)).toString();      return createHash("sha256").update(query).digest("hex");    });}; const schemaTooNewMessage = (dataDir: string): string =>  [    `This Executor binary is older than the schema in ${dataDir}.`,    "The database was likely opened by a newer Executor build.",    "Use a newer Executor binary or set EXECUTOR_DATA_DIR to a different data directory.",  ].join("\n"); const migrationHistoryMismatchMessage = (dataDir: string): string =>  [    `The migration history in ${dataDir} does not match this Executor build.`,    "The database may have been created by a different development branch, manually modified, or corrupted.",    "Use the matching Executor build, set EXECUTOR_DATA_DIR to a different data directory, or restore a backup.",  ].join("\n"); export const checkDrizzleMigrationCompatibility = (input: {  readonly sqlite: Database;  readonly dbPath: string;  readonly dataDir: string;  readonly migrationsFolder: string;}): Effect.Effect<void, LocalDatabaseSchemaTooNew | LocalDatabaseMigrationHistoryMismatch> =>  Effect.gen(function* () {    // Before running migrations, ensure the DB history is a prefix of the    // migrations bundled with this binary. This catches newer or divergent schemas    // before startup reaches arbitrary schema-dependent queries.    if (!drizzleMigrationsTableExists(input.sqlite)) return;     const applied = readAppliedDrizzleMigrationHashes(input.sqlite);    const bundled = readBundledDrizzleMigrationHashes(input.migrationsFolder);     if (applied.length > bundled.length) {      return yield* new LocalDatabaseSchemaTooNew({        message: schemaTooNewMessage(input.dataDir),        dbPath: input.dbPath,        appliedMigrationCount: applied.length,        knownMigrationCount: bundled.length,      });    }     for (let index = 0; index < applied.length; index += 1) {      if (applied[index] !== bundled[index]) {        return yield* new LocalDatabaseMigrationHistoryMismatch({          message: migrationHistoryMismatchMessage(input.dataDir),          dbPath: input.dbPath,          migrationIndex: index,          appliedHash: applied[index],          knownHash: bundled[index],        });      }    }  }); const createLocalExecutorLayer = () => {  const { path: dbPath, dataDir, legacySecrets } = resolveDbPath();   return Layer.effect(LocalExecutorTag)(    Effect.gen(function* () {      const sqlite = yield* Effect.acquireRelease(        Effect.sync(() => new Database(dbPath)),        (conn) => Effect.sync(() => conn.close()),      );      yield* checkDrizzleMigrationCompatibility({        sqlite,        dbPath,        dataDir,        migrationsFolder: MIGRATIONS_FOLDER,      });      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);      }      const configPath = resolveConfigPath(cwd);      const configFile = makeFileConfigSink({        path: configPath,        fsLayer: NodeFileSystem.layer,      });       const staticPlugins = executorConfig.plugins({ configFile });      const dynamicPlugins =        (yield* Effect.promise(() =>          loadPluginsFromJsonc({ path: configPath, deps: { configFile } }),        )) ?? [];      // Static config wins on conflict — mirrors @executor-js/vite-plugin's      // ordering. Without this, a package listed in both surfaces would      // boot twice (double routes, double in-memory storage).      const staticPackageNames = new Set(        staticPlugins.map((p) => p.packageName).filter((n): n is string => !!n),      );      const dedupedDynamic = dynamicPlugins.filter((p) => {        if (p.packageName && staticPackageNames.has(p.packageName)) {          console.warn(            `[executor] plugin "${p.packageName}" appears in both ` +              `executor.config.ts and executor.jsonc#plugins. The static ` +              `entry wins; the jsonc entry is ignored.`,          );          return false;        }        return true;      });      const plugins: LocalPlugins = [...staticPlugins, ...dedupedDynamic];      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",        oauthEndpointUrlPolicy: { allowHttp: true },      });       // 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, targetScope: scope.id });       return { executor, plugins };    }),  );}; export const createExecutorHandle = async () => {  const layer = createLocalExecutorLayer();  const runtime = ManagedRuntime.make(layer);  const bundle = await runtime.runPromise(LocalExecutorTag.asEffect());   return {    executor: bundle.executor,    plugins: bundle.plugins,    dispose: async () => {      await Effect.runPromise(Effect.ignore(bundle.executor.close()));      await ignorePromiseFailure("disposeRuntime", () => runtime.dispose());    },  };}; export type ExecutorHandle = Awaited<ReturnType<typeof createExecutorHandle>>; let sharedHandlePromise: ReturnType<typeof createExecutorHandle> | null = null; const loadSharedHandle = () => {  if (!sharedHandlePromise) {    sharedHandlePromise = createExecutorHandle();  }  return sharedHandlePromise;}; export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor);export const getExecutorBundle = () => loadSharedHandle(); export const disposeExecutor = async (): Promise<void> => {  const currentHandlePromise = sharedHandlePromise;  sharedHandlePromise = null;   const handle = currentHandlePromise ? await handleOrNull(currentHandlePromise) : null;  if (handle) {    await ignorePromiseFailure("disposeExecutor", () => handle.dispose());  }}; export const reloadExecutor = () => {  disposeExecutor();  return getExecutor();};