1053 lines
35 KiB
TypeScript

/**
* mx run — package-first standalone runner.
*
* Preferred mode:
* mx run . --env dev
*
* Actor-file mode:
* mx run ./MyActor.ts --mount my-svc --root MY-ROOT
*/
import { pathToFileURL } from 'node:url';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as net from 'node:net';
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import type { NatsConnection, Subscription } from 'nats';
import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js';
import {
buildMatrixHttpAssetMount,
MatrixHttpAssetEndpointActor,
} from '@open-matrix/system-gateway-http';
import { HostAuthStateStore, HostCredentialStore } from '@open-matrix/system-auth';
import {
serviceCommand,
startServiceCommand,
type IServiceCommandOptions,
type IStartedServiceCommand,
} from './service.js';
import { loadPackageEnvironment, loadPackageEnvironmentFromPath } from '../utils/package-environment.js';
import {
createRunnerRuntimeHandle,
formatRunnerRuntimeClosed,
type IRunnerRuntimeHandle,
} from '../utils/runner-runtime.js';
import { resolveNatsBinary } from '../utils/runner-transport.js';
import { resolveRunnerTransportPlan, type IResolvedRunnerTransportPlan } from '../utils/runner-transport.js';
import { startStandaloneWebappServer, type IStandaloneWebappServerHandle } from '../utils/standalone-webapp-server.js';
import { loadMatrixWebappManifest } from '../utils/webapp-manifest.js';
import {
deregisterRunnerRuntime,
heartbeatRunnerRuntime,
registerRunnerRuntime,
type IRunnerRuntimeRegistration,
type IRunnerWebappRouteMetadata,
} from '../utils/runner-control-plane.js';
import {
packageDeclaresStandaloneFactory,
type ILoadedMatrixServiceManifest,
} from '../utils/service-manifest.js';
import {
defaultRunnerControlMount,
defaultRunnerRuntimeMount,
mountRunnerRuntimeControl,
type RunnerRuntimeLifecycleState,
} from '../utils/runner-runtime-control.js';
import type { ILoadedMatrixWebappManifest } from '../utils/webapp-manifest.js';
export interface IRunCommandOptions {
readonly check?: boolean;
readonly json?: boolean;
readonly env?: string;
readonly envFile?: string;
readonly serve?: boolean;
readonly entry?: string;
readonly mount?: string;
readonly root?: string;
readonly export?: string;
readonly config?: string;
readonly secrets?: string;
readonly overrides?: string;
readonly broker?: boolean;
readonly brokerPort?: number;
readonly federation?: boolean;
readonly backboneUrl?: string;
readonly props?: string;
}
type ActorConstructor = {
new (): MatrixActor;
readonly accepts?: Record<string, unknown>;
readonly name: string;
};
type BrokerLike = {
publish(topic: string, payload: unknown): void;
};
type BrokerConstructor = new () => BrokerLike;
type TransportConstructor = new (broker: BrokerLike, options: { readonly name: string }) => unknown;
type RuntimeConstructor = new () => unknown;
type MatrixContextFactory = {
create(mount: string, transport: unknown, runtime: unknown): Parameters<MatrixActor['initialize']>[0];
};
type FederationPeerConstructor = new (options: {
readonly localRoot: string;
readonly localBus: unknown;
readonly instanceResolver: unknown;
}) => {
setBackbone(transport: unknown): Promise<void>;
start(): Promise<void>;
};
type ExactInstanceResolverConstructor = new () => unknown;
type CreateNatsTransport = (client: NatsConnection) => unknown;
interface IActorFileRunCommandOptions {
readonly mount: string;
readonly root?: string;
readonly export?: string;
readonly broker?: boolean;
readonly brokerPort?: number;
readonly federation?: boolean;
readonly backboneUrl?: string;
readonly props?: string;
}
interface IRunningNatsServer {
readonly port: number;
stop(): Promise<void>;
}
// P1.65 — set process.title for ps/tasklist visibility. Sourcing order:
// MATRIX_RUNTIME_KEY env → packageRef shortname → packageDir basename
// → MATRIX_RUNTIME_ID stripped → default 'runtime'.
function setRuntimeProcessTitle(input: {
readonly runtimeKey?: string;
readonly runtimeId?: string;
readonly packageDir?: string;
}): void {
const fromKey = readTrimmed(input.runtimeKey);
const fromPackageRef = readPackageRefShortName(input.packageDir);
const fromDir = readPackageDirBasename(input.packageDir);
const fromRuntimeId = readRuntimeIdShortName(input.runtimeId);
const name = fromKey ?? fromPackageRef ?? fromDir ?? fromRuntimeId ?? 'runtime';
process.title = `hivecast-runtime:${normalizeRuntimeTitleName(name)}`;
}
function readTrimmed(value: string | undefined): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readPackageRefShortName(packageDir: string | undefined): string | undefined {
if (!packageDir) return undefined;
try {
const manifestPath = path.join(packageDir, 'package.json');
if (!fs.existsSync(manifestPath)) return undefined;
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: unknown };
const name = typeof parsed.name === 'string' ? parsed.name : undefined;
if (!name) return undefined;
return name.split('/').pop();
} catch {
return undefined;
}
}
function readPackageDirBasename(packageDir: string | undefined): string | undefined {
if (!packageDir) return undefined;
const base = path.basename(packageDir);
return base.length > 0 ? base : undefined;
}
function readRuntimeIdShortName(runtimeId: string | undefined): string | undefined {
const value = readTrimmed(runtimeId);
if (!value) return undefined;
return value
.replace(/^RUNTIME-/i, '')
.replace(/^HOST-/i, '')
.replace(/^LOCAL-/i, '');
}
function normalizeRuntimeTitleName(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/^@[^/]+\//, '')
.replace(/@[^@/]+$/, '')
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'runtime';
}
async function waitForPort(
port: number,
timeoutMs: number,
child: ChildProcessWithoutNullStreams,
getLogs: () => string,
getSpawnError?: () => Error | null,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const spawnError = getSpawnError?.();
if (spawnError) {
throw new Error(`Failed to spawn nats-server: ${spawnError.message}`);
}
if (child.exitCode != null) {
throw new Error(`nats-server exited early (${child.exitCode}): ${getLogs()}`);
}
const listening = await new Promise<boolean>((resolve) => {
const socket = net.connect({ host: '127.0.0.1', port });
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('error', () => {
socket.destroy();
resolve(false);
});
socket.setTimeout(500, () => {
socket.destroy();
resolve(false);
});
});
if (listening) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Timed out waiting for nats-server on port ${port}: ${getLogs()}`);
}
async function stopChildProcess(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode != null) {
return;
}
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (child.exitCode == null) {
child.kill('SIGKILL');
}
}, 2000);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
child.kill('SIGTERM');
});
}
async function startNatsServer(port: number, baseDir: string): Promise<IRunningNatsServer> {
const binaryPath = resolveNatsBinary(baseDir);
const child = spawn(binaryPath, ['-a', '127.0.0.1', '-p', String(port), '-js'], {
stdio: 'pipe',
env: process.env,
windowsHide: true,
});
let spawnError: Error | null = null;
let stderr = '';
child.once('error', (error: Error) => {
spawnError = error;
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
await waitForPort(port, 10_000, child, () => {
if (spawnError) {
return `${spawnError.message}${stderr ? ` | ${stderr.trim()}` : ''}`;
}
return stderr.trim();
}, () => spawnError);
return {
port,
async stop(): Promise<void> {
await stopChildProcess(child);
},
};
}
function localTopicToNatsSubject(topic: string, root: string, mount: string): string | null {
if (topic === `${mount}/$events`) {
return `${root}.${mount}.$events`;
}
if (topic.startsWith('$replies.')) {
return `${root}.$reply.${topic.slice('$replies.'.length)}`;
}
if (topic.startsWith(`${root}.$reply.`)) {
return topic;
}
return null;
}
function decodeNatsPayload(
data: Uint8Array,
jsonCodec: { decode(payload: Uint8Array): unknown },
stringCodec: { decode(payload: Uint8Array): string },
): unknown {
try {
return jsonCodec.decode(data);
} catch {
const text = stringCodec.decode(data);
try {
return JSON.parse(text);
} catch {
return text;
}
}
}
function pumpSubscription(
subscription: Subscription,
label: string,
onMessage: (subject: string, payload: unknown) => void,
jsonCodec: { decode(payload: Uint8Array): unknown },
stringCodec: { decode(payload: Uint8Array): string },
): void {
void (async () => {
for await (const message of subscription) {
const payload = decodeNatsPayload(message.data, jsonCodec, stringCodec);
onMessage(message.subject, payload);
}
})().catch((error) => {
console.error(`[mx run] ${label} subscription failed: ${error}`);
});
}
export async function runCommand(
targetPath: string,
options: IRunCommandOptions,
): Promise<void> {
const resolvedPath = path.resolve(targetPath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Run target not found: ${resolvedPath}`);
}
const targetStats = fs.statSync(resolvedPath);
if (targetStats.isDirectory()) {
await runPackageCommand(resolvedPath, options);
return;
}
await runActorFileCommand(resolvedPath, assertActorFileRunOptions(options));
}
async function runPackageCommand(
packageDir: string,
options: IRunCommandOptions,
): Promise<void> {
// P1.65 — name the runtime process for ps/tasklist. Sourcing order:
// MATRIX_RUNTIME_KEY env → packageRef shortname → packageDir basename
// → MATRIX_RUNTIME_ID → 'runtime'.
setRuntimeProcessTitle({
runtimeKey: process.env.MATRIX_RUNTIME_KEY,
runtimeId: process.env.MATRIX_RUNTIME_ID,
packageDir,
});
const hasServiceManifest = packageDeclaresStandaloneFactory(packageDir);
const webapp = loadMatrixWebappManifest(packageDir);
if (!options.serve && !hasServiceManifest && !webapp) {
throw new Error(
`Package run target ${packageDir} does not declare a matrix.json runtime.factory. `
+ 'Use a package directory with runtime.factory metadata, or pass an actor file path with --mount.',
);
}
if (options.serve || (!hasServiceManifest && webapp)) {
await runStandaloneWebappPackage(packageDir, options, {
hasServiceManifest,
webapp,
serve: options.serve === true,
});
return;
}
const result = await serviceCommand(packageDir, {
check: options.check,
json: options.json,
env: options.env,
envFile: options.envFile,
entry: options.entry,
export: options.export,
root: options.root,
mount: options.mount,
config: options.config,
secrets: options.secrets,
overrides: options.overrides,
} satisfies IServiceCommandOptions);
if (!options.check) {
return;
}
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
console.log(`Validated run-in-place bootstrap for ${result.packageName}`);
console.log(`manifest: ${result.manifestPath}`);
console.log(`entry: ${result.entryPath}`);
console.log(`export: ${result.exportName}`);
}
async function runStandaloneWebappPackage(
packageDir: string,
options: IRunCommandOptions,
state: {
readonly hasServiceManifest: boolean;
readonly webapp: ReturnType<typeof loadMatrixWebappManifest>;
readonly serve: boolean;
},
): Promise<void> {
if (!state.webapp) {
throw new Error(`Package run target ${packageDir} does not declare matrix.json webapp metadata`);
}
const envName = options.env?.trim();
const envFile = options.envFile?.trim();
if (!envName) {
throw new Error('Package webapp runtime mode requires --env <name>');
}
const loadedEnvironment = envFile
? loadPackageEnvironmentFromPath(packageDir, envFile, envName)
: loadPackageEnvironment(packageDir, envName);
const root = options.root?.trim() || loadedEnvironment.environment.runtime.root;
const runnerTransport = resolveRunnerTransportPlan(packageDir, loadedEnvironment, root);
let serviceHandle: IStartedServiceCommand | undefined;
let runnerRuntimeHandle: IRunnerRuntimeHandle | undefined;
let webappServer: IStandaloneWebappServerHandle | undefined;
let runtimeRegistration: IRunnerRuntimeRegistration | undefined;
let webappAssetMount: string | undefined;
let heartbeatTimer: NodeJS.Timeout | undefined;
let runtimeReclaimAttempts = 0;
let lifecycleState: RunnerRuntimeLifecycleState = 'starting';
let requestRuntimeShutdown: (reason: string) => void = () => undefined;
const shutdownRequested = new Promise<string>((resolve) => {
requestRuntimeShutdown = resolve;
});
try {
if (state.hasServiceManifest) {
serviceHandle = await startServiceCommand(packageDir, {
env: envName,
envFile,
entry: options.entry,
export: options.export,
root: options.root,
mount: options.mount,
config: options.config,
secrets: options.secrets,
overrides: options.overrides,
} satisfies IServiceCommandOptions);
} else {
runnerRuntimeHandle = await createRunnerRuntimeHandle(root, runnerTransport);
registerHostHomeService(runnerRuntimeHandle.runtime, loadedEnvironment.environment.host?.matrixDir);
const runtimeId = resolveWebappRuntimeId(state.webapp, loadedEnvironment);
const runtimeMount = loadedEnvironment.environment.runtime.runtimeMount
?? process.env.MATRIX_RUNTIME_MOUNT
?? defaultRunnerRuntimeMount(runtimeId);
const controlMount = loadedEnvironment.environment.runtime.controlMount
?? process.env.MATRIX_RUNTIME_CONTROL_MOUNT
?? defaultRunnerControlMount(runtimeId);
await mountRunnerRuntimeControl({
runtime: runnerRuntimeHandle.runtime,
runtimeId,
runtimeMount,
controlMount,
startedAt: new Date().toISOString(),
getState: () => lifecycleState,
requestShutdown: (reason) => requestRuntimeShutdown(reason),
loadedPackage: {
packageRef: state.webapp.packageName,
},
});
if (state.serve) {
webappServer = await startStandaloneWebappServer({
loadedEnvironment,
webapp: state.webapp,
runnerTransport,
});
}
webappAssetMount = await mountMatrixHttpAssetEndpoint(runnerRuntimeHandle.runtime, state.webapp, runtimeId);
runtimeRegistration = await registerRunnerRuntime(
runnerRuntimeHandle.runtime,
buildWebappRuntimeManifest(state.webapp),
loadedEnvironment,
runnerTransport,
undefined,
runtimeMount,
controlMount,
buildRunnerWebappRouteMetadata(state.webapp, webappAssetMount, webappServer),
);
if (!options.check) {
const heartbeatRuntime = runnerRuntimeHandle.runtime;
heartbeatTimer = setInterval(() => {
void (async () => {
const heartbeat = await heartbeatRunnerRuntime(heartbeatRuntime, runtimeRegistration);
if (!heartbeat || heartbeat.known) {
runtimeReclaimAttempts = 0;
return;
}
runtimeReclaimAttempts += 1;
if (runtimeReclaimAttempts === 1 || runtimeReclaimAttempts % 10 === 0) {
console.error(
`[mx run] registry forgot runtime claim for ${heartbeat.runtimeId} (renewed=0); reclaiming`,
);
}
const reclaimed = await runtimeRegistration?.reclaim();
if (reclaimed) {
console.error(
`[mx run] runtime reclaim recovered for ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempt(s): ${reclaimed.inventoryClaimCount} inventory claims re-issued`,
);
runtimeReclaimAttempts = 0;
}
})().catch((error: unknown) => {
if (runtimeReclaimAttempts === 1 || runtimeReclaimAttempts % 10 === 0) {
console.error(
`[mx run] runtime reclaim failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
});
}, 10_000);
}
}
if (state.serve && serviceHandle) {
webappServer = await startStandaloneWebappServer({
loadedEnvironment,
webapp: state.webapp,
runnerTransport: serviceHandle?.result.runnerTransport ?? runnerTransport,
});
}
lifecycleState = 'live';
const buildServeResult = () => ({
packageName: state.webapp?.packageName,
packageDir,
environmentName: loadedEnvironment.envName,
environmentPath: loadedEnvironment.environmentPath,
root,
mode: state.serve ? 'standalone-webapp' : 'webapp-assets',
...(webappAssetMount ? { assetMount: webappAssetMount } : {}),
...(webappServer ? {
baseUrl: webappServer.baseUrl,
httpPort: webappServer.port,
bootstrapPath: webappServer.bootstrapPath,
natsWsPath: webappServer.natsWsPath,
} : {}),
runnerTransport: serviceHandle?.result.runnerTransport ?? runnerTransport,
...(serviceHandle ? { service: serviceHandle.result } : {}),
});
const shutdownAll = async (): Promise<void> => {
lifecycleState = 'stopping';
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = undefined;
}
if (webappServer) {
const current = webappServer;
webappServer = undefined;
await current.shutdown();
}
if (serviceHandle) {
const current = serviceHandle;
serviceHandle = undefined;
await current.shutdown();
}
if (runnerRuntimeHandle) {
const current = runnerRuntimeHandle;
runnerRuntimeHandle = undefined;
await deregisterRunnerRuntime(current.runtime, runtimeRegistration);
await current.shutdown();
}
lifecycleState = 'stopped';
};
if (options.check) {
const result = buildServeResult();
await shutdownAll();
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else if (state.serve) {
console.log(`Validated standalone webapp serve for ${state.webapp.packageName}`);
console.log(`base: ${result.baseUrl}`);
} else {
console.log(`Validated webapp asset runtime for ${state.webapp.packageName}`);
console.log(`asset mount: ${result.assetMount ?? '(service runtime)'}`);
}
return;
}
if (options.json) {
console.log(JSON.stringify(buildServeResult(), null, 2));
} else if (state.serve && webappServer) {
console.log(`[mx run] serving ${state.webapp.packageName} at ${webappServer.baseUrl}`);
console.log(`[mx run] bootstrap: ${webappServer.baseUrl}${webappServer.bootstrapPath}`);
console.log(`[mx run] nats-ws: ${webappServer.baseUrl}${webappServer.natsWsPath}`);
} else {
console.log(`[mx run] running ${state.webapp.packageName} web assets at ${webappAssetMount ?? '(service runtime)'}`);
}
const stopTriggers: Promise<void>[] = [
waitForShutdownSignal(shutdownAll),
shutdownRequested.then(async () => {
await shutdownAll();
}),
];
if (serviceHandle) {
stopTriggers.push(serviceHandle.shutdownRequested.then(async () => {
await shutdownAll();
}));
if (serviceHandle.transportClosed) {
stopTriggers.push(serviceHandle.transportClosed.then((closed) => {
throw new Error(formatRunnerRuntimeClosed(closed));
}));
}
}
if (runnerRuntimeHandle?.closed) {
stopTriggers.push(runnerRuntimeHandle.closed.then((closed) => {
throw new Error(formatRunnerRuntimeClosed(closed));
}));
}
await Promise.race(stopTriggers);
return;
} catch (error) {
lifecycleState = 'failed';
if (webappServer) {
await webappServer.shutdown().catch(() => undefined);
}
if (serviceHandle) {
await serviceHandle.shutdown().catch(() => undefined);
}
if (runnerRuntimeHandle) {
await deregisterRunnerRuntime(runnerRuntimeHandle.runtime, runtimeRegistration).catch(() => undefined);
await runnerRuntimeHandle.shutdown().catch(() => undefined);
}
throw error;
}
}
function registerHostHomeService(
runtime: IRunnerRuntimeHandle['runtime'],
matrixDir: string | undefined,
): void {
const hostHome = matrixDir?.trim();
if (hostHome) {
runtime.registerService('matrix-host-home', hostHome);
runtime.registerService('credential-store', new HostCredentialStore(new HostAuthStateStore(hostHome)));
}
}
async function mountMatrixHttpAssetEndpoint(
runtime: IRunnerRuntimeHandle['runtime'],
webapp: ILoadedMatrixWebappManifest,
runtimeId?: string,
): Promise<string> {
const assetMount = buildMatrixHttpAssetMount(webapp.appName, runtimeId);
if (!runtime.hasComponent(assetMount)) {
await runtime.createSupervised(MatrixHttpAssetEndpointActor as unknown as new () => MatrixActor, assetMount, {
assetEndpoint: {
packageName: webapp.packageName,
appName: webapp.appName,
distDir: webapp.distDir,
entryFile: webapp.entryFile,
},
});
}
return assetMount;
}
function buildRunnerWebappRouteMetadata(
webapp: ILoadedMatrixWebappManifest,
assetMount: string,
webappServer?: IStandaloneWebappServerHandle,
): IRunnerWebappRouteMetadata {
return {
appName: webapp.appName,
routePrefix: `/apps/${webapp.appName}/`,
...(webapp.displayName ? { displayName: webapp.displayName } : {}),
...(webapp.icon ? { icon: webapp.icon } : {}),
...(typeof webapp.navOrder === 'number' ? { navOrder: webapp.navOrder } : {}),
...(webapp.description ? { description: webapp.description } : {}),
...(webapp.shells && webapp.shells.length > 0 ? { shells: webapp.shells } : {}),
assetMount,
...(webappServer ? {
origin: webappServer.baseUrl,
port: webappServer.port,
} : {}),
};
}
function buildWebappRuntimeManifest(
webapp: NonNullable<ReturnType<typeof loadMatrixWebappManifest>>,
): ILoadedMatrixServiceManifest {
return {
packageDir: webapp.packageDir,
packageName: webapp.packageName,
manifestPath: webapp.manifestPath,
entryPath: path.join(webapp.distDir, webapp.entryFile),
exportName: 'webapp',
kind: 'factory',
overrides: {},
packageComponents: [],
serviceInstance: {
id: webapp.packageName,
packageName: webapp.packageName,
class: 'webapp',
rootKind: 'package-root',
autoStart: true,
registrationSource: 'matrix-service',
},
};
}
function resolveWebappRuntimeId(
webapp: NonNullable<ReturnType<typeof loadMatrixWebappManifest>>,
loadedEnvironment: ReturnType<typeof loadPackageEnvironmentFromPath>,
): string {
const runtimeId = loadedEnvironment.environment.runtime.runtimeId?.trim();
if (runtimeId) {
return runtimeId;
}
return webapp.packageName;
}
function actorConstructorFromExport(value: unknown): ActorConstructor | null {
if (typeof value !== 'function') {
return null;
}
return value as ActorConstructor;
}
function requireFunctionExport<T>(
moduleExports: Record<string, unknown>,
exportName: string,
): T {
const value = moduleExports[exportName];
if (typeof value !== 'function') {
throw new Error(`Expected function export ${exportName}`);
}
return value as T;
}
function requireObjectExport<T extends object>(
moduleExports: Record<string, unknown>,
exportName: string,
): T {
const value = moduleExports[exportName];
if (!value || typeof value !== 'object') {
throw new Error(`Expected object export ${exportName}`);
}
return value as T;
}
function assertActorFileRunOptions(options: IRunCommandOptions): IActorFileRunCommandOptions {
const mount = options.mount?.trim();
if (!mount) {
throw new Error(
'Actor-file mode requires --mount. '
+ 'For package execution use: mx run . --env <name>',
);
}
return {
mount,
root: options.root,
export: options.export,
broker: options.broker,
brokerPort: options.brokerPort,
federation: options.federation,
backboneUrl: options.backboneUrl,
props: options.props,
};
}
async function waitForShutdownSignal(onShutdown: () => Promise<void>): Promise<void> {
await new Promise<void>((resolve, reject) => {
let settled = false;
const complete = (error?: unknown) => {
if (settled) {
return;
}
settled = true;
process.off('SIGINT', handleSignal);
process.off('SIGTERM', handleSignal);
if (error) {
reject(error);
return;
}
resolve();
};
const handleSignal = () => {
void onShutdown()
.then(() => complete())
.catch((error) => complete(error));
};
process.on('SIGINT', handleSignal);
process.on('SIGTERM', handleSignal);
});
}
async function runActorFileCommand(
actorPath: string,
options: IActorFileRunCommandOptions,
): Promise<void> {
const root = options.root ?? 'standalone';
const mount = options.mount;
const exportName = options.export ?? 'default';
const props = options.props ? JSON.parse(options.props) : {};
// ── Step 1: Import actor class ──────────────────────────────────────
const moduleUrl = pathToFileURL(actorPath).href;
const mod = await import(moduleUrl) as Record<string, unknown>;
// Try named export, then default, then first export that looks like a class
let ActorClass = actorConstructorFromExport(mod[exportName]);
if (!ActorClass && exportName === 'default') {
// Try first exported class
for (const [key, val] of Object.entries(mod)) {
const candidate = actorConstructorFromExport(val);
if (candidate && key !== 'default') {
ActorClass = candidate;
console.log(`[mx run] Using export: ${key}`);
break;
}
}
}
if (!ActorClass) {
const available = Object.keys(mod).filter(k => actorConstructorFromExport(mod[k]));
throw new Error(
`No actor class found as export '${exportName}'. Available exports: ${available.join(', ') || '(none)'}`
);
}
// ── Step 2: Set up transport ────────────────────────────────────────
//
// Three modes:
// 1. InMemory only (default) — local actors talk, no external access
// 2. +broker — starts embedded NATS NATS on a port, CLI can connect
// 3. +federation — connects to NATS hub backbone, world can reach us
// Dynamic imports — import individual modules to avoid barrel hangs
const cwd = process.cwd();
async function importModule(relativePath: string): Promise<Record<string, unknown>> {
const full = path.resolve(cwd, relativePath);
return await import(pathToFileURL(full).href) as Record<string, unknown>;
}
const [brokerMod, transportMod, contextMod, runtimeMod] = await Promise.all([
importModule('src/transport/InMemoryBroker.ts'),
importModule('src/transport/InMemoryTransport.ts'),
importModule('src/engine/core/MatrixContext.ts'),
importModule('src/core/MockRuntime.ts'),
]);
const InMemoryBroker = requireFunctionExport<BrokerConstructor>(brokerMod, 'InMemoryBroker');
const InMemoryTransport = requireFunctionExport<TransportConstructor>(transportMod, 'InMemoryTransport');
const MatrixContext = requireObjectExport<MatrixContextFactory>(contextMod, 'MatrixContext');
const MockRuntime = requireFunctionExport<RuntimeConstructor>(runtimeMod, 'MockRuntime');
const broker = new InMemoryBroker();
const transport = new InMemoryTransport(broker, { name: 'mx-run' });
const runtime = new MockRuntime();
// ── Step 3: Optional embedded NATS NATS server ──────────────────────────────
let natsServer: IRunningNatsServer | null = null;
let natsClient: NatsConnection | null = null;
if (options.broker || options.federation) {
const port = options.brokerPort ?? 4222;
try {
const { connect, JSONCodec, StringCodec } = await import('nats');
const jsonCodec = JSONCodec();
const stringCodec = StringCodec();
natsServer = await startNatsServer(port, cwd);
console.log(`[mx run] NATS server listening on nats://127.0.0.1:${port}`);
// Bridge InMemoryBroker ↔ local NATS so actors are reachable via NATS
natsClient = await connect({
servers: `nats://127.0.0.1:${port}`,
name: `mx-run-${process.pid}`,
timeout: 5000,
});
const actorInbox = `${root}.${mount}.$inbox`;
pumpSubscription(
natsClient.subscribe(actorInbox),
actorInbox,
(_subject, payload) => {
broker.publish(`${mount}/$inbox`, payload);
},
jsonCodec,
stringCodec,
);
const rootInbox = `${root}.$inbox`;
pumpSubscription(
natsClient.subscribe(rootInbox),
rootInbox,
(_subject, payload) => {
broker.publish('$inbox', payload);
},
jsonCodec,
stringCodec,
);
// Hook actor replies — the actor publishes to the replyTo topic
const origPublish = broker.publish.bind(broker);
broker.publish = (topic: string, payload: unknown) => {
origPublish(topic, payload);
const subject = localTopicToNatsSubject(topic, root, mount);
if (subject) {
natsClient?.publish(subject, jsonCodec.encode(payload));
}
};
} catch (err) {
throw new Error(`Failed to start NATS server for mx run: ${String(err)}`);
}
}
// ── Step 4: Optional Federation ─────────────────────────────────────
if (options.federation) {
const backboneUrl = options.backboneUrl ?? 'nats://hub.example.com:4222';
try {
const fedPath = path.resolve(cwd, 'packages', 'federation', 'dist', 'src', 'index.js');
const fedMod = await import(pathToFileURL(fedPath).href) as Record<string, unknown>;
const FederationPeer = requireFunctionExport<FederationPeerConstructor>(fedMod, 'FederationPeer');
const ExactInstanceResolver = requireFunctionExport<ExactInstanceResolverConstructor>(fedMod, 'ExactInstanceResolver');
const createNatsTransport = requireFunctionExport<CreateNatsTransport>(fedMod, 'createNatsTransport');
const { connect } = await import('nats');
// Connect to backbone
const backboneClient = await connect({
servers: backboneUrl,
name: `mx-run-fed-${root}-${process.pid}`,
timeout: 10000,
});
console.log(`[mx run] Federation backbone connected: ${backboneUrl}`);
const backboneTransport = createNatsTransport(backboneClient);
if (!natsServer) {
throw new Error('Federation mode requires the embedded NATS server to be running');
}
const localNats = await connect({
servers: `nats://127.0.0.1:${options.brokerPort ?? 4222}`,
name: `mx-run-fed-local-${process.pid}`,
timeout: 3000,
});
const localBus = createNatsTransport(localNats);
const peer = new FederationPeer({
localRoot: root,
localBus,
instanceResolver: new ExactInstanceResolver(),
});
await peer.setBackbone(backboneTransport);
await peer.start();
console.log(`[mx run] FederationPeer active for root: ${root}`);
console.log(`[mx run] Actor reachable at: ${root}/${mount}`);
} catch (err) {
throw new Error(`Federation setup failed for mx run: ${String(err)}`);
}
}
// ── Step 5: Boot the actor ──────────────────────────────────────────
const context = MatrixContext.create(mount, transport, runtime);
const actor = new ActorClass();
await actor.initialize(context, ActorClass.name || 'Actor');
// Assign actor properties requested by the CLI.
for (const [key, value] of Object.entries(props)) {
Object.defineProperty(actor, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
const accepts = ActorClass.accepts
? Object.keys(ActorClass.accepts).join(', ')
: 'N/A';
console.log('');
console.log('╔════════════════════════════════════════════════╗');
console.log('║ mx run — Actor Live ║');
console.log('╠════════════════════════════════════════════════╣');
console.log(`║ Actor: ${ActorClass.name}`.padEnd(49) + '║');
console.log(`║ Mount: ${mount}`.padEnd(49) + '║');
console.log(`║ Root: ${root}`.padEnd(49) + '║');
console.log(`║ Accepts: ${accepts}`.padEnd(49) + '║');
if (options.broker || options.federation) {
console.log(`║ Broker: nats://127.0.0.1:${options.brokerPort ?? 4222}`.padEnd(49) + '║');
}
if (options.federation) {
console.log(`║ Federation: CONNECTED`.padEnd(49) + '║');
console.log(`║ URI: ${root}/${mount}`.padEnd(49) + '║');
}
console.log('╚════════════════════════════════════════════════╝');
console.log('');
if (options.broker) {
console.log(`Talk to this actor:`);
console.log(` matrix send ${mount} $introspect -r ${root}`);
console.log('');
}
if (options.federation) {
console.log(`From FlowPad or a federated client:`);
console.log(` flow().fromValue({}).viaRemote("${root}/${mount}", "introspect")`);
console.log('');
}
console.log('Press Ctrl+C to stop.');
// Keep process alive
await new Promise<void>(() => {
const shutdown = async () => {
console.log('\n[mx run] Shutting down...');
try {
if (natsClient && !natsClient.isClosed()) {
await natsClient.drain();
}
} catch {
// Best-effort shutdown only.
}
try {
await natsServer?.stop();
} catch {
// Best-effort shutdown only.
}
process.exit(0);
};
process.on('SIGINT', () => {
void shutdown();
});
});
}