import { join } from 'node:path' import { Mastra } from '@mastra/core' import type { MastraModelConfig } from '@mastra/core/llm' import { LibSQLStore } from '@mastra/libsql' import { Approval, DecisionCase, Envelope, ExecutionPermit, ExecutionReceipt, Proposal, ResourceProfile } from '@vpp/domain' import { AuthorityService, CaseDeskService, EnvelopeService, FileEventBus, FileExportGateway, FsRepository, FsSnapshotStore, HUBEI_BID_PACK_PLACEHOLDER, HttpSkillClient, LedgerService, LineageRecorder, MemoryTimeSeriesStore, PendingApprovalSchema, PolicyEngine, RevenueScenarioSimulator, hubeiSpotBiddingPack, } from '@vpp/services' import type { SkillClient } from '@vpp/services' import type { RuntimeConfig, RuntimeContext } from './context.js' import { AGENT_SPECS, MastraLlm, NullLlm } from './llm.js' import type { LlmPort } from './llm.js' import { createDayAheadBid } from './workflows/day-ahead-bid.js' import { createDayAheadSituation } from './workflows/day-ahead-situation.js' import { createProposalLifecycle } from './workflows/proposal-lifecycle.js' /** * Runtime = thin shell over Mastra + the self-built services (docs/09 ยง0). * `createRuntime` wires storage, workflows and agents; everything durable * lives under `dataDir` so a second `createRuntime` on the same directory * *is* a process restart for the purposes of the resumability tests. */ export interface RuntimeOptions { dataDir: string /** Router model id (e.g. 'openai/gpt-...'), an OpenAI-compatible config, or an LlmPort for tests; null = LLM down. */ llm: MastraModelConfig | LlmPort | null skills: SkillClient | string clock?: () => string config?: Partial /** Optional in-memory overrides for tests (time series is in-memory in phase 1 regardless). */ timeseries?: RuntimeContext['timeseries'] } export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { policyPackId: 'hubei-spot-bidding', approverRoles: ['senior-trader', 'ops-lead', 'risk-officer'], // OPEN-QUESTION B3 bidPermitTtlMs: 6 * 3_600_000, // OPEN-QUESTION A1 risk: { risk_aversion: '0.3', commitment_buffer_k: '0.9', min_block_mwh: '0.5', marginal_cost_yuan_per_mwh: '0' }, // B6 / A6 / A4 worstCaseLossBudgetYuan: '100000', // OPEN-QUESTION B5 forecastWindowDays: 28, extremeDayPriceRatio: '2.0', // OPEN-QUESTION B7 bidDeadlineLocal: '09:00', // OPEN-QUESTION A1 } export interface Runtime { mastra: Mastra ctx: RuntimeContext close(): Promise } const isLlmPort = (x: unknown): x is LlmPort => !!x && typeof x === 'object' && 'structured' in (x as object) && 'backend' in (x as object) export async function createRuntime(opts: RuntimeOptions): Promise { const config = { ...DEFAULT_RUNTIME_CONFIG, ...opts.config } const clock = opts.clock ?? (() => new Date().toISOString()) let seq = 0 const newId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${(++seq).toString(36)}-${Math.random().toString(36).slice(2, 6)}` const dir = (name: string) => join(opts.dataDir, name) const snapshots = new FsSnapshotStore(dir('snapshots')) const events = new FileEventBus(dir('events.jsonl'), clock) const ledger = new LedgerService({ daMonthlyDeviationBand: '0.05', clock }) // OPEN-QUESTION A5 const authority = new AuthorityService(new FsRepository(dir('permits.json'), ExecutionPermit, (p) => p.id, clock), () => newId('permit')) const policy = new PolicyEngine([hubeiSpotBiddingPack('2026.03', HUBEI_BID_PACK_PLACEHOLDER)]) const envelopes = new EnvelopeService(new FsRepository(dir('envelopes.json'), Envelope, (e) => e.id, clock)) const caseDesk = new CaseDeskService({ cases: new FsRepository(dir('cases.json'), DecisionCase, (c) => c.id, clock), proposals: new FsRepository(dir('proposals.json'), Proposal, (p) => p.id, clock), approvals: new FsRepository(dir('approvals.json'), Approval, (a) => a.id, clock), pending: new FsRepository(dir('pending.json'), PendingApprovalSchema, (p) => p.proposal_id, clock), authority, snapshots, events, clock, }) const gateway = new FileExportGateway(authority, dir('exports'), new FsRepository(dir('receipts.json'), ExecutionReceipt, (r) => r.idempotency_key, clock)) let llm: LlmPort let agents: Record | ReturnType = {} if (opts.llm === null) llm = new NullLlm() else if (isLlmPort(opts.llm)) llm = opts.llm else { const m = new MastraLlm(opts.llm, AGENT_SPECS) llm = m agents = m.mastraAgents() } const ctx: RuntimeContext = { config, clock, newId, snapshots, timeseries: opts.timeseries ?? new MemoryTimeSeriesStore(clock), resources: new FsRepository(dir('resources.json'), ResourceProfile, (r) => r.resource_id, clock), ledger, events, policy, envelopes, authority, gateway, simulation: new RevenueScenarioSimulator(), caseDesk, skills: typeof opts.skills === 'string' ? new HttpSkillClient(opts.skills) : opts.skills, lineage: new LineageRecorder(), llm, } const storage = new LibSQLStore({ id: 'vpp-runtime', url: `file:${dir('mastra.db')}` }) // Workflows are built per runtime so their steps close over *this* context; // module-level singletons would bind to whichever Mastra registered last. const proposalLifecycle = createProposalLifecycle(ctx) const mastra = new Mastra({ storage, workflows: { proposalLifecycle, dayAheadSituation: createDayAheadSituation(ctx), dayAheadBid: createDayAheadBid(ctx, proposalLifecycle) }, agents, }) return { mastra, ctx, close: () => storage.close() } }