134 lines
5.5 KiB
TypeScript
134 lines
5.5 KiB
TypeScript
|
|
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<RuntimeConfig>
|
||
|
|
/** 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<void>
|
||
|
|
}
|
||
|
|
|
||
|
|
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<Runtime> {
|
||
|
|
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<string, never> | ReturnType<MastraLlm['mastraAgents']> = {}
|
||
|
|
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() }
|
||
|
|
}
|