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<typeof createLocalPlugins>; class LocalExecutorTag extends Context.Service< LocalExecutorTag, Effect.Success<ReturnType<typeof createExecutor<LocalPlugins>>>>()("@executor-js/local/Executor") {} export type LocalExecutor = Context.Service.Shape<typeof LocalExecutorTag>; 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<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 disposeExecutor = async (): Promise<void> => { const currentHandlePromise = sharedHandlePromise; sharedHandlePromise = null; const handle = await currentHandlePromise?.catch(() => null); await handle?.dispose().catch(() => undefined);}; export const reloadExecutor = () => { disposeExecutor(); return getExecutor();};
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<typeof createLocalPlugins>;
class LocalExecutorTag extends Context.Service<
LocalExecutorTag,
Effect.Success<ReturnType<typeof createExecutor<LocalPlugins>>>
>()("@executor-js/local/Executor") {}
export type LocalExecutor = Context.Service.Shape<typeof LocalExecutorTag>;
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<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 disposeExecutor = async (): Promise<void> => {
const currentHandlePromise = sharedHandlePromise;
sharedHandlePromise = null;
const handle = await currentHandlePromise?.catch(() => null);
await handle?.dispose().catch(() => undefined);
};
export const reloadExecutor = () => {
disposeExecutor();
return getExecutor();
};