// ---------------------------------------------------------------------------// Boot-time sync — replays sources from executor.jsonc into the executor.// Plugins upsert so a re-sync on an already-populated DB is a no-op.// Write-back (DB → file) is handled by the ConfigFileSink passed to each// plugin in executor.ts.// --------------------------------------------------------------------------- import { Cause, Effect, Match } from "effect";import { join } from "node:path";import * as fs from "node:fs";import * as jsonc from "jsonc-parser"; import type { SourceConfig, ExecutorFileConfig, ConfigHeaderValue, McpAuthConfig,} from "@executor-js/config";import { SECRET_REF_PREFIX } from "@executor-js/config";import type { ScopeId } from "@executor-js/sdk";import type { McpConnectionAuthInput } from "@executor-js/plugin-mcp"; import type { LocalExecutor } from "./executor"; // ---------------------------------------------------------------------------// Header translation: config format → plugin format// --------------------------------------------------------------------------- const translateHeader = ( value: ConfigHeaderValue,): string | { secretId: string; prefix?: string } => { if (typeof value === "string") { if (value.startsWith(SECRET_REF_PREFIX)) { return { secretId: value.slice(SECRET_REF_PREFIX.length) }; } return value; } // Object form: { value, prefix? } if (typeof value.value === "string" && value.value.startsWith(SECRET_REF_PREFIX)) { return { secretId: value.value.slice(SECRET_REF_PREFIX.length), prefix: value.prefix, }; } return value.value;}; const translateHeaders = ( headers: Record<string, ConfigHeaderValue> | undefined,): Record<string, string | { secretId: string; prefix?: string }> | undefined => { if (!headers) return undefined; const out: Record<string, string | { secretId: string; prefix?: string }> = {}; for (const [k, v] of Object.entries(headers)) { out[k] = translateHeader(v); } return out;}; // MCP auth translation: file format → plugin format. The header variant// stores credentials as `secret-public-ref:<id>`; the plugin SDK takes the// raw secret id. The oauth2 variant is structurally identical.export const translateMcpAuth = ( auth: McpAuthConfig | undefined,): McpConnectionAuthInput | undefined => { if (!auth) return undefined; if (auth.kind === "none") return { kind: "none" }; if (auth.kind === "header") { const secretId = auth.secret.startsWith(SECRET_REF_PREFIX) ? auth.secret.slice(SECRET_REF_PREFIX.length) : auth.secret; return { kind: "header", headerName: auth.headerName, secretId, prefix: auth.prefix, }; } return { kind: "oauth2", connectionId: auth.connectionId };}; // ---------------------------------------------------------------------------// Config path resolution// --------------------------------------------------------------------------- export const resolveConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); // ---------------------------------------------------------------------------// Load config (sync, no Effect deps — runs at startup)// --------------------------------------------------------------------------- const loadConfigSync = (path: string): ExecutorFileConfig | null => { if (!fs.existsSync(path)) return null; const raw = fs.readFileSync(path, "utf-8"); const errors: jsonc.ParseError[] = []; const parsed = jsonc.parse(raw, errors); if (errors.length > 0) { console.warn(`[config-sync] Failed to parse ${path}:`, errors); return null; } return parsed as ExecutorFileConfig;}; // ---------------------------------------------------------------------------// Sync from config → DB// --------------------------------------------------------------------------- const addSourceFromConfig = ( executor: LocalExecutor, targetScope: ScopeId, source: SourceConfig,): Effect.Effect<void, unknown> => { return Match.value(source).pipe( Match.when({ kind: "openapi" }, (s) => executor.openapi .addSpec({ spec: s.spec, scope: targetScope, baseUrl: s.baseUrl, namespace: s.namespace, headers: translateHeaders(s.headers), credentialTargetScope: targetScope, }) .pipe(Effect.asVoid), ), Match.when({ kind: "graphql" }, (s) => executor.graphql .addSource({ endpoint: s.endpoint, scope: targetScope, namespace: s.namespace, headers: translateHeaders(s.headers) as Record<string, string> | undefined, credentialTargetScope: targetScope, }) .pipe(Effect.asVoid), ), Match.when({ kind: "mcp" }, (s) => { if (s.transport === "stdio") { return executor.mcp .addSource({ transport: "stdio", scope: targetScope, name: s.name, command: s.command, args: s.args ? [...s.args] : undefined, env: s.env, cwd: s.cwd, namespace: s.namespace, }) .pipe(Effect.asVoid); } return executor.mcp .addSource({ transport: "remote", scope: targetScope, name: s.name, endpoint: s.endpoint, remoteTransport: s.remoteTransport, queryParams: s.queryParams, headers: s.headers, namespace: s.namespace, auth: translateMcpAuth(s.auth), credentialTargetScope: targetScope, }) .pipe(Effect.asVoid); }), Match.exhaustive, );}; /** * Read executor.jsonc and replay all sources into the executor. * Each source is added independently — if one fails, the rest still load. */export const syncFromConfig = (input: { readonly executor: LocalExecutor; readonly configPath: string; readonly targetScope: ScopeId;}): Effect.Effect<void> => Effect.gen(function* () { const { executor, configPath, targetScope } = input; const config = loadConfigSync(configPath); if (!config?.sources?.length) { console.log(`[config-sync] ${configPath} missing or empty, skipping`); return; } console.log(`[config-sync] syncing ${config.sources.length} source(s) from ${configPath}`); const results = yield* Effect.forEach( config.sources, (source) => addSourceFromConfig(executor, targetScope, source).pipe( Effect.map(() => true as const), Effect.catchCause((cause) => { const ns = "namespace" in source ? source.namespace : "name" in source ? source.name : "unknown"; const squashed = Cause.squash(cause); const message = squashed && typeof squashed === "object" && "message" in squashed ? String((squashed as { message: unknown }).message) : Cause.pretty(cause); console.warn(`[config-sync] Failed to load source "${ns}": ${message}`); return Effect.succeed(false as const); }), ), // Serial — bun:sqlite serializes transactions on a single connection, // so concurrent addSpec calls race on BEGIN. { concurrency: 1 }, ); const ok = results.filter(Boolean).length; console.log(`[config-sync] ${ok}/${results.length} source(s) synced`); });
// ---------------------------------------------------------------------------
// Boot-time sync — replays sources from executor.jsonc into the executor.
// Plugins upsert so a re-sync on an already-populated DB is a no-op.
// Write-back (DB → file) is handled by the ConfigFileSink passed to each
// plugin in executor.ts.
// ---------------------------------------------------------------------------
import { Cause, Effect, Match } from "effect";
import { join } from "node:path";
import * as fs from "node:fs";
import * as jsonc from "jsonc-parser";
import type {
SourceConfig,
ExecutorFileConfig,
ConfigHeaderValue,
McpAuthConfig,
} from "@executor-js/config";
import { SECRET_REF_PREFIX } from "@executor-js/config";
import type { ScopeId } from "@executor-js/sdk";
import type { McpConnectionAuthInput } from "@executor-js/plugin-mcp";
import type { LocalExecutor } from "./executor";
// ---------------------------------------------------------------------------
// Header translation: config format → plugin format
// ---------------------------------------------------------------------------
const translateHeader = (
value: ConfigHeaderValue,
): string | { secretId: string; prefix?: string } => {
if (typeof value === "string") {
if (value.startsWith(SECRET_REF_PREFIX)) {
return { secretId: value.slice(SECRET_REF_PREFIX.length) };
}
return value;
}
// Object form: { value, prefix? }
if (typeof value.value === "string" && value.value.startsWith(SECRET_REF_PREFIX)) {
return {
secretId: value.value.slice(SECRET_REF_PREFIX.length),
prefix: value.prefix,
};
}
return value.value;
};
const translateHeaders = (
headers: Record<string, ConfigHeaderValue> | undefined,
): Record<string, string | { secretId: string; prefix?: string }> | undefined => {
if (!headers) return undefined;
const out: Record<string, string | { secretId: string; prefix?: string }> = {};
for (const [k, v] of Object.entries(headers)) {
out[k] = translateHeader(v);
}
return out;
};
// MCP auth translation: file format → plugin format. The header variant
// stores credentials as `secret-public-ref:<id>`; the plugin SDK takes the
// raw secret id. The oauth2 variant is structurally identical.
export const translateMcpAuth = (
auth: McpAuthConfig | undefined,
): McpConnectionAuthInput | undefined => {
if (!auth) return undefined;
if (auth.kind === "none") return { kind: "none" };
if (auth.kind === "header") {
const secretId = auth.secret.startsWith(SECRET_REF_PREFIX)
? auth.secret.slice(SECRET_REF_PREFIX.length)
: auth.secret;
return {
kind: "header",
headerName: auth.headerName,
secretId,
prefix: auth.prefix,
};
}
return { kind: "oauth2", connectionId: auth.connectionId };
};
// ---------------------------------------------------------------------------
// Config path resolution
// ---------------------------------------------------------------------------
export const resolveConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc");
// ---------------------------------------------------------------------------
// Load config (sync, no Effect deps — runs at startup)
// ---------------------------------------------------------------------------
const loadConfigSync = (path: string): ExecutorFileConfig | null => {
if (!fs.existsSync(path)) return null;
const raw = fs.readFileSync(path, "utf-8");
const errors: jsonc.ParseError[] = [];
const parsed = jsonc.parse(raw, errors);
if (errors.length > 0) {
console.warn(`[config-sync] Failed to parse ${path}:`, errors);
return null;
}
return parsed as ExecutorFileConfig;
};
// ---------------------------------------------------------------------------
// Sync from config → DB
// ---------------------------------------------------------------------------
const addSourceFromConfig = (
executor: LocalExecutor,
targetScope: ScopeId,
source: SourceConfig,
): Effect.Effect<void, unknown> => {
return Match.value(source).pipe(
Match.when({ kind: "openapi" }, (s) =>
executor.openapi
.addSpec({
spec: s.spec,
scope: targetScope,
baseUrl: s.baseUrl,
namespace: s.namespace,
headers: translateHeaders(s.headers),
credentialTargetScope: targetScope,
})
.pipe(Effect.asVoid),
),
Match.when({ kind: "graphql" }, (s) =>
executor.graphql
.addSource({
endpoint: s.endpoint,
scope: targetScope,
namespace: s.namespace,
headers: translateHeaders(s.headers) as Record<string, string> | undefined,
credentialTargetScope: targetScope,
})
.pipe(Effect.asVoid),
),
Match.when({ kind: "mcp" }, (s) => {
if (s.transport === "stdio") {
return executor.mcp
.addSource({
transport: "stdio",
scope: targetScope,
name: s.name,
command: s.command,
args: s.args ? [...s.args] : undefined,
env: s.env,
cwd: s.cwd,
namespace: s.namespace,
})
.pipe(Effect.asVoid);
}
return executor.mcp
.addSource({
transport: "remote",
scope: targetScope,
name: s.name,
endpoint: s.endpoint,
remoteTransport: s.remoteTransport,
queryParams: s.queryParams,
headers: s.headers,
namespace: s.namespace,
auth: translateMcpAuth(s.auth),
credentialTargetScope: targetScope,
})
.pipe(Effect.asVoid);
}),
Match.exhaustive,
);
};
/**
* Read executor.jsonc and replay all sources into the executor.
* Each source is added independently — if one fails, the rest still load.
*/
export const syncFromConfig = (input: {
readonly executor: LocalExecutor;
readonly configPath: string;
readonly targetScope: ScopeId;
}): Effect.Effect<void> =>
Effect.gen(function* () {
const { executor, configPath, targetScope } = input;
const config = loadConfigSync(configPath);
if (!config?.sources?.length) {
console.log(`[config-sync] ${configPath} missing or empty, skipping`);
return;
}
console.log(`[config-sync] syncing ${config.sources.length} source(s) from ${configPath}`);
const results = yield* Effect.forEach(
config.sources,
(source) =>
addSourceFromConfig(executor, targetScope, source).pipe(
Effect.map(() => true as const),
Effect.catchCause((cause) => {
const ns =
"namespace" in source ? source.namespace : "name" in source ? source.name : "unknown";
const squashed = Cause.squash(cause);
const message =
squashed && typeof squashed === "object" && "message" in squashed
? String((squashed as { message: unknown }).message)
: Cause.pretty(cause);
console.warn(`[config-sync] Failed to load source "${ns}": ${message}`);
return Effect.succeed(false as const);
}),
),
// Serial — bun:sqlite serializes transactions on a single connection,
// so concurrent addSpec calls race on BEGIN.
{ concurrency: 1 },
);
const ok = results.filter(Boolean).length;
console.log(`[config-sync] ${ok}/${results.length} source(s) synced`);
});