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