/** * Runtime: orchestrates trading recipe config, scanner module, actions, DSL, risk, health. */ import type { RuntimeConfig } from "./runtime-config.js"; import type { AuditQueryArgs, AuditQueryResult } from "./audit-query.types.js"; import type { ExternalScannerIngestRequest, ExternalScannerIngestResult } from "../scanners/external-scanner-receiver.js"; import type { ScannerSupervisionSource } from "../scanners/supervision.js"; import type { McpTransport } from "../senpi/types.js"; import type { ScannerDataProviders } from "../scanners/engine/data-providers.js"; import { DslPlugin as DslPluginClass } from "../dsl/plugin/index.js"; import type { RuntimeHealthStatus, RuntimeSystemState } from "../health/types.js"; import type { ActionExecutionRecord, ActionFilter, ActionLatestState, ReadActionHistoryOptions } from "../actions/store/types.js"; export interface RuntimeOptions { stateDir: string; mcpTransport?: McpTransport; apiKey?: string; scannerProviders?: ScannerDataProviders; /** Stable UUID identifying this install (from the plugin registry). */ runtimeId?: string; /** * Unresolved config (raw, pre-`${VAR}`). Stamped into telemetry's recipe fingerprint and lifecycle * snapshot — never the resolved config, which would inline secrets. Absent → telemetry omits the * config hash/blob (it never blocks boot). */ unresolvedConfig?: Record; } export declare class Runtime { /** Not readonly: {@link Runtime.rebuildComponents} swaps it, which is the whole point. */ private config; private readonly options; private bus; private hookSystem; private stateManager; private positionIdRegistry; private scannerModule; private dslPlugin; private senpi; private healthStateModule; private domainEventSubscriber; /** Wallet under which this runtime's local event sink is registered; cleared on teardown. */ private eventSinkAddress; /** Lazy runtime identity builder; stamps per-trace identity at the signal.process seam. */ private buildTraceIdentity?; private traceIdentity?; private traceIdentityResolved; private started; private startedAt; /** Serial signal processing: queue and consumer so only one signal is handled at a time. */ private signalQueue; private signalQueueWait; private signalConsumerStopped; private signalConsumerDone; /** Disposer for this runtime's `runtime.queue_depth` gauge observation; set on start, called on stop. */ private queueDepthGaugeDispose; /** Disposer for this runtime's `dsl.active_positions` gauge observation; set on start, called on stop. */ private dslActivePositionsGaugeDispose; /** Interval emitting this runtime's liveness heartbeat; set on start, cleared on stop. */ private livenessHeartbeatTimer; private runActionsForSignal; private strategyConfig; /** * The supervision fact source this runtime was handed, kept so a rebuilt scanner module inherits * it. * * Every caller of {@link attachScannerSupervisionSource} sits in the plugin's boot and * hot-install paths, and an in-place update runs neither. A module built by a rebuild therefore * starts with no source and no way to be given one, which is not a cosmetic gap: liveness for * push-driven scanners is derived entirely from it. */ private supervisionSource; /** * The context the CURRENT recipe's actions hold as `this.context`. * * Retained so the configuration the trading path is actually using can be read back. An update's * report is a claim about what should be live; this is the thing that is, and the two are worth * being able to compare — the failure this guards against is a report that says `slots: 1` over a * strategy still entering three. */ private currentRuntimeContext; private actionRuntimeModule; /** Stored so we can call off() in stop() and avoid retaining Runtime via scanner EventEmitter. */ private onScannerRunComplete; private onScannerRunError; /** * Release both run listeners from the CURRENT scanner module. Idempotent; called on every * teardown (boot failure, rebuild, stop) so a stale module never retains the Runtime. */ private unwireScannerRunListeners; /** Set at the end of start()'s dependency phase; read by every build and rebuild. */ private componentDeps; /** Cached strategy id resolved from the runtime wallet on first {@link auditQuery} call. */ private cachedStrategyId; /** In-flight resolution promise; ensures concurrent first-burst callers reuse one `listStrategies`. */ private strategyIdResolution; /** Serializes reconcile vs the trade loop. One shared key "reconcile". */ private reconcileMutex; /** Deferred gate: consumer awaits this before touching slot counts / running actions. */ private reconcileGate; /** Whether the runtime has been requested to stop (stops the boot-reconcile retry loop). */ private reconcileStopped; /** NodeJS timeout handle for a pending reconcile retry; cleared in stop(). */ private reconcileRetryTimer; /** Set when the reconcile gate is resolved by a successful/immediate boot reconcile; used to evict the pre-gate backlog. NOT set on stop(). */ private gateResolvedAt; /** Count of stale (pre-gate-open) signals evicted; surfaced in logs. */ private staleSignalsEvicted; /** * Per-process UUID. Used as the synthetic session_key for direct-path * spans (and lets us group "all LLM calls from this runtime boot" in * telemetry). */ private readonly runtimeBootId; constructor(config: RuntimeConfig, options: RuntimeOptions); get dsl(): DslPluginClass | null; getHealthStatus(): Promise; getSystemState(): Promise; /** * Latest persisted snapshots for actions under this runtime's state directory * (optionally filtered by wallet address and/or action name). */ listActionLatestStates(filter?: ActionFilter): Promise; /** Full `latest.json` for one action instance, or null when missing. */ getActionLatestState(address: string, actionName: string): Promise; /** Rolling JSONL execution history for one action. */ getActionExecutionHistory(address: string, actionName: string, opts?: ReadActionHistoryOptions): Promise; /** * Accepts one external scanner ingest request for the runtime's configured * strategy. * * The runtime stays intentionally thin here: scanner-specific validation, * lifecycle emission, signal persistence, retained-context storage, and * retention all remain owned by the * scanner module so the ingest path cannot drift from normal scanner * behavior. */ ingestExternalScannerData(request: ExternalScannerIngestRequest): Promise; /** * Records a scaffold-reported tick failure for one of this runtime's push-driven * external scanners into run telemetry (no ingest). The address is the runtime's * own strategy address; the caller supplies only the configured scanner name. * Returns false for a not-yet-started runtime or an unknown/ineligible scanner — * this is a fire-and-forget telemetry seam off the `/errors` boundary. */ recordExternalScannerError(scannerName: string, error: { type: string; error_type?: string; message?: string; tick_id?: string; }): boolean; /** * Attaches the external-scanner supervision fact source to the scanner module, * which joins liveness/supervisor verdicts onto its health and state rows. */ attachScannerSupervisionSource(source: ScannerSupervisionSource): void; /** * Sub-millisecond in-memory read of the runtime's serial signal-queue * depth. Backs the `runtimes.items[].signalQueueDepth` gauge in * `GET /health` and the load-shedding signal documented in * runtime-phase-2-api-plan Section 9. Pure, sync, allocation-free. */ getSignalQueueDepth(): number; /** * Return the cached strategy id resolved from this runtime's wallet, or * `null` if {@link auditQuery} has not yet been called (or if the first * call ended in `NOT_FOUND`). * * This is an in-memory read — it never calls MCP. Callers that need a * guaranteed value should call {@link auditQuery} first; after a successful * call this method returns the same id synchronously. * * Used by the `/audit` HTTP handler to include `strategyId` in the response * without re-resolving via `listStrategies`. */ getStrategyId(): string | null; /** * Run an MCP `audit_query` through the runtime's persistent * {@link SenpiClient}, scoped to this runtime's strategy. * * On the first call the wallet is resolved to its strategy id via * `strategy_list` and the result is cached on the instance. A * per-instance in-flight Promise (`strategyIdResolution`) acts as a * mutex so concurrent first-burst callers fire only one * `listStrategies` request. * * `resource_type` and `resource_id` are owned by this method — * caller-supplied values for those keys are silently overwritten so * callers can never escape their own strategy scope. * * @param args Filters forwarded to MCP `audit_query`. * @param opts `signal` aborts the underlying MCP request. * @throws {@link AuditQueryError} * - `NOT_FOUND` — `listStrategies` returned zero matches. * - `UPSTREAM` — MCP failed (network, schema, auth). * - `ABORTED` — caller aborted via `opts.signal`. */ auditQuery(args: AuditQueryArgs, opts?: { signal?: AbortSignal; }): Promise; /** * Repopulate the PositionIdRegistry's confirmed entries at boot from persisted DSL * states. Best-effort: a failure here must not block runtime start. */ private rehydratePositionIdRegistry; /** * Cached wallet → strategy-id lookup. Concurrent callers share one * in-flight `listStrategies` promise; the cached id is reused across * subsequent calls for the lifetime of this Runtime instance (cleared * implicitly on stop() when the instance is discarded). */ private resolveStrategyId; start(): Promise; /** * Cancel exchange SLs and archive DSL state for the strategy, then log removal. */ /** @returns Count of active DSL positions archived during teardown. */ /** * Build every component derived from the recipe: scanners, actions, the DSL plugin, and the * health surface that holds references to all three. * * Called by `start()` and again by {@link rebuildComponents}. Deliberately does NOT create the * reconcile gate, launch the boot-reconcile loop, or call `initRisk` — those are once-per-runtime * and re-running them would re-block trading on an already-converged runtime, duplicate a * detached loop, and double-subscribe the bus respectively. */ private buildComponents; /** * Phase one of a build: everything the recipe determines, constructed but NOT installed on `this` * and NOT started. * * The split exists so a rebuild can do all of its fallible work while the OLD components are * still running. A recipe that names a scanner which will not construct, or an action class that * does not exist, throws here — with the live runtime untouched, still scanning and still holding * its stops. The alternative (the previous shape) tore everything down first and then discovered * the new config was unbuildable, leaving a funded wallet with an open book, no scanners and no * exit engine. * * Nothing in here may subscribe to the bus, start a timer, or touch disk. That is what makes it * safe to run twice over — once for the attempt, once more for a rollback. `DslPlugin.create` is * deliberately NOT called here for exactly that reason: it subscribes position handlers and * starts the monitor loop, so pre-building it would put two plugins on one bus, both ticking the * same positions. Its config is parsed here instead, which is where a bad preset actually throws. */ private constructComponents; /** * Phase two of a build: install the constructed components on `this`, then create the one * component that cannot be constructed without side effects. * * Everything fallible that COULD be done earlier already has been, in * {@link constructComponents}. What remains is assignment — which cannot throw — plus * `DslPlugin.create`, whose config this build already parsed and whose state manager, senpi * client and strategy-state map are all passed in (each of those resolvers returns immediately * when supplied). That is why the teardown window in {@link rebuildComponents} is narrow enough * to be worth taking, and why its rollback is a backstop rather than the plan. */ private commitComponents; /** * Register the scan-complete listener on the CURRENT scanner module. * * Separate from {@link buildComponents} because the listener is held against a specific module * instance: a rebuild replaces the module, so the old registration goes with it and a fresh one * has to be made against the new one. */ private wireScannerRunListener; /** * Launch the single serial signal consumer. Called ONCE, from `start()`. * * Deliberately not part of {@link wireScannerRunListener}: that has to run again on every rebuild, * because the listener is held against a specific scanner-module instance and the rebuild replaces * it. Launching the consumer alongside it started a SECOND infinite `runSignalConsumer()` loop on * every apply — nothing stopped the first, `signalConsumerStopped` is only set by `stop()`, and a * rollback made it three. * * Two things broke. `signalQueueWait` holds exactly one resolver, so whichever consumer parked * last owned the wake-up and `signalConsumerDone` tracked only the newest — `stop()` woke one * consumer and then awaited a different one's promise, hanging `runtime delete`, gateway shutdown * and SIGTERM forever on any runtime that had been updated once. And with a non-empty queue at * swap time both consumers ran concurrently: the slot check is read BEFORE the reconcile lock, so * two passes could each read `used = 0` and each open a position on a one-slot strategy. * * Serial signal processing is the invariant the whole trade path is built on; there must be * exactly one of these per runtime. */ private startSignalConsumer; /** * Swap this runtime onto a new recipe without restarting it or touching its state. * * Teardown is the reverse of the build, and both halves matter. The scan listener is released * before the module it is registered on; `scannerModule.stop()` clears its timers and awaits * in-flight runs; `dslPlugin.stop()` is AWAITED, because it releases its bus subscriptions and * drains the tick that may still be mid-flight at the venue — without that the outgoing monitor * keeps writing state while the incoming one is already ticking the same positions, and the two * hold independent StateManagers so nothing else serialises them. * * State on disk is untouched throughout: DSL position files, scanner stores and action history * all survive, which is the entire reason an update is worth having over a delete-and-recreate. */ rebuildComponents(config: RuntimeConfig): Promise; private rebuildComponentsLocked; /** * Install the freshly built components, serialized against the trade loop. * * The signal consumer runs every action pass inside * `reconcileMutex.withKeyLock("reconcile", () => this.runActionsForSignal(...))`, reading * `runActionsForSignal` at call time from inside the lock. Taking the same lock here means a * signal already in flight finishes against the OLD actions before the swap happens, and the next * one picks up the new ones — no pass can straddle the change. * * The lock is scoped to the SWAP and deliberately NOT to the teardown that precedes it. * `teardownComponentsForRebuild` awaits the DSL plugin's in-flight tick, and that tick takes this * very lock for its periodic reconcile (`runReconcile` is handed `reconcileSerialize`). Holding * the lock across the drain would deadlock the runtime — permanently, with an open book and * nothing managing it — because `KeyedMutex` is a plain FIFO and grants no reentrancy. * * Creating the DSL plugin inside the lock is safe for the mirror-image reason: `create()` starts * the monitor loop but does not await its first tick, so that tick queues behind this lock * instead of blocking the call that holds it. */ private swapInComponents; /** * Drop the scan listener, stop the scanner module, stop and release the DSL plugin. * * Shared by the rebuild and its rollback so the two cannot drift — the rollback runs it a second * time to clear whatever the failed commit had already installed, and every step is idempotent * for exactly that reason. * * Order matters: the listener comes off before the module it is registered on; * `scannerModule.stop()` clears its timers and awaits in-flight runs; `dslPlugin.stop()` is * AWAITED, because it releases its bus subscriptions and drains the tick that may still be * mid-flight at the venue. */ private teardownComponentsForRebuild; removeStrategy(address: string): Promise; /** Tear down DSL for the runtime strategy before deleting the runtime (gateway / CLI). */ teardownDslForRuntimeDeletion(): Promise; /** * Resolve the per-boot trace identity once. The envelope is immutable per boot, so build it * lazily and cache it. The build is guarded: a failing identity builder must never abort * signal processing. */ private resolveTraceIdentity; /** * Single consumer: process one scanner:run:complete at a time so slot counts stay correct. */ private runSignalConsumer; /** * Resolve the reconcile gate on a SUCCESSFUL reconcile (boot loop OR a periodic monitor * reconcile). Sets `gateResolvedAt` so stale-signal eviction works regardless of * which path resolves first. Idempotent: a no-op once the gate is resolved. NOT used by * stop() — stop() force-resolves via `gate.resolve()` WITHOUT setting `gateResolvedAt`, so a * shutdown never enables eviction (the consumer bails on `signalConsumerStopped` instead). */ private resolveReconcileGate; /** * Detached boot-reconcile loop: runs runReconcile repeatedly until success or until * the runtime is stopped. Resolves the reconcileGate on first success. * If dslPlugin is null, the gate resolves immediately (nothing to reconcile into). */ private runBootReconcileLoop; /** * Dispose the domain-event subscriber. Idempotent; shared by stop() and the failed-start * cleanup path in start(). */ private disposeSubscribers; stop(): Promise; private stopLocked; } //# sourceMappingURL=runtime.d.ts.map