/** * Start Server - Unified Server API * * Simple alternative to createServer + createServerPlugin pattern. * Auto-discovers slices from state and routes from plugins. */ import { Hono } from "hono"; import { cors } from "hono/cors"; import type { Runtime, Action, ExecutionStartedAction, ExecutionCompletedAction, ExecutionFailedAction, ExecutionAbortedAction, ExecutionInterruptedAction } from "@gizmo-ai/runtime"; import { isExecutionLifecycleAction } from "@gizmo-ai/runtime"; import type { RouteContext, ServerRoute, AuthConfig } from "./types.ts"; import type { ManifestConfig, Server } from "./types.ts"; import { createEventsRoute } from "./routes/events.ts"; import { createInvokeRoute } from "./routes/invoke.ts"; import { createAbortRoute } from "./routes/abort.ts"; import { createStateRoute } from "./routes/state.ts"; import { createAuthMiddleware } from "./middleware/auth.ts"; import { createRunsRoute } from "./routes/runs.ts"; import { createHydrateRoute } from "./routes/hydrate.ts"; import { createManifestRoute } from "./routes/manifest.ts"; import { createContractsRoute } from "./routes/contracts.ts"; import { createStreamStateRoute } from "./routes/stream-state.ts"; import { createStreamActionsRoute } from "./routes/stream-actions.ts"; import { safeSerialize } from "./lib/serialization.ts"; import { isSerializable } from "./lib/serialization.ts"; import { createServerPlugin } from "./plugin.ts"; import { emitStateChanged, emitExecutionLifecycle } from "./lib/event-bus.ts"; import type { ActionContract } from "@gizmo-ai/runtime"; import type { ExecutionLifecycleEvent } from "./types.ts"; import type { PluginManifestSchema } from "./manifest-schema.ts"; import { createSchemaRegistry } from "./schema-registry.ts"; /** * Configuration for startServer() */ export interface StartServerConfig { /** Port to listen on (default: 3001) */ port?: number; /** Heartbeat interval in ms (default: 30000) */ heartbeatInterval?: number; /** Base path for all routes (default: "") */ basePath?: string; /** Custom serializer for state (default: safe JSON serializer) */ serializer?: (value: unknown) => string; /** Optional manifest configuration for agent identity */ manifest?: ManifestConfig; /** State slices to broadcast (default: auto-discovered from state) */ slices?: string[]; /** Number of recent runs to store for history (default: 10) */ historySize?: number; /** Persistence configuration for JSONL storage */ persistence?: { enabled: boolean; baseDir?: string; }; /** Action contracts from installed plugins */ contracts?: ActionContract[]; /** Routes to mount (replaces plugin route auto-discovery) */ routes?: ServerRoute[]; /** Authentication configuration (optional, no auth by default) */ auth?: AuthConfig; /** * Plugin manifest schemas for static manifest generation. * When provided, the manifest uses these schemas instead of inferring from runtime state. */ schemas?: PluginManifestSchema[]; /** * Whether to infer state schema from runtime (default: false when schemas provided). * Set to true to always use runtime inference even when schemas are available. */ inferState?: boolean; } /** * Start a server for an Gizmo runtime with automatic route discovery * * This is the simplified API that: * - Auto-discovers state slices * - Auto-mounts plugin routes * - Creates server plugin automatically * * @example * ```typescript * import { createRuntime } from "@gizmo-ai/runtime"; * import { hitl, branching } from "@gizmo-ai/hitl-plugin"; * import { agent } from "@gizmo-ai/agent-plugin"; * import { startServer } from "@gizmo-ai/server"; * * const runtime = createRuntime({ * plugins: [ * hitl({ autoApprove: ['read', 'glob', 'grep'] }), * agent({ model, tools, systemMessage }), * branching(), * ], * }); * * // Routes auto-mounted from plugins: * // - /approvals (from hitl) * // - /branching (from branching) * const server = startServer(runtime, { port: 3001 }); * ``` */ export function startServer< S extends Record, A extends Action >( runtime: Runtime, config: StartServerConfig = {} ): Server { const { port = 3001, heartbeatInterval = 30000, basePath = "", serializer = safeSerialize, manifest: manifestConfig, slices: configuredSlices, historySize = 10, persistence, contracts = [], routes = [], auth, schemas = [], inferState, } = config; // Create schema registry from plugin schemas const schemaRegistry = schemas.length > 0 ? createSchemaRegistry(schemas) : undefined; // Create server plugin for event bus and state broadcasting const serverPlugin = createServerPlugin({ slices: configuredSlices, historySize, persistence, }); const { bus, slices, resolvedSlices } = serverPlugin; // Auto-discover slices from current state const state = runtime.getState(); const autoSlices = configuredSlices ?? Object.keys(state).filter( key => typeof state[key as keyof typeof state] !== 'function' ); // Subscribe to runtime actions to emit state changes to the bus // This is necessary because the server plugin's middleware isn't in the runtime's middleware chain let previousState = runtime.getState(); runtime.subscribeToActions((action) => { const currentState = runtime.getState(); // Check each slice for changes const slicesToCheck = configuredSlices ?? Object.keys(currentState).filter( key => isSerializable(currentState[key as keyof typeof currentState]) ); for (const sliceKey of slicesToCheck) { const before = previousState[sliceKey as keyof typeof previousState]; const after = currentState[sliceKey as keyof typeof currentState]; // Reference equality check if (before !== after) { emitStateChanged(bus, { slice: sliceKey, before, after, causingAction: action, }); } } // Emit execution lifecycle events const lifecycleEvent = mapActionToLifecycleEvent(action); if (lifecycleEvent) { emitExecutionLifecycle(bus, lifecycleEvent); } // Track run history and persistence in startServer mode. serverPlugin.recordAction(action, currentState); previousState = currentState; }); // Check if history is enabled const historyEnabled = historySize > 0 || persistence?.enabled === true; // Create Hono app const app = new Hono(); // Mount routes const prefix = basePath.replace(/\/$/, ""); // Add CORS middleware app.use("*", cors()); // Apply auth middleware if configured if (auth) { app.use("*", createAuthMiddleware(auth, prefix)); } // Stream routes app.route( `${prefix}/stream/state`, createStreamStateRoute({ runtime, bus, slices: autoSlices, resolvedSlices: () => resolvedSlices(runtime.getState()), heartbeatInterval, serializer, }) ); app.route( `${prefix}/stream/actions`, createStreamActionsRoute({ runtime, bus, heartbeatInterval, serializer, }) ); // Combined events stream (AG-UI compatible) app.route( `${prefix}/events`, createEventsRoute({ runtime, bus, slices: autoSlices, resolvedSlices: () => resolvedSlices(runtime.getState()), heartbeatInterval, serializer, }) ); // Standard routes app.route(`${prefix}/invoke`, createInvokeRoute(runtime)); app.route(`${prefix}/abort`, createAbortRoute(runtime)); app.route(`${prefix}/state`, createStateRoute(runtime, serializer)); app.route(`${prefix}/runs`, createRunsRoute({ getRunHistory: serverPlugin.getRunHistory, persistence: serverPlugin.persistence, runtime, })); app.route(`${prefix}/hydrate`, createHydrateRoute({ runtime, persistence: serverPlugin.persistence, })); // Health check app.get(`${prefix}/health`, (c) => c.json({ status: "ok" })); // Manifest endpoint app.route( `${prefix}/.well-known/manifest.json`, createManifestRoute({ runtime, manifestConfig, basePath: prefix, historyEnabled, routes, schemaRegistry, inferState, }) ); // Contracts endpoint if (contracts.length > 0) { app.route( `${prefix}/contracts`, createContractsRoute({ contracts }) ); } // Auto-mount plugin routes from runtime plugins const routeContext: RouteContext = { getState: runtime.getState as () => S, dispatch: runtime.dispatch as (action: Action) => void, subscribe: runtime.subscribe, }; const plugins = runtime.getPlugins(); for (const plugin of plugins) { if (plugin.routes) { const routePath = plugin.routes.path.startsWith('/') ? plugin.routes.path : `/${plugin.routes.path}`; app.route(`${prefix}${routePath}`, plugin.routes.createHandler(routeContext)); } } // Mount additional configured routes for (const route of routes) { const routePath = route.path.startsWith('/') ? route.path : `/${route.path}`; app.route(`${prefix}${routePath}`, route.createHandler(routeContext)); } // Server control let server: ReturnType | null = null; const listen = (listenPort: number) => { server = Bun.serve({ port: listenPort, fetch: app.fetch, idleTimeout: 255, // Max allowed by Bun }); console.log(`Gizmo server listening on http://localhost:${listenPort}`); }; const close = () => { if (server) { server.stop(); server = null; } }; // Start server immediately if port specified listen(port); return { app, listen, close, }; } /** * Map execution actions to lifecycle events */ function mapActionToLifecycleEvent(action: Action): ExecutionLifecycleEvent | null { if (!isExecutionLifecycleAction(action)) { return null; } switch (action.type) { case "RUNTIME_EXECUTION_STARTED": { const a = action as ExecutionStartedAction; return { status: "started", executionId: a.payload.executionId, turnId: a.payload.turnId, }; } case "RUNTIME_EXECUTION_COMPLETED": { const a = action as ExecutionCompletedAction; return { status: "completed", executionId: a.payload.executionId, }; } case "RUNTIME_EXECUTION_FAILED": { const a = action as ExecutionFailedAction; return { status: "failed", executionId: a.payload.executionId, error: a.payload.error, }; } case "RUNTIME_EXECUTION_ABORTED": { const a = action as ExecutionAbortedAction; return { status: "aborted", executionId: a.payload.executionId, }; } case "RUNTIME_EXECUTION_INTERRUPTED": { const a = action as ExecutionInterruptedAction; return { status: "interrupted", executionId: a.payload.executionId, turnId: a.payload.interruptedTurnId, }; } default: return null; } }