vpp-ai-platform/packages/runtime/src/runtime.ts
Thomas Bayes 1cc21e0dc6
Some checks are pending
ci / typescript (push) Waiting to run
ci / python (push) Waiting to run
ci / evals (push) Blocked by required conditions
M3: Mastra runtime, safety chain, two agents, Case Desk v1
- packages/domain: safety-chain objects (ValidationResult, SimulationResult,
  EnvelopeMatch, StaleDenial, ExecutionReceipt, LineageRef/BidProposalDraft,
  RouterDecision, BidExportFile) + fixtures on both sides.
- packages/services: proposal digest; PolicyEngine + hubei-spot-bidding pack
  (digest-valid, bid-format, price-limits, quantity-non-negative,
  ledger-consistency, lineage-integrity, originator-permission — each with
  pass/fail tests); EnvelopeService; AuthorityService (fresh check, permits,
  revoke, gateway validate); FileExportGateway (idempotent receipts);
  Memory/File EventBus; LineageRecorder + P2 assembler; RevenueScenario
  simulator; CaseDeskService; FsRepository; skill HTTP client moved here.
- packages/runtime: createRuntime (LibSQL storage, per-runtime workflow
  factories), proposal-lifecycle (rule check → simulation → envelope gate
  with suspend/resume → fresh check + permit → release), day-ahead-situation,
  day-ahead-bid, TriggerService (scheduled/event/manual), LlmPort
  (Mastra/Scripted/Null), Case Desk HTTP API, dev entry point.
- Tests: all eight docs/01 invariants, docs/07 06:00→08:30 end to end with
  LLM down, restart survival of a suspended approval, permit expiry and
  revocation, replay of a released proposal, trigger scheduling. 141 TS +
  60 Python tests.
- Known gaps: ledger not yet persisted (replayed on restart); STALE ends the
  run instead of looping to rule check; synthetic data stands in for
  historical replay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 06:55:55 -04:00

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() }
}