import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; import * as fs from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { createHash } from "node:crypto"; import { Subject, Tenant, createExecutor, type AnyPlugin, type Executor } from "@executor-js/sdk"; import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; import { collectTables } from "@executor-js/api/server"; import { loadPluginsFromJsonc } from "@executor-js/config"; import executorConfig from "../executor.config"; import { authConfigTransforms } from "./db/auth-config-migration"; import { createSqliteFumaDb } from "./db/sqlite-fumadb"; import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration"; interface ResolvedStorage { readonly dataDir: string; readonly sqlitePath: string; } const localNamespace = "executor_local"; // The single local subject. Local is single-user; the executor binds one // tenant (the cwd-derived workspace) plus this subject so it can own both // `owner: "org"` (workspace-shared) and `owner: "user"` connections. const LOCAL_SUBJECT = "local"; const resolveStorage = (): ResolvedStorage => { const dataDir = process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor"); fs.mkdirSync(dataDir, { recursive: true }); return { dataDir, sqlitePath: join(dataDir, "data.db"), }; }; // Hash suffix disambiguates same-basename folders so two projects with // identical directory names cannot collide on the same tenant id. const makeTenantId = (cwd: string): string => { const folder = basename(cwd) || cwd; const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 8); return `${folder}-${hash}`; }; const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); // Plugins reach the host through two doors that compose: // - `executor.config.ts`'s static tuple // - `executor.jsonc#plugins` loaded at boot // Static config wins on conflict, matching the Vite plugin. type LocalPlugins = readonly AnyPlugin[]; const loadLocalPlugins = Effect.gen(function* () { const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); const staticPlugins = executorConfig.plugins(); const dynamicPlugins = (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ?? []; const staticPackageNames = new Set( staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name), ); const dedupedDynamic = dynamicPlugins.filter((plugin) => { if (plugin.packageName && staticPackageNames.has(plugin.packageName)) { console.warn( `[executor] plugin "${plugin.packageName}" appears in both ` + `executor.config.ts and executor.jsonc#plugins. The static ` + `entry wins; the jsonc entry is ignored.`, ); return false; } return true; }); return { cwd, plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins, }; }); interface LocalExecutorBundle { readonly executor: Executor; readonly plugins: LocalPlugins; } class LocalExecutorTag extends Context.Service()( "@executor-js/local/Executor", ) {} export type LocalExecutor = LocalExecutorBundle["executor"]; class LocalExecutorCreateError extends Data.TaggedError("LocalExecutorCreateError")<{ readonly message: string; readonly cause: unknown; }> {} class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{ readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime"; readonly cause: unknown; }> {} const CREATE_SQLITE_ERROR_MESSAGE = "Failed to open local SQLite data. Close other Executor processes and retry, or run with --log-level debug for details."; const ignorePromiseFailure = ( operation: LocalExecutorDisposeError["operation"], try_: () => Promise, ) => Effect.runPromise( Effect.ignore( Effect.tryPromise({ try: try_, catch: (cause) => new LocalExecutorDisposeError({ operation, cause }), }), ), ); const handleOrNull = (promise: ReturnType) => Effect.runPromise( Effect.tryPromise({ try: () => promise, catch: (cause) => new LocalExecutorDisposeError({ operation: "createHandle", cause }), }).pipe( Effect.catch(() => Effect.succeed> | null>(null), ), ), ); const createLocalExecutorLayer = () => { const storage = resolveStorage(); return Layer.effect(LocalExecutorTag)( Effect.gen(function* () { const { cwd, plugins } = yield* loadLocalPlugins; const tenantId = makeTenantId(cwd); const tables = collectTables(); const migration = yield* Effect.tryPromise({ try: () => migrateLocalV1ToV2IfNeeded({ sqlitePath: storage.sqlitePath, tables, namespace: localNamespace, tenantId, }), catch: (cause) => new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause, }), }); const sqlite = yield* Effect.acquireRelease( Effect.tryPromise({ try: () => createSqliteFumaDb({ tables, namespace: localNamespace, path: storage.sqlitePath, }), catch: (cause) => new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause, }), }), (db) => Effect.promise(() => db.close()).pipe(Effect.ignore), ); // One-off data migration: rewrite pre-canonical integration auth // configs (incl. v1→v2 outputs) into the shared placements model. // Idempotent — a no-op once every row is canonical. yield* Effect.tryPromise({ try: () => runSqliteAuthConfigMigration(sqlite.client, authConfigTransforms), catch: (cause) => new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause }), }); // webBaseUrl is where the executor's web UI listens — same port as the // daemon API since the daemon serves both. Mirrors serve.ts's port // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL // overrides entirely for deployments where the UI is on a different host. const webBaseUrl = process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`; const executor = yield* createExecutor({ tenant: Tenant.make(tenantId), subject: Subject.make(LOCAL_SUBJECT), db: sqlite.db, plugins, onElicitation: "accept-all", oauthEndpointUrlPolicy: { allowHttp: true }, // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback` // route on the same origin as the web UI. Derived from `webBaseUrl` // (loopback localhost is correct + intended for the local CLI, but it // is wired explicitly here rather than relying on a hidden default). redirectUri: new URL("/api/oauth/callback", webBaseUrl).toString(), // Built-in agent-facing tools (integrations / connections / policies). coreTools: { webBaseUrl, }, }); if (migration.migrated) { console.warn( `[executor] Migrated local Executor data to v2; moved old DB to ${migration.backupPath}.`, ); for (const warning of migration.warnings) { console.warn(`[executor] local v2 migration: ${warning}`); } } 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>; let sharedHandlePromise: ReturnType | 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 => { 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(); };