From 1cc21e0dc66346d09ce058bb6ed91f394bf514c6 Mon Sep 17 00:00:00 2001 From: Thomas Bayes Date: Wed, 2 Sep 2026 06:55:55 -0400 Subject: [PATCH] M3: Mastra runtime, safety chain, two agents, Case Desk v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA --- CLAUDE.md | 18 +- README.md | 33 +- .../bid_proposal_draft/literal-number.json | 13 + .../router_decision/unknown-template.json | 7 + contracts/fixtures/bid_export_file/basic.json | 213 ++ .../fixtures/bid_proposal_draft/basic.json | 16 + .../fixtures/envelope_match/outside.json | 10 + contracts/fixtures/envelope_match/within.json | 8 + .../execution_receipt/file-export.json | 9 + .../router_decision/day-ahead-bid.json | 7 + .../simulation_result/revenue-ok.json | 18 + .../fixtures/stale_denial/ledger-moved.json | 7 + .../fixtures/validation_result/pass.json | 16 + .../validation_result/reject-price.json | 20 + contracts/schema/bid_export_file.json | 110 + contracts/schema/bid_proposal_draft.json | 78 + contracts/schema/envelope_match.json | 54 + contracts/schema/execution_receipt.json | 51 + contracts/schema/router_decision.json | 43 + contracts/schema/simulation_result.json | 83 + contracts/schema/stale_denial.json | 31 + contracts/schema/validation_result.json | 83 + package-lock.json | 2410 ++++++++++++++++- packages/domain/scripts/make-fixtures.ts | 109 + packages/domain/src/approval.ts | 1 + packages/domain/src/case.ts | 1 + packages/domain/src/chain.ts | 119 + packages/domain/src/index.ts | 19 + packages/domain/src/proposal.ts | 4 + packages/evals/src/cli.ts | 2 +- packages/evals/src/harness.ts | 2 +- packages/evals/src/index.ts | 3 +- packages/evals/test/harness.test.ts | 2 +- packages/runtime/package.json | 27 + packages/runtime/src/api.ts | 68 + packages/runtime/src/context.ts | 61 + packages/runtime/src/index.ts | 9 + packages/runtime/src/llm.ts | 109 + packages/runtime/src/main.ts | 24 + packages/runtime/src/runtime.ts | 133 + packages/runtime/src/skills.ts | 33 + packages/runtime/src/trigger.ts | 133 + .../runtime/src/workflows/day-ahead-bid.ts | 167 ++ .../src/workflows/day-ahead-situation.ts | 137 + .../src/workflows/proposal-lifecycle.ts | 247 ++ packages/runtime/test/api.test.ts | 54 + packages/runtime/test/helpers.ts | 102 + packages/runtime/test/lifecycle.test.ts | 292 ++ packages/runtime/tsconfig.json | 8 + packages/services/package.json | 3 +- packages/services/src/authority.ts | 127 + packages/services/src/casedesk.ts | 180 ++ packages/services/src/digest.ts | 12 + packages/services/src/envelope.ts | 107 + packages/services/src/events.ts | 104 + packages/services/src/gateway.ts | 75 + packages/services/src/index.ts | 10 + packages/services/src/lineage.ts | 120 + packages/services/src/policy.ts | 210 ++ packages/services/src/relational.ts | 34 + packages/services/src/simulation.ts | 105 + .../client.ts => services/src/skillclient.ts} | 0 packages/services/test/chain.test.ts | 293 ++ packages/services/test/helpers.ts | 99 + skills-py/vpp_contracts/bid_export_file.py | 53 + skills-py/vpp_contracts/bid_proposal_draft.py | 41 + skills-py/vpp_contracts/envelope_match.py | 26 + skills-py/vpp_contracts/execution_receipt.py | 27 + skills-py/vpp_contracts/router_decision.py | 36 + skills-py/vpp_contracts/simulation_result.py | 36 + skills-py/vpp_contracts/stale_denial.py | 19 + skills-py/vpp_contracts/validation_result.py | 42 + 72 files changed, 6934 insertions(+), 29 deletions(-) create mode 100644 contracts/fixtures-invalid/bid_proposal_draft/literal-number.json create mode 100644 contracts/fixtures-invalid/router_decision/unknown-template.json create mode 100644 contracts/fixtures/bid_export_file/basic.json create mode 100644 contracts/fixtures/bid_proposal_draft/basic.json create mode 100644 contracts/fixtures/envelope_match/outside.json create mode 100644 contracts/fixtures/envelope_match/within.json create mode 100644 contracts/fixtures/execution_receipt/file-export.json create mode 100644 contracts/fixtures/router_decision/day-ahead-bid.json create mode 100644 contracts/fixtures/simulation_result/revenue-ok.json create mode 100644 contracts/fixtures/stale_denial/ledger-moved.json create mode 100644 contracts/fixtures/validation_result/pass.json create mode 100644 contracts/fixtures/validation_result/reject-price.json create mode 100644 contracts/schema/bid_export_file.json create mode 100644 contracts/schema/bid_proposal_draft.json create mode 100644 contracts/schema/envelope_match.json create mode 100644 contracts/schema/execution_receipt.json create mode 100644 contracts/schema/router_decision.json create mode 100644 contracts/schema/simulation_result.json create mode 100644 contracts/schema/stale_denial.json create mode 100644 contracts/schema/validation_result.json create mode 100644 packages/domain/src/chain.ts create mode 100644 packages/runtime/package.json create mode 100644 packages/runtime/src/api.ts create mode 100644 packages/runtime/src/context.ts create mode 100644 packages/runtime/src/index.ts create mode 100644 packages/runtime/src/llm.ts create mode 100644 packages/runtime/src/main.ts create mode 100644 packages/runtime/src/runtime.ts create mode 100644 packages/runtime/src/skills.ts create mode 100644 packages/runtime/src/trigger.ts create mode 100644 packages/runtime/src/workflows/day-ahead-bid.ts create mode 100644 packages/runtime/src/workflows/day-ahead-situation.ts create mode 100644 packages/runtime/src/workflows/proposal-lifecycle.ts create mode 100644 packages/runtime/test/api.test.ts create mode 100644 packages/runtime/test/helpers.ts create mode 100644 packages/runtime/test/lifecycle.test.ts create mode 100644 packages/runtime/tsconfig.json create mode 100644 packages/services/src/authority.ts create mode 100644 packages/services/src/casedesk.ts create mode 100644 packages/services/src/digest.ts create mode 100644 packages/services/src/envelope.ts create mode 100644 packages/services/src/events.ts create mode 100644 packages/services/src/gateway.ts create mode 100644 packages/services/src/lineage.ts create mode 100644 packages/services/src/policy.ts create mode 100644 packages/services/src/simulation.ts rename packages/{evals/src/client.ts => services/src/skillclient.ts} (100%) create mode 100644 packages/services/test/chain.test.ts create mode 100644 packages/services/test/helpers.ts create mode 100644 skills-py/vpp_contracts/bid_export_file.py create mode 100644 skills-py/vpp_contracts/bid_proposal_draft.py create mode 100644 skills-py/vpp_contracts/envelope_match.py create mode 100644 skills-py/vpp_contracts/execution_receipt.py create mode 100644 skills-py/vpp_contracts/router_decision.py create mode 100644 skills-py/vpp_contracts/simulation_result.py create mode 100644 skills-py/vpp_contracts/stale_denial.py create mode 100644 skills-py/vpp_contracts/validation_result.py diff --git a/CLAUDE.md b/CLAUDE.md index 22791b1..f4fd354 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,13 +6,17 @@ docs win — or write an ADR changing the doc first. ## Current phase -Docs complete; M1 and M2 implemented (schemas + contracts pipeline; ledger, -snapshot, time-series, relational, ingestion services; Python skill service -with forecast/MILP/report skills; L2 eval harness with committed baseline). -Storage is in-memory reference semantics — persistent adapters arrive with M3. -The eval dataset is synthetic until historical data lands. Next is M3. Build -order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric must -update the baseline in the same change (`npm run eval -w @vpp/evals -- --write-baseline`). +Docs complete; M1–M3 implemented: schemas + contracts pipeline; deterministic +services (ledger, stores, policy engine, envelope, authority/permits, gateway, +events, lineage assembler, simulation, Case Desk); Python skill service; L2 eval +harness with committed baseline (synthetic data); Mastra runtime with the +proposal-lifecycle safety chain (durable suspend/resume), situation and bid +workflows, trigger service, LLM port with LLM-down mode, Case Desk API. Storage +is file-backed reference semantics — Postgres/Timescale adapters later. Next is +M4. Build order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric +must update the baseline in the same change (`npm run eval -w @vpp/evals -- --write-baseline`). +Workflows are per-runtime factories (`createProposalLifecycle(ctx)` etc.) — never +module singletons, or a second Mastra instance steals their context. Do not start a milestone's work before its predecessor's acceptance criteria are testable, and do not build phase-2 items (edge control links, federation, interaction/load-control agents) unless explicitly asked. diff --git a/README.md b/README.md index da2e5c9..c3c2b69 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,28 @@ a deterministic safety chain (rule check → simulation → envelope/human appro execution permit) governs everything before any external effect. **The LLM never computes numbers and never touches the second-level control loop.** -**Status: M1 and M2 implemented.** Design docs 00–13 are complete; -implementation follows [ROADMAP.md](ROADMAP.md). Present today: domain schemas -(`packages/domain`), the TS↔Python contract pipeline (`contracts/`, -`skills-py/vpp_contracts`), ledger/snapshot/time-series/relational/ingestion -services (`packages/services`), the Python skill service — load/PV/price -forecasts with calibrated quantiles, bid-optimization MILP, report generator -(`skills-py/vpp_skills`) — and the L2 eval harness with a committed baseline -(`packages/evals`). The eval dataset is **synthetic** (no historical Hubei data -yet); baselines on it measure the harness, not the KPI. M3 (runtime, agents, -safety chain) is next. +**Status: M1–M3 implemented.** Design docs 00–13 are complete; implementation +follows [ROADMAP.md](ROADMAP.md). Present today: + +- `packages/domain` — zod schemas for every business and safety-chain object, exported to + `contracts/` and regenerated as pydantic models (`skills-py/vpp_contracts`). +- `packages/services` — deterministic services: ledger (with the P7 cascade), snapshot, + time-series, relational and ingestion stores; policy engine + `hubei-spot-bidding` pack; + envelope gate; authority (fresh check, permits, revocation); file-export gateway; + event store/bus; lineage recorder + P2 assembler; revenue-scenario simulation; Case Desk. +- `skills-py/vpp_skills` — Python skill service: load/PV/price forecasts with calibrated + quantiles, bid-optimization MILP, report generator. +- `packages/evals` — L2 eval harness with a committed baseline (on a **synthetic** dataset). +- `packages/runtime` — Mastra runtime: `proposal-lifecycle` (the safety chain, suspend/resume + for human approval, durable across restarts), `day-ahead-situation`, `day-ahead-bid`; + trigger service (scheduled / event / manual via router agent); provider-abstracted LLM + port with an LLM-down mode; Case Desk HTTP API. Bid release is a file export for manual + upload (degraded channel by design). + +All eight docs/01 invariants have automated tests (`packages/services/test/chain.test.ts`, +`packages/runtime/test/lifecycle.test.ts`). Storage is file-backed reference semantics; +Postgres/Timescale adapters and the programmatic trading-platform channel are later work. +M4 (resource agent, envelopes live, review loop) is next. ## Development @@ -26,6 +38,7 @@ pydantic models use `StrEnum` and PEP 604 unions). npm ci npm run check # typecheck, re-export contracts, run TS tests npm run eval -w @vpp/evals -- --check # L2 harness vs baseline (needs the skill service below) +npm run start -w @vpp/runtime # runtime + Case Desk API on :4100 (VPP_LLM_MODEL unset = LLM-down mode) cd skills-py uv venv --python 3.11 .venv && uv pip install -r requirements.txt # or python3.11 -m venv diff --git a/contracts/fixtures-invalid/bid_proposal_draft/literal-number.json b/contracts/fixtures-invalid/bid_proposal_draft/literal-number.json new file mode 100644 index 0000000..4ba3f40 --- /dev/null +++ b/contracts/fixtures-invalid/bid_proposal_draft/literal-number.json @@ -0,0 +1,13 @@ +{ + "market_date": "2026-03-15", + "prices_ref": { + "tool_call_id": "tc-001", + "path": "prices_yuan_per_mwh" + }, + "quantities_ref": { + "tool_call_id": "tc-001", + "path": "quantities_mwh" + }, + "expected_revenue_ref": "510600.00", + "rationale": "x" +} diff --git a/contracts/fixtures-invalid/router_decision/unknown-template.json b/contracts/fixtures-invalid/router_decision/unknown-template.json new file mode 100644 index 0000000..9ec9ac7 --- /dev/null +++ b/contracts/fixtures-invalid/router_decision/unknown-template.json @@ -0,0 +1,7 @@ +{ + "workflow_id": "transfer-funds", + "params": { + "market_date": "2026-03-15" + }, + "confidence": "HIGH" +} diff --git a/contracts/fixtures/bid_export_file/basic.json b/contracts/fixtures/bid_export_file/basic.json new file mode 100644 index 0000000..6e5767f --- /dev/null +++ b/contracts/fixtures/bid_export_file/basic.json @@ -0,0 +1,213 @@ +{ + "format_version": "1.0.0", + "proposal_id": "prop-001", + "proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "permit_id": "permit-001", + "proposal_type": "BID", + "market_date": "2026-03-15", + "prices_yuan_per_mwh": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00", + "0.00" + ] + }, + "quantities_mwh": { + "interval_minutes": 15, + "date": "2026-03-15", + "values": [ + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5", + "12.5" + ] + }, + "exported_at": "2026-03-14T08:30:00Z" +} diff --git a/contracts/fixtures/bid_proposal_draft/basic.json b/contracts/fixtures/bid_proposal_draft/basic.json new file mode 100644 index 0000000..42bf945 --- /dev/null +++ b/contracts/fixtures/bid_proposal_draft/basic.json @@ -0,0 +1,16 @@ +{ + "market_date": "2026-03-15", + "prices_ref": { + "tool_call_id": "tc-001", + "path": "prices_yuan_per_mwh" + }, + "quantities_ref": { + "tool_call_id": "tc-001", + "path": "quantities_mwh" + }, + "expected_revenue_ref": { + "tool_call_id": "tc-001", + "path": "expected_revenue_yuan" + }, + "rationale": "Evening peak priced above P50; allocate bounded energy to 18:00–20:00." +} diff --git a/contracts/fixtures/envelope_match/outside.json b/contracts/fixtures/envelope_match/outside.json new file mode 100644 index 0000000..467deb0 --- /dev/null +++ b/contracts/fixtures/envelope_match/outside.json @@ -0,0 +1,10 @@ +{ + "proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "within": false, + "envelope_id": null, + "required_level": "L2", + "reasons": [ + "no ACTIVE envelope covers BID/DAY_AHEAD for pool-hubei-01" + ], + "checked_at": "2026-03-14T08:30:00Z" +} diff --git a/contracts/fixtures/envelope_match/within.json b/contracts/fixtures/envelope_match/within.json new file mode 100644 index 0000000..a2f5eb1 --- /dev/null +++ b/contracts/fixtures/envelope_match/within.json @@ -0,0 +1,8 @@ +{ + "proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "within": true, + "envelope_id": "env-bid-001", + "required_level": "L1", + "reasons": [], + "checked_at": "2026-03-14T08:30:00Z" +} diff --git a/contracts/fixtures/execution_receipt/file-export.json b/contracts/fixtures/execution_receipt/file-export.json new file mode 100644 index 0000000..5239b99 --- /dev/null +++ b/contracts/fixtures/execution_receipt/file-export.json @@ -0,0 +1,9 @@ +{ + "receipt_id": "rcpt-001", + "proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "permit_id": "permit-001", + "channel": "FILE_EXPORT", + "idempotency_key": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:permit-001", + "artifact_ref": "exports/bid-2026-03-15-prop-001.json", + "accepted_at": "2026-03-14T08:30:00Z" +} diff --git a/contracts/fixtures/router_decision/day-ahead-bid.json b/contracts/fixtures/router_decision/day-ahead-bid.json new file mode 100644 index 0000000..937c66a --- /dev/null +++ b/contracts/fixtures/router_decision/day-ahead-bid.json @@ -0,0 +1,7 @@ +{ + "workflow_id": "day-ahead-bid", + "params": { + "market_date": "2026-03-15" + }, + "confidence": "HIGH" +} diff --git a/contracts/fixtures/simulation_result/revenue-ok.json b/contracts/fixtures/simulation_result/revenue-ok.json new file mode 100644 index 0000000..fa46744 --- /dev/null +++ b/contracts/fixtures/simulation_result/revenue-ok.json @@ -0,0 +1,18 @@ +{ + "proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kind": "REVENUE_SCENARIOS", + "alert": false, + "alerts": [], + "metrics": { + "revenue_p05_yuan": "410200.00", + "revenue_p50_yuan": "510600.00", + "worst_loss_yuan": "0.00" + }, + "scenario_count": 1000, + "simulator": { + "name": "revenue-scenarios", + "version": "1.0.0", + "seed": 20260315 + }, + "simulated_at": "2026-03-14T08:30:00Z" +} diff --git a/contracts/fixtures/stale_denial/ledger-moved.json b/contracts/fixtures/stale_denial/ledger-moved.json new file mode 100644 index 0000000..664beee --- /dev/null +++ b/contracts/fixtures/stale_denial/ledger-moved.json @@ -0,0 +1,7 @@ +{ + "proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reasons": [ + "ledger version 42 at approval, now 43" + ], + "checked_at": "2026-03-15T23:59:59Z" +} diff --git a/contracts/fixtures/validation_result/pass.json b/contracts/fixtures/validation_result/pass.json new file mode 100644 index 0000000..3a9ac64 --- /dev/null +++ b/contracts/fixtures/validation_result/pass.json @@ -0,0 +1,16 @@ +{ + "proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "policy_pack": { + "id": "hubei-spot-bidding", + "version": "2026.03" + }, + "ok": true, + "violations": [], + "rules_evaluated": [ + "bid-format", + "price-limits", + "ledger-consistency", + "lineage-integrity" + ], + "checked_at": "2026-03-14T08:30:00Z" +} diff --git a/contracts/fixtures/validation_result/reject-price.json b/contracts/fixtures/validation_result/reject-price.json new file mode 100644 index 0000000..dd1871b --- /dev/null +++ b/contracts/fixtures/validation_result/reject-price.json @@ -0,0 +1,20 @@ +{ + "proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "policy_pack": { + "id": "hubei-spot-bidding", + "version": "2026.03" + }, + "ok": false, + "violations": [ + { + "rule_id": "price-limits", + "severity": "REJECT", + "message": "price 1500.00 above cap" + } + ], + "rules_evaluated": [ + "bid-format", + "price-limits" + ], + "checked_at": "2026-03-14T08:30:00Z" +} diff --git a/contracts/schema/bid_export_file.json b/contracts/schema/bid_export_file.json new file mode 100644 index 0000000..ba1cac8 --- /dev/null +++ b/contracts/schema/bid_export_file.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "format_version": { + "type": "string", + "const": "1.0.0" + }, + "proposal_id": { + "type": "string", + "minLength": 1 + }, + "proposal_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "permit_id": { + "type": "string", + "minLength": 1 + }, + "proposal_type": { + "type": "string", + "enum": [ + "BID", + "INVITATION", + "CONTROL_PLAN", + "DISPATCH_PLAN" + ] + }, + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "prices_yuan_per_mwh": { + "type": "object", + "properties": { + "interval_minutes": { + "type": "number", + "const": 15 + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "values": { + "minItems": 96, + "maxItems": 96, + "type": "array", + "items": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + } + } + }, + "required": [ + "interval_minutes", + "date", + "values" + ], + "additionalProperties": false + }, + "quantities_mwh": { + "type": "object", + "properties": { + "interval_minutes": { + "type": "number", + "const": 15 + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "values": { + "minItems": 96, + "maxItems": 96, + "type": "array", + "items": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + } + } + }, + "required": [ + "interval_minutes", + "date", + "values" + ], + "additionalProperties": false + }, + "exported_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + } + }, + "required": [ + "format_version", + "proposal_id", + "proposal_digest", + "permit_id", + "proposal_type", + "market_date", + "prices_yuan_per_mwh", + "quantities_mwh", + "exported_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/bid_export_file.json", + "title": "BidExportFile" +} diff --git a/contracts/schema/bid_proposal_draft.json b/contracts/schema/bid_proposal_draft.json new file mode 100644 index 0000000..c7f5ec8 --- /dev/null +++ b/contracts/schema/bid_proposal_draft.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "prices_ref": { + "type": "object", + "properties": { + "tool_call_id": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tool_call_id", + "path" + ], + "additionalProperties": false + }, + "quantities_ref": { + "type": "object", + "properties": { + "tool_call_id": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tool_call_id", + "path" + ], + "additionalProperties": false + }, + "expected_revenue_ref": { + "type": "object", + "properties": { + "tool_call_id": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tool_call_id", + "path" + ], + "additionalProperties": false + }, + "rationale": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "market_date", + "prices_ref", + "quantities_ref", + "expected_revenue_ref", + "rationale" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/bid_proposal_draft.json", + "title": "BidProposalDraft" +} diff --git a/contracts/schema/envelope_match.json b/contracts/schema/envelope_match.json new file mode 100644 index 0000000..3b3af93 --- /dev/null +++ b/contracts/schema/envelope_match.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "proposal_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "within": { + "type": "boolean" + }, + "envelope_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "required_level": { + "type": "string", + "enum": [ + "L1", + "L2", + "L3" + ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "checked_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + } + }, + "required": [ + "proposal_digest", + "within", + "envelope_id", + "required_level", + "reasons", + "checked_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/envelope_match.json", + "title": "EnvelopeMatch" +} diff --git a/contracts/schema/execution_receipt.json b/contracts/schema/execution_receipt.json new file mode 100644 index 0000000..9cdf27b --- /dev/null +++ b/contracts/schema/execution_receipt.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "receipt_id": { + "type": "string", + "minLength": 1 + }, + "proposal_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "permit_id": { + "type": "string", + "minLength": 1 + }, + "channel": { + "type": "string", + "enum": [ + "FILE_EXPORT", + "TRADING_PLATFORM_API", + "SIMULATION_GATEWAY" + ] + }, + "idempotency_key": { + "type": "string", + "minLength": 1 + }, + "artifact_ref": { + "type": "string", + "minLength": 1 + }, + "accepted_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + } + }, + "required": [ + "receipt_id", + "proposal_digest", + "permit_id", + "channel", + "idempotency_key", + "artifact_ref", + "accepted_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/execution_receipt.json", + "title": "ExecutionReceipt" +} diff --git a/contracts/schema/router_decision.json b/contracts/schema/router_decision.json new file mode 100644 index 0000000..8b82bcb --- /dev/null +++ b/contracts/schema/router_decision.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "workflow_id": { + "type": "string", + "enum": [ + "day-ahead-situation", + "day-ahead-bid", + "adhoc-analysis" + ] + }, + "params": { + "type": "object", + "properties": { + "market_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + "required": [ + "market_date" + ], + "additionalProperties": false + }, + "confidence": { + "type": "string", + "enum": [ + "HIGH", + "MEDIUM", + "LOW" + ] + } + }, + "required": [ + "workflow_id", + "params", + "confidence" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/router_decision.json", + "title": "RouterDecision" +} diff --git a/contracts/schema/simulation_result.json b/contracts/schema/simulation_result.json new file mode 100644 index 0000000..b6c44f5 --- /dev/null +++ b/contracts/schema/simulation_result.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "proposal_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "kind": { + "type": "string", + "enum": [ + "REVENUE_SCENARIOS", + "POWER_BALANCE" + ] + }, + "alert": { + "type": "boolean" + }, + "alerts": { + "type": "array", + "items": { + "type": "string" + } + }, + "metrics": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + } + }, + "scenario_count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "simulator": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "seed": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "name", + "version", + "seed" + ], + "additionalProperties": false + }, + "simulated_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + } + }, + "required": [ + "proposal_digest", + "kind", + "alert", + "alerts", + "metrics", + "scenario_count", + "simulator", + "simulated_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/simulation_result.json", + "title": "SimulationResult" +} diff --git a/contracts/schema/stale_denial.json b/contracts/schema/stale_denial.json new file mode 100644 index 0000000..5942dff --- /dev/null +++ b/contracts/schema/stale_denial.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "proposal_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "reasons": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "checked_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + } + }, + "required": [ + "proposal_digest", + "reasons", + "checked_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/stale_denial.json", + "title": "StaleDenial" +} diff --git a/contracts/schema/validation_result.json b/contracts/schema/validation_result.json new file mode 100644 index 0000000..abf777e --- /dev/null +++ b/contracts/schema/validation_result.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "proposal_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "policy_pack": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "version" + ], + "additionalProperties": false + }, + "ok": { + "type": "boolean" + }, + "violations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rule_id": { + "type": "string", + "minLength": 1 + }, + "severity": { + "type": "string", + "enum": [ + "REJECT", + "WARN" + ] + }, + "message": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "rule_id", + "severity", + "message" + ], + "additionalProperties": false + } + }, + "rules_evaluated": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "checked_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + } + }, + "required": [ + "proposal_digest", + "policy_pack", + "ok", + "violations", + "rules_evaluated", + "checked_at" + ], + "additionalProperties": false, + "$id": "https://vpp-ai-platform/contracts/validation_result.json", + "title": "ValidationResult" +} diff --git a/package-lock.json b/package-lock.json index 79d4371..77eeb39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,195 @@ "packages/*" ] }, + "node_modules/@a2a-js/sdk-v0_3": { + "name": "@a2a-js/sdk", + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.14.tgz", + "integrity": "sha512-F6Ew1AtPzCLhTn8h9yiqTe7DiDf6XVrSnq9V1YqSl9eWqPm6anMveTiKdCSb/76cW0YiJc24rNaUrVezFFHbqQ==", + "license": "Apache-2.0", + "dependencies": { + "uuid": "^11.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.10.2", + "@grpc/grpc-js": "^1.11.0", + "express": "^4.21.2 || ^5.1.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + }, + "@grpc/grpc-js": { + "optional": true + }, + "express": { + "optional": true + } + } + }, + "node_modules/@a2a-js/sdk-v1": { + "name": "@a2a-js/sdk", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-1.0.1.tgz", + "integrity": "sha512-CJQdh3Wzwo8qIx5UUkSJ7+7BEI16PB+MXMHHNSmx8JQsQed2HlQgvx1ENOiKUfYA3PlcEvxIwv14dBblhDuPmw==", + "license": "Apache-2.0", + "dependencies": { + "jose": "^6.2.3", + "uuid": "^11.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.10.2", + "@grpc/grpc-js": "^1.11.0", + "express": "^4.21.2 || ^5.1.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + }, + "@grpc/grpc-js": { + "optional": true + }, + "express": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz", + "integrity": "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils-v5": { + "name": "@ai-sdk/provider-utils", + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.30.tgz", + "integrity": "sha512-NCJ9JKow5ENAgEZxzvEvF20thwDiH+hutvzmrUDbloRX0azpJHNst8+7pZIVryYhLM9wgpT5/ShTSjPTFhkxEQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v6": { + "name": "@ai-sdk/provider-utils", + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.40.tgz", + "integrity": "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.14", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v6/node_modules/@ai-sdk/provider": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.14.tgz", + "integrity": "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils-v7": { + "name": "@ai-sdk/provider-utils", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.13.tgz", + "integrity": "sha512-fScDJMDnTbx32kLDQqp0MvPjvwkgiwvlBxlmIg7XW5PbS91LG6JjH3PQG+34oMFglqfpQA355e24OdGj5PPoDw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.4", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v7/node_modules/@ai-sdk/provider": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.4.tgz", + "integrity": "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-v5": { + "name": "@ai-sdk/provider", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz", + "integrity": "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-v6": { + "name": "@ai-sdk/provider", + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.14.tgz", + "integrity": "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-v7": { + "name": "@ai-sdk/provider", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.4.tgz", + "integrity": "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -451,6 +640,15 @@ "node": ">=18" } }, + "node_modules/@isaacs/ttlcache": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.5.tgz", + "integrity": "sha512-VwGZqqjAWPICTmxUZnbpEfO60LhPWzquik+bmyXGY7pYRn6diEvCI5i6Ca+J6o2y4vS73HrpuMTo2dOvUevH8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -458,6 +656,289 @@ "dev": true, "license": "MIT" }, + "node_modules/@libsql/client": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.4.tgz", + "integrity": "sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.17.4", + "@libsql/hrana-client": "^0.10.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.28", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.4.tgz", + "integrity": "sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz", + "integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mastra/core": { + "version": "1.63.2", + "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.63.2.tgz", + "integrity": "sha512-BHVDF4GtQnIqRND/lPVVoTnmoNzZ7+voz+EpI4YSY0W7In6c/EOrmMnshuNQSDJ9NpxQNrwip0WGYpRYwzFirQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@a2a-js/sdk-v0_3": "npm:@a2a-js/sdk@~0.3.14", + "@a2a-js/sdk-v1": "npm:@a2a-js/sdk@~1.0.1", + "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.30", + "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.40", + "@ai-sdk/provider-utils-v7": "npm:@ai-sdk/provider-utils@5.0.13", + "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.3", + "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.14", + "@ai-sdk/provider-v7": "npm:@ai-sdk/provider@4.0.4", + "@isaacs/ttlcache": "^2.1.5", + "@lukeed/uuid": "^2.0.1", + "@mastra/schema-compat": "1.3.7", + "@modelcontextprotocol/server": "2.0.0", + "@sindresorhus/slugify": "^2.2.1", + "@standard-schema/spec": "^1.1.0", + "ajv": "^8.20.0", + "chat": "^4.34.0", + "croner": "^10.0.1", + "dotenv": "^17.3.1", + "execa": "^9.6.1", + "fastq": "^1.20.1", + "gray-matter": "^4.0.3", + "ignore": "^7.0.5", + "jpeg-js": "^0.4.4", + "json-schema": "^0.4.0", + "lru-cache": "^11.2.7", + "p-map": "^7.0.4", + "p-retry": "^7.1.1", + "picomatch": "^4.0.3", + "posthog-node": "^5.46.1", + "tokenx": "^1.3.0", + "ws": "^8.21.0", + "xxhash-wasm": "^1.1.0" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@mastra/libsql": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/@mastra/libsql/-/libsql-1.22.2.tgz", + "integrity": "sha512-Nr21NY1VhalnpVAkD3tl/LpsBOD6nXrt5si0cR3K2be+rAUWXH6ncBs+KijEfugINDwl9JkseEwpMncYXjhWZQ==", + "license": "Apache-2.0", + "dependencies": { + "@libsql/client": "^0.17.4" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@mastra/core": ">=1.63.1-0 <2.0.0-0" + } + }, + "node_modules/@mastra/schema-compat": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@mastra/schema-compat/-/schema-compat-1.3.7.tgz", + "integrity": "sha512-06WfY+j9rulYnDp8CM7pL7JIyrntkMolqmyyb1VJNEzlYTQCIcJcY0SwQiubZv2tztmoYGikszDiRVyXp0E9gA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema-to-zod": "^2.7.0", + "zod-from-json-schema": "^0.5.2" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -475,6 +956,27 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, + "node_modules/@posthog/core": { + "version": "1.50.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", + "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.407.1" + } + }, + "node_modules/@posthog/types": { + "version": "1.407.2", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.2.tgz", + "integrity": "sha512-F/x/qlZSGiianCEZlZ9XRYHUYffH07Zv+1oOllxAw15ifObagxSZzc2IneZE01eFqPl2OmoNuY86ER5Uyvy8Zw==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -825,6 +1327,61 @@ "win32" ] }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/slugify": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", + "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/transliterate": "^1.0.0", + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", + "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -836,6 +1393,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -850,16 +1416,45 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.4.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -983,10 +1578,45 @@ "resolved": "packages/evals", "link": true }, + "node_modules/@vpp/runtime": { + "resolved": "packages/runtime", + "link": true + }, "node_modules/@vpp/services": { "resolved": "packages/services", "link": true }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -997,6 +1627,16 @@ "node": ">=12" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1007,6 +1647,16 @@ "node": ">=8" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1024,6 +1674,56 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chat": { + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/chat/-/chat-4.39.0.tgz", + "integrity": "sha512-o7D+oAbQAYFVoc9+OH8wya11609ybRThNTF3s/5C1LAABGGi6IrxoQQEN/aIe+4h5pmKYzUjXeqTcuOZcpIAZA==", + "license": "MIT", + "dependencies": { + "@workflow/serde": "4.1.0-beta.2", + "mdast-util-to-string": "^4.0.0", + "remark-gfm": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "remend": "^1.2.1", + "unified": "^11.0.5" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "ai": "^6.0.182 || ^7.0.0", + "workflow": "^5.0.0-beta.35", + "zod": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + }, + "workflow": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/chat/node_modules/@workflow/serde": { + "version": "4.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", + "integrity": "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==", + "license": "Apache-2.0" + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -1034,11 +1734,43 @@ "node": ">= 16" } }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1058,6 +1790,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1068,6 +1813,49 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1117,6 +1905,31 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1127,6 +1940,41 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1137,6 +1985,55 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1155,6 +2052,21 @@ } } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1170,6 +2082,139 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, + "node_modules/js-base64": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz", + "integrity": "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1177,6 +2222,91 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-to-zod": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.8.1.tgz", + "integrity": "sha512-fRr1mHgZ7hboLKBUdR428gd9dIHUFGivUqOeiDcSmyXkNZCtB1uGaZLvsjZ4GaN5pwBIs+TGIOf6s+Rp5/R/zA==", + "license": "ISC", + "bin": { + "json-schema-to-zod": "dist/cjs/cli.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1184,6 +2314,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1194,11 +2333,772 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1220,6 +3120,82 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", + "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1248,9 +3224,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1287,6 +3261,121 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-node": { + "version": "5.51.6", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.51.6.tgz", + "integrity": "sha512-r+Ge3p0OnOcOWeTnvKZmAtDwECeIoyNTyxBxuZc8wM6RHCQ9j2Xrhogrf39HSq1Y2gIOpWLqIDRmWDcu7xmqcA==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.50.0" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remend": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.1.tgz", + "integrity": "sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==", + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -1333,6 +3422,40 @@ "fsevents": "~2.3.2" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1340,6 +3463,18 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1350,6 +3485,12 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -1364,6 +3505,27 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -1438,6 +3600,22 @@ "node": ">=14.0.0" } }, + "node_modules/tokenx": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tokenx/-/tokenx-1.6.0.tgz", + "integrity": "sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==", + "license": "MIT" + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tsx": { "version": "4.23.13", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", @@ -1475,9 +3653,135 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -1650,6 +3954,21 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1667,15 +3986,74 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-from-json-schema": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/zod-from-json-schema/-/zod-from-json-schema-0.5.6.tgz", + "integrity": "sha512-U33AJ7ZWS6y9XNSzMWcdy8hRAvZmWhTtpYJu0SXPT5AArbc9nq2ur7Magzmn5RF9KBV4b3FP0nCpmqqlfXlR9w==", + "license": "MIT", + "dependencies": { + "zod": "^4.0.17" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "packages/domain": { "name": "@vpp/domain", "version": "0.1.0", @@ -1703,12 +4081,30 @@ "vitest": "^3.0.0" } }, + "packages/runtime": { + "name": "@vpp/runtime", + "version": "0.1.0", + "dependencies": { + "@mastra/core": "^1.63.2", + "@mastra/libsql": "^1.22.2", + "@vpp/domain": "*", + "@vpp/services": "*", + "zod": "^4.1.0" + }, + "devDependencies": { + "@types/node": "^26.4.1", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^3.0.0" + } + }, "packages/services": { "name": "@vpp/services", "version": "0.1.0", "dependencies": { "@vpp/domain": "*", - "decimal.js-light": "^2.5.1" + "decimal.js-light": "^2.5.1", + "zod": "^4.1.0" }, "devDependencies": { "@types/node": "^26.4.1", diff --git a/packages/domain/scripts/make-fixtures.ts b/packages/domain/scripts/make-fixtures.ts index 121c81c..fa7358a 100644 --- a/packages/domain/scripts/make-fixtures.ts +++ b/packages/domain/scripts/make-fixtures.ts @@ -248,6 +248,101 @@ const valid: Record> = { generated_at: T1, }, }, + validation_result: { + pass: { + proposal_digest: REF_B, + policy_pack: { id: 'hubei-spot-bidding', version: '2026.03' }, + ok: true, + violations: [], + rules_evaluated: ['bid-format', 'price-limits', 'ledger-consistency', 'lineage-integrity'], + checked_at: T1, + }, + 'reject-price': { + proposal_digest: REF_B, + policy_pack: { id: 'hubei-spot-bidding', version: '2026.03' }, + ok: false, + violations: [{ rule_id: 'price-limits', severity: 'REJECT', message: 'price 1500.00 above cap' }], + rules_evaluated: ['bid-format', 'price-limits'], + checked_at: T1, + }, + }, + simulation_result: { + 'revenue-ok': { + proposal_digest: REF_B, + kind: 'REVENUE_SCENARIOS', + alert: false, + alerts: [], + metrics: { revenue_p05_yuan: '410200.00', revenue_p50_yuan: '510600.00', worst_loss_yuan: '0.00' }, + scenario_count: 1000, + simulator: { name: 'revenue-scenarios', version: '1.0.0', seed: 20260315 }, + simulated_at: T1, + }, + }, + envelope_match: { + within: { + proposal_digest: REF_B, + within: true, + envelope_id: 'env-bid-001', + required_level: 'L1', + reasons: [], + checked_at: T1, + }, + outside: { + proposal_digest: REF_B, + within: false, + envelope_id: null, + required_level: 'L2', + reasons: ['no ACTIVE envelope covers BID/DAY_AHEAD for pool-hubei-01'], + checked_at: T1, + }, + }, + stale_denial: { + 'ledger-moved': { + proposal_digest: REF_B, + reasons: ['ledger version 42 at approval, now 43'], + checked_at: T2, + }, + }, + execution_receipt: { + 'file-export': { + receipt_id: 'rcpt-001', + proposal_digest: REF_B, + permit_id: 'permit-001', + channel: 'FILE_EXPORT', + idempotency_key: `${REF_B}:permit-001`, + artifact_ref: 'exports/bid-2026-03-15-prop-001.json', + accepted_at: T1, + }, + }, + bid_proposal_draft: { + basic: { + market_date: DATE, + prices_ref: { tool_call_id: 'tc-001', path: 'prices_yuan_per_mwh' }, + quantities_ref: { tool_call_id: 'tc-001', path: 'quantities_mwh' }, + expected_revenue_ref: { tool_call_id: 'tc-001', path: 'expected_revenue_yuan' }, + rationale: 'Evening peak priced above P50; allocate bounded energy to 18:00–20:00.', + }, + }, + router_decision: { + 'day-ahead-bid': { + workflow_id: 'day-ahead-bid', + params: { market_date: DATE }, + confidence: 'HIGH', + }, + }, + bid_export_file: { + basic: { + format_version: '1.0.0', + proposal_id: 'prop-001', + proposal_digest: REF_B, + permit_id: 'permit-001', + proposal_type: 'BID', + market_date: DATE, + prices_yuan_per_mwh: curve('0.00'), + quantities_mwh: curve('12.5'), + exported_at: T1, + }, + }, situation_report: { 'normal-day': { id: 'sit-001', @@ -333,6 +428,20 @@ const invalid: Record> = { return o })(), }, + bid_proposal_draft: { + // a literal number where only a lineage reference is allowed (I1/P2) + 'literal-number': { + market_date: DATE, + prices_ref: { tool_call_id: 'tc-001', path: 'prices_yuan_per_mwh' }, + quantities_ref: { tool_call_id: 'tc-001', path: 'quantities_mwh' }, + expected_revenue_ref: '510600.00', + rationale: 'x', + }, + }, + router_decision: { + // a workflow id that is not a registered template + 'unknown-template': { workflow_id: 'transfer-funds', params: { market_date: DATE }, confidence: 'HIGH' }, + }, position_update: { // missing optimistic-concurrency field 'missing-version': (() => { diff --git a/packages/domain/src/approval.ts b/packages/domain/src/approval.ts index 6974fe3..eeb3900 100644 --- a/packages/domain/src/approval.ts +++ b/packages/domain/src/approval.ts @@ -3,6 +3,7 @@ import { DecimalString, Id, IsoUtc, SnapshotRef } from './common.js' import { ProposalType } from './proposal.js' export const ApprovalLevel = z.enum(['L1', 'L2', 'L3']) +export type ApprovalLevel = z.infer /** Binds to an exact proposal digest and validity window; stale when evidence changes (docs/03 §2.1). */ export const Approval = z.object({ diff --git a/packages/domain/src/case.ts b/packages/domain/src/case.ts index 1a06875..d2b1053 100644 --- a/packages/domain/src/case.ts +++ b/packages/domain/src/case.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { Id, IsoUtc } from './common.js' export const CaseKind = z.enum(['DAY_AHEAD_BID', 'DR_EVENT', 'DEVIATION_RESOLUTION', 'REVIEW', 'ADHOC_ANALYSIS']) +export type CaseKind = z.infer /** * Operator's unit of accountability (docs/02 §5, ADR-0008): one objective, diff --git a/packages/domain/src/chain.ts b/packages/domain/src/chain.ts new file mode 100644 index 0000000..88de7a5 --- /dev/null +++ b/packages/domain/src/chain.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' +import { Curve96, DecimalString, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js' +import { ApprovalLevel } from './approval.js' +import { ProposalType } from './proposal.js' + +/** + * Safety-chain objects (docs/03, docs/11 §2): the outputs of rule check, + * simulation, envelope gate and gateway. All are snapshotted into lineage so a + * decision can be replayed (I7). + */ + +export const PolicyPackRef = z.object({ + id: Id, + version: z.string().min(1), +}) +export type PolicyPackRef = z.infer + +export const RuleViolation = z.object({ + rule_id: Id, + severity: z.enum(['REJECT', 'WARN']), + message: z.string().min(1), +}) + +export const ValidationResult = z.object({ + proposal_digest: SnapshotRef, + policy_pack: PolicyPackRef, + ok: z.boolean(), + violations: z.array(RuleViolation), + rules_evaluated: z.array(Id), + checked_at: IsoUtc, +}) +export type ValidationResult = z.infer + +/** Revenue scenario backtest (bids) or power-balance sim (control) — docs/03 §2. */ +export const SimulationResult = z.object({ + proposal_digest: SnapshotRef, + kind: z.enum(['REVENUE_SCENARIOS', 'POWER_BALANCE']), + /** Any alert escalates to a human — one vote, no exceptions (docs/03 §2). */ + alert: z.boolean(), + alerts: z.array(z.string()), + metrics: z.record(z.string(), DecimalString), + scenario_count: z.int().nonnegative(), + simulator: z.object({ name: z.string().min(1), version: z.string().min(1), seed: z.int() }), + simulated_at: IsoUtc, +}) +export type SimulationResult = z.infer + +export const EnvelopeMatch = z.object({ + proposal_digest: SnapshotRef, + within: z.boolean(), + envelope_id: Id.nullable(), + required_level: ApprovalLevel, + reasons: z.array(z.string()), + checked_at: IsoUtc, +}) +export type EnvelopeMatch = z.infer + +export const StaleDenial = z.object({ + proposal_digest: SnapshotRef, + reasons: z.array(z.string().min(1)).min(1), + checked_at: IsoUtc, +}) +export type StaleDenial = z.infer + +/** Idempotent effect receipt (I5). Same (digest, permit) → same receipt. */ +export const ExecutionReceipt = z.object({ + receipt_id: Id, + proposal_digest: SnapshotRef, + permit_id: Id, + channel: z.enum(['FILE_EXPORT', 'TRADING_PLATFORM_API', 'SIMULATION_GATEWAY']), + idempotency_key: z.string().min(1), + artifact_ref: z.string().min(1), + accepted_at: IsoUtc, +}) +export type ExecutionReceipt = z.infer + +/** Reference into recorded tool-call lineage (I1/P2): the only way a number enters a payload. */ +export const LineageRef = z.object({ + tool_call_id: Id, + path: z.string().min(1), +}) +export type LineageRef = z.infer + +/** + * What the trading agent's LLM step is allowed to emit (docs/09 §3): refs and + * prose, never numbers. The assembler dereferences refs into a BidPayload. + */ +export const BidProposalDraft = z.object({ + market_date: MarketDate, + prices_ref: LineageRef, + quantities_ref: LineageRef, + expected_revenue_ref: LineageRef, + rationale: z.string().min(1), +}) +export type BidProposalDraft = z.infer + +/** Router agent output (docs/09 §1): choose a registered template, never invent one. */ +export const WorkflowTemplateId = z.enum(['day-ahead-situation', 'day-ahead-bid', 'adhoc-analysis']) + +export const RouterDecision = z.object({ + workflow_id: WorkflowTemplateId, + params: z.object({ market_date: MarketDate }), + confidence: z.enum(['HIGH', 'MEDIUM', 'LOW']), +}) +export type RouterDecision = z.infer + +/** Bid file handed to the manual-upload channel (docs/06 degraded channel; M3 release path). */ +export const BidExportFile = z.object({ + format_version: z.literal('1.0.0'), + proposal_id: Id, + proposal_digest: SnapshotRef, + permit_id: Id, + proposal_type: ProposalType, + market_date: MarketDate, + prices_yuan_per_mwh: Curve96, + quantities_mwh: Curve96, + exported_at: IsoUtc, +}) +export type BidExportFile = z.infer diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index eca63a1..1e481e6 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -15,6 +15,16 @@ import { ReportRequest, SkillReport, } from './skill.js' +import { + BidExportFile, + BidProposalDraft, + EnvelopeMatch, + ExecutionReceipt, + RouterDecision, + SimulationResult, + StaleDenial, + ValidationResult, +} from './chain.js' export * from './common.js' export * from './proposal.js' @@ -27,6 +37,7 @@ export * from './resource.js' export * from './case.js' export * from './event.js' export * from './skill.js' +export * from './chain.js' /** * Registry driving the contracts pipeline: keys become schema/fixture/module @@ -50,4 +61,12 @@ export const schemaRegistry: Record = { bid_optimization_result: BidOptimizationResult, report_request: ReportRequest, skill_report: SkillReport, + validation_result: ValidationResult, + simulation_result: SimulationResult, + envelope_match: EnvelopeMatch, + stale_denial: StaleDenial, + execution_receipt: ExecutionReceipt, + bid_proposal_draft: BidProposalDraft, + router_decision: RouterDecision, + bid_export_file: BidExportFile, } diff --git a/packages/domain/src/proposal.ts b/packages/domain/src/proposal.ts index af42948..1bacb45 100644 --- a/packages/domain/src/proposal.ts +++ b/packages/domain/src/proposal.ts @@ -24,6 +24,7 @@ export const ProposalStatus = z.enum([ 'ROLLED_BACK', 'REJECTED', ]) +export type ProposalStatus = z.infer export const ToolCallRef = z.object({ tool_call_id: Id, @@ -32,6 +33,7 @@ export const ToolCallRef = z.object({ inputs_ref: SnapshotRef, outputs_ref: SnapshotRef, }) +export type ToolCallRef = z.infer /** * Provenance attached to every proposal (docs/03 §1). Rule check enforces that @@ -43,6 +45,7 @@ export const Lineage = z.object({ ledger_version: z.int().nonnegative(), policy_pack_version: z.string().min(1), }) +export type Lineage = z.infer /** Day-ahead bid: per-interval price/quantity pairs. */ export const BidPayload = z.object({ @@ -52,6 +55,7 @@ export const BidPayload = z.object({ quantities_mwh: Curve96, expected_revenue_yuan: DecimalString, }) +export type BidPayload = z.infer const ProposalBase = z.object({ id: Id, diff --git a/packages/evals/src/cli.ts b/packages/evals/src/cli.ts index 46cab84..fda56fd 100644 --- a/packages/evals/src/cli.ts +++ b/packages/evals/src/cli.ts @@ -9,7 +9,7 @@ */ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { HttpSkillClient } from './client.js' +import { HttpSkillClient } from '@vpp/services' import { loadDataset } from './dataset.js' import { DEFAULT_CONFIG, runL2, stableJson } from './harness.js' import type { EvalRun } from './harness.js' diff --git a/packages/evals/src/harness.ts b/packages/evals/src/harness.ts index 591f830..c836c8a 100644 --- a/packages/evals/src/harness.ts +++ b/packages/evals/src/harness.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import type { BidOptimizationResult, BidRiskParams, ForecastBundle, ForecastKind } from '@vpp/domain' import { Decimal, LedgerService } from '@vpp/services' -import type { SkillClient } from './client.js' +import type { SkillClient } from '@vpp/services' import type { Dataset, DatasetDay } from './dataset.js' import { nums, toCurve } from './dataset.js' import { diff --git a/packages/evals/src/index.ts b/packages/evals/src/index.ts index 32ac09c..41a034f 100644 --- a/packages/evals/src/index.ts +++ b/packages/evals/src/index.ts @@ -1,4 +1,5 @@ export * from './metrics.js' export * from './dataset.js' -export * from './client.js' +export { HttpSkillClient, SkillHttpError } from '@vpp/services' +export type { SkillClient } from '@vpp/services' export * from './harness.js' diff --git a/packages/evals/test/harness.test.ts b/packages/evals/test/harness.test.ts index b629021..614d8c2 100644 --- a/packages/evals/test/harness.test.ts +++ b/packages/evals/test/harness.test.ts @@ -9,7 +9,7 @@ import type { ReportRequest, SkillReport, } from '@vpp/domain' -import type { SkillClient } from '../src/client.js' +import type { SkillClient } from '@vpp/services' import { loadDataset } from '../src/dataset.js' import { DEFAULT_CONFIG, runL2 } from '../src/harness.js' diff --git a/packages/runtime/package.json b/packages/runtime/package.json new file mode 100644 index 0000000..9c8ce62 --- /dev/null +++ b/packages/runtime/package.json @@ -0,0 +1,27 @@ +{ + "name": "@vpp/runtime", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run", + "start": "tsx src/main.ts" + }, + "dependencies": { + "@mastra/core": "^1.63.2", + "@mastra/libsql": "^1.22.2", + "@vpp/domain": "*", + "@vpp/services": "*", + "zod": "^4.1.0" + }, + "devDependencies": { + "@types/node": "^26.4.1", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/runtime/src/api.ts b/packages/runtime/src/api.ts new file mode 100644 index 0000000..684723d --- /dev/null +++ b/packages/runtime/src/api.ts @@ -0,0 +1,68 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { z } from 'zod' +import { LlmUnavailable } from './llm.js' +import type { Runtime } from './runtime.js' +import type { TriggerService } from './trigger.js' +import { ResumeDecision } from './workflows/proposal-lifecycle.js' + +/** + * Case Desk v1 HTTP API (docs/02 §5, docs/11 §1.1 CaseDeskPort). Inbox, case + * view, lineage expansion, and the approve/reject endpoint that resumes the + * lifecycle run. Identity comes from the existing permission system + * (docs/06 §4) — here as trusted headers behind that system's gateway: + * x-user-id / x-user-role. The approver identity is what I1/I2 checks. + */ +const DecideBody = z.object({ decision: z.enum(['approve', 'reject']), comment: z.string().optional() }) +const TaskBody = z.object({ message: z.string().min(1) }) + +const json = (res: ServerResponse, status: number, body: unknown) => { + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +const readBody = (req: IncomingMessage): Promise => + new Promise((resolve, reject) => { + let data = '' + req.on('data', (c) => (data += c)) + req.on('end', () => { + try { + resolve(data ? JSON.parse(data) : {}) + } catch (e) { + reject(e) + } + }) + }) + +export function createApi(rt: Runtime, triggers: TriggerService): Server { + return createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost') + const parts = url.pathname.split('/').filter(Boolean) + const user = { id: req.headers['x-user-id'], role: req.headers['x-user-role'] } + try { + if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { status: 'ok', llm: rt.ctx.llm.backend }) + if (req.method === 'GET' && url.pathname === '/inbox') return json(res, 200, rt.ctx.caseDesk.inbox().filter((p) => p.run_id !== '')) + if (req.method === 'GET' && url.pathname === '/cases') return json(res, 200, rt.ctx.caseDesk.cases.list().map((r) => r.value)) + if (req.method === 'GET' && parts[0] === 'cases' && parts[1] && parts.length === 2) return json(res, 200, rt.ctx.caseDesk.read(parts[1])) + if (req.method === 'GET' && parts[0] === 'proposals' && parts[1] && parts[2] === 'lineage') return json(res, 200, rt.ctx.caseDesk.expandLineage(parts[1])) + if (req.method === 'GET' && url.pathname === '/events') return json(res, 200, rt.ctx.events.list(url.searchParams.get('correlation_id') ? { correlation_id: url.searchParams.get('correlation_id')! } : {})) + + if (req.method === 'POST' && parts[0] === 'approvals' && parts[1] && parts[2] === 'decide') { + if (typeof user.id !== 'string' || typeof user.role !== 'string') return json(res, 401, { error: 'x-user-id and x-user-role required' }) + const body = DecideBody.parse(await readBody(req)) + const decision = ResumeDecision.parse({ ...body, approver: { id: user.id, role: user.role } }) + const result = await triggers.decide(parts[1], decision) + return json(res, 200, { run_id: parts[1], status: result.status, outcome: result.status === 'success' ? result.result.outcome : null }) + } + if (req.method === 'POST' && url.pathname === '/tasks') { + const body = TaskBody.parse(await readBody(req)) + return json(res, 200, await triggers.manual(body.message)) + } + return json(res, 404, { error: 'not found' }) + } catch (e) { + if (e instanceof z.ZodError) return json(res, 400, { error: e.issues }) + if (e instanceof LlmUnavailable) return json(res, 503, { error: e.message }) + return json(res, 500, { error: (e as Error).message }) + } + }) +} diff --git a/packages/runtime/src/context.ts b/packages/runtime/src/context.ts new file mode 100644 index 0000000..462ce9d --- /dev/null +++ b/packages/runtime/src/context.ts @@ -0,0 +1,61 @@ +import type { + AuthorityService, + CaseDeskService, + EnvelopeService, + EventBus, + FileExportGateway, + LedgerService, + LineageRecorder, + PolicyEngine, + ResourceRegistry, + SimulationPort, + SkillClient, + SnapshotStore, + TimeSeriesStore, +} from '@vpp/services' +import type { LlmPort } from './llm.js' + +/** + * Everything a workflow step may touch. Deterministic services are plain + * objects imported by steps (docs/09 §7: never exposed to the LLM); the LLM + * port is the one non-deterministic dependency and is isolated behind + * `llm`. Workflows are created per runtime and close over their context, so + * two runtimes can coexist in one process — which is what the restart test does. + */ +export interface RuntimeConfig { + policyPackId: string + /** OPEN-QUESTION B3: which roles may approve; placeholder list. */ + approverRoles: string[] + /** OPEN-QUESTION A1: bid permit lifetime — until the market deadline. Placeholder. */ + bidPermitTtlMs: number + /** OPEN-QUESTION B6 / A6 / A4 placeholders passed to the optimizer. */ + risk: { risk_aversion: string; commitment_buffer_k: string; min_block_mwh: string; marginal_cost_yuan_per_mwh: string } + /** OPEN-QUESTION B5: worst-case loss budget for the simulation alert. Placeholder. */ + worstCaseLossBudgetYuan: string + /** History window (days) handed to forecast skills. */ + forecastWindowDays: number + /** OPEN-QUESTION B7: price-spike ratio (p90/p50) above which the day is EXTREME. Placeholder. */ + extremeDayPriceRatio: string + /** Bid deadline as HH:MM Asia/Shanghai on D-1 (OPEN-QUESTION A1); used for case deadlines. */ + bidDeadlineLocal: string +} + +export interface RuntimeContext { + config: RuntimeConfig + clock: () => string + newId: (prefix: string) => string + snapshots: SnapshotStore + timeseries: TimeSeriesStore + resources: ResourceRegistry + ledger: LedgerService + events: EventBus + policy: PolicyEngine + envelopes: EnvelopeService + authority: AuthorityService + gateway: FileExportGateway + simulation: SimulationPort + caseDesk: CaseDeskService + skills: SkillClient + lineage: LineageRecorder + llm: LlmPort +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts new file mode 100644 index 0000000..f555049 --- /dev/null +++ b/packages/runtime/src/index.ts @@ -0,0 +1,9 @@ +export * from './llm.js' +export * from './skills.js' +export * from './context.js' +export * from './runtime.js' +export * from './trigger.js' +export * from './api.js' +export * from './workflows/proposal-lifecycle.js' +export * from './workflows/day-ahead-situation.js' +export * from './workflows/day-ahead-bid.js' diff --git a/packages/runtime/src/llm.ts b/packages/runtime/src/llm.ts new file mode 100644 index 0000000..3d72268 --- /dev/null +++ b/packages/runtime/src/llm.ts @@ -0,0 +1,109 @@ +import { Agent } from '@mastra/core/agent' +import type { MastraModelConfig } from '@mastra/core/llm' +import type { z } from 'zod' + +/** + * LLM provider abstraction (P6, ADR-0003). Agent code programs against + * `LlmPort.structured()` — "give me an object of this schema" — never a vendor + * API. Backends: a Mastra Agent over a router model id (dev: commercial API; + * prod: the GuangMing OpenAI-compatible adapter), a scripted provider for + * tests, and the null provider that models an LLM outage. Every caller must + * survive `LlmUnavailable` (I6) — the deterministic fallback is part of the + * step, not an afterthought. + */ +export class LlmUnavailable extends Error { + constructor(reason: string) { + super(`LLM unavailable: ${reason}`) + this.name = 'LlmUnavailable' + } +} + +export interface LlmPort { + readonly backend: string + structured(agentId: string, prompt: string, schema: z.ZodType): Promise +} + +export interface AgentSpec { + id: string + name: string + instructions: string +} + +/** Real backend: one Mastra Agent per spec, all on the same routed model. */ +export class MastraLlm implements LlmPort { + readonly backend: string + private readonly agents = new Map() + + constructor(model: MastraModelConfig, specs: AgentSpec[], backend = 'mastra') { + this.backend = backend + for (const s of specs) this.agents.set(s.id, new Agent({ id: s.id, name: s.name, instructions: s.instructions, model })) + } + + mastraAgents(): Record { + return Object.fromEntries(this.agents) + } + + async structured(agentId: string, prompt: string, schema: z.ZodType): Promise { + const agent = this.agents.get(agentId) + if (!agent) throw new Error(`agent ${agentId} not registered`) + let res: { object?: unknown } + try { + res = await agent.generate(prompt, { structuredOutput: { schema: schema as never } }) + } catch (e) { + throw new LlmUnavailable((e as Error).message) + } + const parsed = schema.safeParse(res.object) + if (!parsed.success) throw new LlmUnavailable(`structured output failed schema: ${parsed.error.message}`) + return parsed.data + } +} + +/** LLM outage (or simply "no model configured"): every call fails fast. */ +export class NullLlm implements LlmPort { + readonly backend = 'null' + async structured(): Promise { + throw new LlmUnavailable('no LLM backend configured') + } +} + +/** Test backend: canned answers keyed by agent id, optionally a function of the prompt. */ +export class ScriptedLlm implements LlmPort { + readonly backend = 'scripted' + readonly prompts: Array<{ agentId: string; prompt: string }> = [] + constructor(private readonly answers: Record unknown)>) {} + + async structured(agentId: string, prompt: string, schema: z.ZodType): Promise { + this.prompts.push({ agentId, prompt }) + const a = this.answers[agentId] + if (a === undefined) throw new LlmUnavailable(`no scripted answer for ${agentId}`) + const raw = typeof a === 'function' ? (a as (p: string) => unknown)(prompt) : a + const parsed = schema.safeParse(raw) + if (!parsed.success) throw new LlmUnavailable(`scripted output failed schema: ${parsed.error.message}`) + return parsed.data + } +} + +/** The five agents (docs/02 §2). M3 wires three; interaction/load-control are phase 2. */ +export const AGENT_SPECS: AgentSpec[] = [ + { + id: 'router-agent', + name: 'Router', + instructions: + 'You route an operator request to exactly one registered workflow template and extract its parameters. ' + + 'You never invent workflows. If the request is ambiguous, choose adhoc-analysis with LOW confidence.', + }, + { + id: 'analysis-agent', + name: 'Analysis', + instructions: + 'You are the situational-awareness analyst for a virtual power plant. Given forecast bundles (already computed by tools), ' + + 'write short findings. Never state a number that is not in the provided tool outputs; cite snapshot refs.', + }, + { + id: 'trading-agent', + name: 'Trading', + instructions: + 'You draft day-ahead bid proposals. All numbers come from the bid-optimization tool output you are given; ' + + 'you reference them by {tool_call_id, path} and explain the strategy in one paragraph for the approver.', + }, +] diff --git a/packages/runtime/src/main.ts b/packages/runtime/src/main.ts new file mode 100644 index 0000000..5087918 --- /dev/null +++ b/packages/runtime/src/main.ts @@ -0,0 +1,24 @@ +/** + * Dev/shadow entry point: `npm run start -w @vpp/runtime`. + * VPP_DATA_DIR durable state (default ./data) + * VPP_SKILLS_URL Python skill service (default http://127.0.0.1:8000) + * VPP_LLM_MODEL Mastra router model id, e.g. openai/gpt-... ; unset = LLM down (I6 mode) + * VPP_API_PORT Case Desk API port (default 4100) + * Scheduled triggers (D-1 06:00 situation, 08:00 bid, Asia/Shanghai) run in-process. + */ +import { createApi } from './api.js' +import { createRuntime } from './runtime.js' +import { TriggerService } from './trigger.js' + +const rt = await createRuntime({ + dataDir: process.env['VPP_DATA_DIR'] ?? './data', + skills: process.env['VPP_SKILLS_URL'] ?? 'http://127.0.0.1:8000', + llm: process.env['VPP_LLM_MODEL'] ? (process.env['VPP_LLM_MODEL'] as `${string}/${string}`) : null, +}) +const triggers = new TriggerService(rt) +triggers.startEventConsumers() +triggers.startScheduler() +const port = Number(process.env['VPP_API_PORT'] ?? 4100) +createApi(rt, triggers).listen(port, () => { + console.log(`vpp runtime: api on :${port}, llm=${rt.ctx.llm.backend}, data=${process.env['VPP_DATA_DIR'] ?? './data'}`) +}) diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts new file mode 100644 index 0000000..12559d3 --- /dev/null +++ b/packages/runtime/src/runtime.ts @@ -0,0 +1,133 @@ +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() } +} diff --git a/packages/runtime/src/skills.ts b/packages/runtime/src/skills.ts new file mode 100644 index 0000000..1592701 --- /dev/null +++ b/packages/runtime/src/skills.ts @@ -0,0 +1,33 @@ +import type { ToolCallRef } from '@vpp/domain' +import type { LineageRecorder, SnapshotStore } from '@vpp/services' + +/** + * Skill registry wrapper (docs/09 §4): every skill call snapshots its input + * and output and records a ToolCallRef into the run's lineage buffer. The + * Mastra Tool surface (for agent-initiated calls) is layered on top in a + * later milestone; M3 workflows call skills deterministically from steps, + * which is the docs/09 §3 discipline ("context assembled by upstream steps"). + */ +export interface RegisteredSkill { + id: string + version: string + call(input: I): Promise<{ output: O; ref: ToolCallRef }> +} + +export function registerSkill( + deps: { lineage: LineageRecorder; snapshots: SnapshotStore; idGen: () => string }, + def: { id: string; version: string; invoke: (input: I) => Promise }, +): RegisteredSkill { + return { + id: def.id, + version: def.version, + async call(input: I) { + const inputs_ref = deps.snapshots.put(input) + const output = await def.invoke(input) + const outputs_ref = deps.snapshots.put(output) + const ref: ToolCallRef = { tool_call_id: deps.idGen(), tool: def.id, version: def.version, inputs_ref, outputs_ref } + deps.lineage.record(ref) + return { output, ref } + }, + } +} diff --git a/packages/runtime/src/trigger.ts b/packages/runtime/src/trigger.ts new file mode 100644 index 0000000..2fa7b32 --- /dev/null +++ b/packages/runtime/src/trigger.ts @@ -0,0 +1,133 @@ +import { RouterDecision } from '@vpp/domain' +import type { EventEnvelope } from '@vpp/domain' +import { LlmUnavailable } from './llm.js' +import type { Runtime } from './runtime.js' +import { ENVELOPE_GATE_STEP_ID } from './workflows/proposal-lifecycle.js' +import type { ResumeDecision } from './workflows/proposal-lifecycle.js' + +/** + * Trigger service (docs/02 §1, docs/09 §1): three sources, one shape — + * "instantiate template X with params Y". + * scheduled: cron entries (D-1 06:00 situation, 08:00 bid) — no LLM in the path + * event: bus consumers with deterministic rules + * manual: the router agent turns free text into a RouterDecision + * (a template id + params, never a new flow); on LLM outage the + * operator picks the template explicitly. + * Approval is a resume on the lifecycle run (docs/09 §2). + */ +export interface ScheduleEntry { + id: string + /** 'HH:MM' Asia/Shanghai on D-1 */ + localTime: string + workflow: 'dayAheadSituation' | 'dayAheadBid' +} + +export const DEFAULT_SCHEDULE: ScheduleEntry[] = [ + { id: 'situation-0600', localTime: '06:00', workflow: 'dayAheadSituation' }, // docs/07 D-1 06:00 + { id: 'bid-0800', localTime: '08:00', workflow: 'dayAheadBid' }, // docs/07 D-1 08:00; window is OPEN-QUESTION A1 +] + +export const nextMarketDate = (nowIso: string): string => { + const shanghai = new Date(new Date(nowIso).getTime() + 8 * 3_600_000) + shanghai.setUTCDate(shanghai.getUTCDate() + 1) + return shanghai.toISOString().slice(0, 10) +} + +export class TriggerService { + private timer: NodeJS.Timeout | null = null + private readonly firedToday = new Set() + private readonly unsubscribe: Array<() => void> = [] + + constructor( + private readonly rt: Runtime, + private readonly schedule: ScheduleEntry[] = DEFAULT_SCHEDULE, + ) {} + + // ---- scheduled --------------------------------------------------------- + + /** Polls the clock; fires each entry once per local day. Deterministic, LLM-free. */ + startScheduler(pollMs = 30_000): void { + if (this.timer) return + this.timer = setInterval(() => void this.tick(), pollMs) + this.timer.unref() + } + + stop(): void { + if (this.timer) clearInterval(this.timer) + this.timer = null + for (const u of this.unsubscribe) u() + } + + async tick(nowIso = this.rt.ctx.clock()): Promise { + const local = new Date(new Date(nowIso).getTime() + 8 * 3_600_000) + const hhmm = local.toISOString().slice(11, 16) + const day = local.toISOString().slice(0, 10) + const fired: string[] = [] + for (const entry of this.schedule) { + const key = `${day}:${entry.id}` + if (hhmm >= entry.localTime && !this.firedToday.has(key)) { + this.firedToday.add(key) + await this.startTemplate(entry.workflow, { market_date: nextMarketDate(nowIso) }, 'SCHEDULED', entry.id) + fired.push(entry.id) + } + } + return fired + } + + // ---- event ------------------------------------------------------------- + + /** Deterministic event rules. M3: a SituationPublished with EXTREME risk opens an abnormal-day case (docs/13 §1). */ + startEventConsumers(): void { + this.unsubscribe.push( + this.rt.ctx.events.subscribe('SituationPublished', (evt: EventEnvelope) => { + const payload = evt.payload as { report: { market_date: string; risk_level: string; id: string } } + if (payload.report.risk_level === 'EXTREME') { + this.rt.ctx.caseDesk.open({ + id: this.rt.ctx.newId('case'), + kind: 'ADHOC_ANALYSIS', + objective: `Abnormal-day protocol review for ${payload.report.market_date} (situation ${payload.report.id})`, + owner: 'user-ops-lead', // OPEN-QUESTION B8 + deadline: `${payload.report.market_date}T00:00:00Z`, + }) + } + }), + ) + } + + // ---- manual ------------------------------------------------------------ + + async route(message: string): Promise { + const prompt = `Operator request: "${message}". Today (UTC) is ${this.rt.ctx.clock()}. Default market_date is ${nextMarketDate(this.rt.ctx.clock())}.` + try { + return await this.rt.ctx.llm.structured('router-agent', prompt, RouterDecision) + } catch (e) { + if (e instanceof LlmUnavailable) throw new LlmUnavailable('router needs an LLM; pick a template explicitly via startTemplate') + throw e + } + } + + async manual(message: string): Promise<{ decision: RouterDecision; run_id: string | null }> { + const decision = await this.route(message) + if (decision.workflow_id === 'adhoc-analysis') return { decision, run_id: null } + const run_id = await this.startTemplate(decision.workflow_id === 'day-ahead-bid' ? 'dayAheadBid' : 'dayAheadSituation', decision.params, 'MANUAL', 'router') + return { decision, run_id } + } + + async startTemplate(workflow: 'dayAheadSituation' | 'dayAheadBid', params: { market_date: string }, trigger: 'SCHEDULED' | 'EVENT' | 'MANUAL', source: string): Promise { + const wf = this.rt.mastra.getWorkflow(workflow) + const run = await wf.createRun() + this.rt.ctx.events.append({ event_type: 'WorkflowTriggered', payload: { workflow, params, trigger, source, run_id: run.runId }, correlation_id: run.runId }) + const result = await run.start({ inputData: params }) + this.rt.ctx.events.append({ event_type: 'WorkflowFinished', payload: { workflow, run_id: run.runId, status: result.status }, correlation_id: run.runId }) + if (result.status === 'failed') throw result.error + return run.runId + } + + // ---- approval = resume ------------------------------------------------- + + async decide(runId: string, decision: ResumeDecision) { + const wf = this.rt.mastra.getWorkflow('proposalLifecycle') + const run = await wf.createRun({ runId }) + return run.resume({ step: ENVELOPE_GATE_STEP_ID, resumeData: decision }) + } +} diff --git a/packages/runtime/src/workflows/day-ahead-bid.ts b/packages/runtime/src/workflows/day-ahead-bid.ts new file mode 100644 index 0000000..6ce65d1 --- /dev/null +++ b/packages/runtime/src/workflows/day-ahead-bid.ts @@ -0,0 +1,167 @@ +import { createStep, createWorkflow } from '@mastra/core/workflows' +import { z } from 'zod' +import type { BidProposalDraft, Proposal, ToolCallRef } from '@vpp/domain' +import { BidProposalDraft as BidProposalDraftSchema, Curve96, MarketDate, Proposal as ProposalSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain' +import { Decimal, assembleBidProposal } from '@vpp/services' +import type { RuntimeContext } from '../context.js' +import { LlmUnavailable } from '../llm.js' +import { registerSkill } from '../skills.js' +import { runForecasts } from './day-ahead-situation.js' +import { LifecycleOutput } from './proposal-lifecycle.js' +import type { createProposalLifecycle } from './proposal-lifecycle.js' + +/** + * D-1 08:00 申报策略与报价优化 (docs/07, docs/09 §3): deterministic context + * assembly (ledger bounds, capacity, price forecast) → MILP tool → the + * trading agent drafts a proposal made of *references* → assembler builds + * the Proposal from lineage → submit to the lifecycle workflow. + * With the LLM down the draft is templated; the numbers are identical. + */ +export const BidInput = z.object({ market_date: MarketDate, case_id: z.string().min(1).optional() }) + +export const BidOutput = z.object({ + case_id: z.string(), + proposal_id: z.string(), + proposal: ProposalSchema, + lifecycle_run_id: z.string(), + lifecycle: LifecycleOutput.nullable(), + lifecycle_status: z.enum(['success', 'suspended', 'failed']), + llm_used: z.boolean(), +}) + +const Context = z.object({ + market_date: MarketDate, + case_id: z.string(), + tool_calls: z.array(ToolCallRefSchema), + data_refs: z.array(z.string()), + milp_call: ToolCallRefSchema, + milp_output: z.unknown(), + ledger_version: z.int(), + policy_pack_version: z.string(), + capacity: Curve96, +}) + +function capacityCurve(ctx: RuntimeContext, marketDate: string) { + const total = ctx.resources.list().reduce((s, r) => s.add(new Decimal(r.value.certified_adjustable_mw)), new Decimal(0)) + return { interval_minutes: 15 as const, date: marketDate, values: Array.from({ length: 96 }, () => total.toFixed(3)) } +} + +export function createDayAheadBid(ctx: RuntimeContext, lifecycle: ReturnType) { + const fetchContext = createStep({ + id: 'fetch-context', + inputSchema: BidInput, + outputSchema: Context, + execute: async ({ inputData }) => { + const caseId = + inputData.case_id ?? + ctx.caseDesk.open({ + id: ctx.newId('case'), + kind: 'DAY_AHEAD_BID', + objective: `Submit day-ahead bid for ${inputData.market_date}`, + owner: 'user-trader-01', // OPEN-QUESTION B3: owner role mapping + // D-1 at the local bid deadline (Asia/Shanghai = UTC+8), stored as UTC. + deadline: new Date(new Date(`${inputData.market_date}T${ctx.config.bidDeadlineLocal}:00Z`).getTime() - 86_400_000 - 8 * 3_600_000).toISOString(), + }).id + + return ctx.lineage.run(async () => { + const { forecasts, calls } = await runForecasts(ctx, inputData.market_date) + const bounds = ctx.ledger.dayAheadBounds(inputData.market_date) + const capacity = capacityCurve(ctx, inputData.market_date) + ctx.lineage.addDataRef(ctx.snapshots.put({ resources: ctx.resources.list().map((r) => ({ id: r.key, version: r.version })) })) + const milp = registerSkill( + { lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') }, + { id: 'bid-optimization-milp', version: '1.0.0', invoke: (req: Parameters[0]) => ctx.skills.optimizeBid(req) }, + ) + const { output, ref } = await milp.call({ market_date: inputData.market_date, price_forecast: forecasts.PRICE, adjustable_capacity_mw: capacity, position_bounds: bounds, risk: ctx.config.risk }) + if (output.solver.status !== 'OPTIMAL') throw new Error(`bid optimization ${output.solver.status}: ${output.binding_constraints.join('; ')}`) + const lin = ctx.lineage.current() + ctx.caseDesk.addEvidence(caseId, lin.data_refs) + return { + market_date: inputData.market_date, + case_id: caseId, + tool_calls: [...calls, ref].filter((c, i, arr) => arr.findIndex((x) => x.tool_call_id === c.tool_call_id) === i), + data_refs: lin.data_refs, + milp_call: ref, + milp_output: output, + ledger_version: bounds.ledger_version, + policy_pack_version: ctx.policy.current(ctx.config.policyPackId).version, + capacity, + } + }) + }, + }) + + const bidStrategy = createStep({ + id: 'bid-strategy', + inputSchema: Context, + outputSchema: Context.extend({ draft: BidProposalDraftSchema, llm_used: z.boolean() }), + execute: async ({ inputData }) => { + const tc = inputData.milp_call.tool_call_id + const out = inputData.milp_output as { expected_revenue_yuan: string; daily_energy_mwh: string; binding_constraints: string[] } + const template: BidProposalDraft = { + market_date: inputData.market_date, + prices_ref: { tool_call_id: tc, path: 'prices_yuan_per_mwh' }, + quantities_ref: { tool_call_id: tc, path: 'quantities_mwh' }, + expected_revenue_ref: { tool_call_id: tc, path: 'expected_revenue_yuan' }, + rationale: `Solver ${tc}: expected revenue ${out.expected_revenue_yuan} yuan over ${out.daily_energy_mwh} MWh; binding: ${out.binding_constraints.join(', ') || 'none'} (template — LLM unavailable)`, + } + try { + const prompt = + `Market date ${inputData.market_date}. The bid-optimization tool call id is ${tc}; its output has fields prices_yuan_per_mwh, quantities_mwh, expected_revenue_yuan (= ${out.expected_revenue_yuan}), daily_energy_mwh (= ${out.daily_energy_mwh}), binding_constraints (${out.binding_constraints.join(', ') || 'none'}). ` + + 'Return a BidProposalDraft whose three refs point at those fields of that tool call, plus a one-paragraph rationale for the approver. Do not write any number that is not quoted above.' + const draft = await ctx.llm.structured('trading-agent', prompt, BidProposalDraftSchema) + return { ...inputData, draft, llm_used: true } + } catch (e) { + if (!(e instanceof LlmUnavailable)) throw e + return { ...inputData, draft: template, llm_used: false } + } + }, + }) + + const assembleAndSubmit = createStep({ + id: 'assemble-and-submit', + inputSchema: bidStrategy.outputSchema, + outputSchema: BidOutput, + execute: async ({ inputData }) => { + const proposal: Proposal = assembleBidProposal(inputData.draft, { + id: ctx.newId('prop'), + originator: { agent: 'trading-agent', trigger: 'SCHEDULED', task_id: inputData.case_id }, + lineage: { tool_calls: inputData.tool_calls as ToolCallRef[], data_refs: inputData.data_refs }, + ledger_version: inputData.ledger_version, + policy_pack_version: inputData.policy_pack_version, + snapshots: ctx.snapshots, + now: ctx.clock(), + }) + ctx.snapshots.put({ draft: inputData.draft, proposal_id: proposal.id }) + ctx.caseDesk.recordProposal(inputData.case_id, proposal) + + // ProposalSubmissionPort (docs/11 §1.1): the business workflow ends here; + // the lifecycle workflow takes over on its own run id (resumable). + const run = await lifecycle.createRun() + const result = await run.start({ inputData: { proposal, case_id: inputData.case_id } }) + if (result.status === 'suspended') { + const pending = ctx.caseDesk.pending.get(proposal.id) + if (pending) ctx.caseDesk.pending.put({ ...pending.value, run_id: run.runId }, pending.version) + } + return { + case_id: inputData.case_id, + proposal_id: proposal.id, + proposal, + lifecycle_run_id: run.runId, + lifecycle: result.status === 'success' ? result.result : null, + lifecycle_status: (result.status === 'success' ? 'success' : result.status === 'suspended' ? 'suspended' : 'failed') as 'success' | 'suspended' | 'failed', + llm_used: inputData.llm_used, + } + }, + }) + + return createWorkflow({ + id: 'day-ahead-bid', + inputSchema: BidInput, + outputSchema: BidOutput, + }) + .then(fetchContext) + .then(bidStrategy) + .then(assembleAndSubmit) + .commit() +} diff --git a/packages/runtime/src/workflows/day-ahead-situation.ts b/packages/runtime/src/workflows/day-ahead-situation.ts new file mode 100644 index 0000000..b5db0fd --- /dev/null +++ b/packages/runtime/src/workflows/day-ahead-situation.ts @@ -0,0 +1,137 @@ +import { createStep, createWorkflow } from '@mastra/core/workflows' +import { z } from 'zod' +import type { ForecastBundle, ForecastKind, SituationReport, ToolCallRef } from '@vpp/domain' +import { ForecastBundle as ForecastBundleSchema, MarketDate, SituationReport as SituationReportSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain' +import type { RuntimeContext } from '../context.js' +import { LlmUnavailable } from '../llm.js' +import { registerSkill } from '../skills.js' + +/** + * D-1 06:00 日前态势 (docs/07): periodic trigger → forecasts (tools) → + * analysis agent writes findings around the numbers → SituationReport on + * the bus. Runs with the LLM down (I6): findings fall back to a template. + */ +export const SituationInput = z.object({ market_date: MarketDate }) + +export const SituationOutput = z.object({ + report: SituationReportSchema, + report_ref: z.string(), + forecasts: z.object({ LOAD: ForecastBundleSchema, PV: ForecastBundleSchema, PRICE: ForecastBundleSchema }), + tool_calls: z.array(ToolCallRefSchema), + llm_backend: z.string(), + llm_used: z.boolean(), +}) + +const SERIES: Record = { + LOAD: { series: 'load:aggregate', unit: 'mw' }, + PV: { series: 'pv:aggregate', unit: 'mw' }, + PRICE: { series: 'price:da', unit: 'yuan_per_mwh' }, +} + +const Findings = z.object({ + findings: z.array(z.object({ kind: z.enum(['TREND', 'ANOMALY', 'RISK', 'ATTRIBUTION']), summary: z.string().min(1) })).min(1).max(6), +}) + +export async function runForecasts(ctx: RuntimeContext, marketDate: string): Promise<{ forecasts: Record; calls: ToolCallRef[] }> { + const d = new Date(`${marketDate}T00:00:00Z`) + const from = new Date(d.getTime() - ctx.config.forecastWindowDays * 86_400_000).toISOString().slice(0, 10) + const to = new Date(d.getTime() - 86_400_000).toISOString().slice(0, 10) + const forecasts = {} as Record + const calls: ToolCallRef[] = [] + for (const kind of ['LOAD', 'PV', 'PRICE'] as const) { + const history = ctx.timeseries.range(SERIES[kind].series, from, to).map((r) => r.curve) + if (history.length === 0) throw new Error(`no history for ${SERIES[kind].series} in [${from}, ${to}] — ingest data first`) + const featuresRef = ctx.snapshots.put({ series: SERIES[kind].series, from, to, revisions: history.length }) + ctx.lineage.addDataRef(featuresRef) + const skill = registerSkill( + { lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') }, + { id: `${kind.toLowerCase()}-forecast`, version: '1.0.0', invoke: (req: Parameters[1]) => ctx.skills.forecast(kind, req) }, + ) + const { output, ref } = await skill.call({ kind, market_date: marketDate, unit: SERIES[kind].unit, history, exogenous: {}, features_snapshot_ref: featuresRef }) + forecasts[kind] = output + calls.push(ref) + } + return { forecasts, calls } +} + +/** Risk level is deterministic (it gates the abnormal-day protocol, docs/13 §1); the LLM only narrates. */ +export function riskLevel(ctx: RuntimeContext, price: ForecastBundle): SituationReport['risk_level'] { + const ratio = Math.max(...price.quantiles.p90.values.map((v, i) => Number(v) / Math.max(Number(price.quantiles.p50.values[i]), 1e-9))) + const extreme = Number(ctx.config.extremeDayPriceRatio) + if (ratio >= extreme) return 'EXTREME' + if (ratio >= 1 + (extreme - 1) * 0.66) return 'HIGH' + if (ratio >= 1 + (extreme - 1) * 0.33) return 'MEDIUM' + return 'LOW' +} + +export function createDayAheadSituation(ctx: RuntimeContext) { + const forecastStep = createStep({ + id: 'forecast', + inputSchema: SituationInput, + outputSchema: z.object({ market_date: MarketDate, forecasts: SituationOutput.shape.forecasts, tool_calls: z.array(ToolCallRefSchema) }), + execute: async ({ inputData }) => { + return ctx.lineage.run(async () => { + const { forecasts, calls } = await runForecasts(ctx, inputData.market_date) + return { market_date: inputData.market_date, forecasts, tool_calls: calls } + }) + }, + }) + + const analyzeStep = createStep({ + id: 'analyze', + inputSchema: forecastStep.outputSchema, + outputSchema: SituationOutput, + execute: async ({ inputData }) => { + const { forecasts, tool_calls } = inputData + const refs = tool_calls.map((t) => t.outputs_ref) + const level = riskLevel(ctx, forecasts.PRICE) + const peakPrice = Math.max(...forecasts.PRICE.quantiles.p50.values.map(Number)) + const peakLoad = Math.max(...forecasts.LOAD.quantiles.p50.values.map(Number)) + + let findings: SituationReport['findings'] + let llmUsed = false + const fallback: SituationReport['findings'] = [ + { kind: 'TREND', summary: `P50 peak load ${peakLoad.toFixed(1)} MW; P50 peak price ${peakPrice.toFixed(2)} yuan/MWh (template — LLM unavailable)`, refs }, + { kind: 'RISK', summary: `risk level ${level} from price band width`, refs }, + ] + try { + const prompt = + `Market date ${inputData.market_date}. Tool outputs (snapshot refs): ` + + tool_calls.map((t) => `${t.tool}→${t.outputs_ref.slice(0, 12)}`).join(', ') + + `. Deterministic risk level: ${level}. P50 peak load ${peakLoad.toFixed(1)} MW, P50 peak price ${peakPrice.toFixed(2)} yuan/MWh. ` + + 'Write 2–4 findings (TREND/ANOMALY/RISK) that only restate these numbers.' + const out = await ctx.llm.structured('analysis-agent', prompt, Findings) + findings = out.findings.map((f) => ({ ...f, refs })) + llmUsed = true + } catch (e) { + if (!(e instanceof LlmUnavailable)) throw e + findings = fallback + } + + const report: SituationReport = { + id: ctx.newId('sit'), + market_date: inputData.market_date, + risk_level: level, + findings, + forecast_refs: [forecasts.LOAD.id, forecasts.PV.id, forecasts.PRICE.id], + generated_at: ctx.clock(), + } + const report_ref = ctx.snapshots.put(report) + ctx.events.append({ + event_type: 'SituationPublished', + payload: { report, report_ref, forecast_tool_calls: tool_calls }, + correlation_id: `situation-${inputData.market_date}`, + }) + return { report, report_ref, forecasts, tool_calls, llm_backend: ctx.llm.backend, llm_used: llmUsed } + }, + }) + + return createWorkflow({ + id: 'day-ahead-situation', + inputSchema: SituationInput, + outputSchema: SituationOutput, + }) + .then(forecastStep) + .then(analyzeStep) + .commit() +} diff --git a/packages/runtime/src/workflows/proposal-lifecycle.ts b/packages/runtime/src/workflows/proposal-lifecycle.ts new file mode 100644 index 0000000..f49cb85 --- /dev/null +++ b/packages/runtime/src/workflows/proposal-lifecycle.ts @@ -0,0 +1,247 @@ +import { createStep, createWorkflow } from '@mastra/core/workflows' +import { z } from 'zod' +import type { Approval, ForecastBundle, Proposal, ProposalStatus } from '@vpp/domain' +import { Approval as ApprovalSchema, ForecastBundle as ForecastBundleSchema, Proposal as ProposalSchema } from '@vpp/domain' +import { GatewayRejected, assertApproverIndependent, isStaleDenial } from '@vpp/services' +import type { RuntimeContext } from '../context.js' + +/** + * The safety chain as ONE workflow for every proposal type (ADR-0005, + * docs/03 §2, docs/09 §2): rule check → simulation → envelope gate + * (suspend for humans) → fresh check + permit → release. Every transition is + * recorded through the case desk (event-sourced) so the run is replayable. + */ +export const LifecycleInput = z.object({ proposal: ProposalSchema, case_id: z.string().min(1) }) + +export const LifecycleOutcome = z.enum(['RELEASED', 'REJECTED', 'STALE', 'SIMULATION_ALERT']) + +export const LifecycleOutput = z.object({ + outcome: LifecycleOutcome, + proposal: ProposalSchema, + permit_id: z.string().nullable(), + receipt_id: z.string().nullable(), + reasons: z.array(z.string()), +}) + +const Carry = z.object({ + proposal: ProposalSchema, + case_id: z.string(), + simulation_alert: z.boolean(), + simulation_alerts: z.array(z.string()), + approvals: z.array(ApprovalSchema), + envelope_match: z.unknown().nullable(), + done: LifecycleOutput.nullable(), +}) +type Carry = z.infer + +export const ResumeDecision = z.object({ + decision: z.enum(['approve', 'reject']), + approver: z.object({ id: z.string().min(1), role: z.string().min(1) }), + comment: z.string().optional(), +}) +export type ResumeDecision = z.infer + +const SuspendInfo = z.object({ + reason: z.string(), + proposal_id: z.string(), + required_level: z.string(), + reasons: z.array(z.string()), +}) + +function transition(ctx: RuntimeContext, caseId: string, p: Proposal, status: ProposalStatus, extra: Record = {}): Proposal { + const next: Proposal = { ...p, status } + ctx.caseDesk.recordProposal(caseId, next) + if (Object.keys(extra).length) { + ctx.events.append({ event_type: `Lifecycle.${status}`, payload: { proposal_id: p.id, digest: p.digest, ...extra }, correlation_id: caseId, causation_id: p.id }) + } + return next +} + +function priceForecastFromLineage(ctx: RuntimeContext, p: Proposal): ForecastBundle | null { + for (const tc of [...p.lineage.tool_calls].reverse()) { + if (tc.tool !== 'price-forecast') continue + const parsed = ForecastBundleSchema.safeParse(ctx.snapshots.get(tc.outputs_ref)) + if (parsed.success) return parsed.data + } + return null +} + +const finish = (c: Carry, outcome: z.infer, reasons: string[], permit_id: string | null = null, receipt_id: string | null = null): Carry => ({ + ...c, + done: { outcome, proposal: c.proposal, permit_id, receipt_id, reasons }, +}) + +/** One lifecycle workflow per runtime: steps close over that runtime's services. */ +export function createProposalLifecycle(ctx: RuntimeContext) { + const ruleCheck = createStep({ + id: 'rule-check', + inputSchema: LifecycleInput, + outputSchema: Carry, + execute: async ({ inputData }) => { + const pack = ctx.policy.pack(ctx.config.policyPackId, inputData.proposal.lineage.policy_pack_version) + let p = transition(ctx, inputData.case_id, inputData.proposal, 'RULE_CHECK') + const result = ctx.policy.check(p, { id: pack.id, version: pack.version }, { ledger: ctx.ledger.read(), ledgerService: ctx.ledger, snapshots: ctx.snapshots, now: ctx.clock() }) + const ref = ctx.snapshots.put(result) + ctx.events.append({ event_type: 'RuleCheckCompleted', payload: { proposal_id: p.id, ok: result.ok, result_ref: ref, violations: result.violations }, correlation_id: inputData.case_id, causation_id: p.id }) + const carry: Carry = { proposal: p, case_id: inputData.case_id, simulation_alert: false, simulation_alerts: [], approvals: [], envelope_match: null, done: null } + if (!result.ok) { + p = transition(ctx, inputData.case_id, p, 'REJECTED', { by: 'rule-check', violations: result.violations }) + return finish({ ...carry, proposal: p }, 'REJECTED', result.violations.map((v) => `${v.rule_id}: ${v.message}`)) + } + return carry + }, + }) + + const simulation = createStep({ + id: 'simulation', + inputSchema: Carry, + outputSchema: Carry, + execute: async ({ inputData }) => { + if (inputData.done) return inputData + const p = transition(ctx, inputData.case_id, inputData.proposal, 'SIMULATION') + const result = ctx.simulation.simulate(p, { + now: ctx.clock(), + priceForecast: priceForecastFromLineage(ctx, p), + marginalCostYuanPerMwh: ctx.config.risk.marginal_cost_yuan_per_mwh, + worstCaseLossBudgetYuan: ctx.config.worstCaseLossBudgetYuan, + }) + const ref = ctx.snapshots.put(result) + ctx.events.append({ event_type: 'SimulationCompleted', payload: { proposal_id: p.id, alert: result.alert, alerts: result.alerts, metrics: result.metrics, result_ref: ref }, correlation_id: inputData.case_id, causation_id: p.id }) + return { ...inputData, proposal: p, simulation_alert: result.alert, simulation_alerts: result.alerts } + }, + }) + + const envelopeGate = createStep({ + id: 'envelope-gate', + inputSchema: Carry, + outputSchema: Carry, + suspendSchema: SuspendInfo, + resumeSchema: ResumeDecision, + execute: async ({ inputData, resumeData, suspend }) => { + if (inputData.done) return inputData + const now = ctx.clock() + const p0 = inputData.proposal + + if (resumeData) { + // —— Human decision path (I1/I2 enforced here; the AI has no route to APPROVED). + try { + assertApproverIndependent(p0, resumeData.approver, ctx.config.approverRoles) + } catch (e) { + ctx.events.append({ event_type: 'ApprovalRefused', payload: { proposal_id: p0.id, approver: resumeData.approver, reason: (e as Error).message }, correlation_id: inputData.case_id, causation_id: p0.id }) + return await suspend({ reason: `approval refused: ${(e as Error).message}`, proposal_id: p0.id, required_level: 'L2', reasons: [(e as Error).message] }) + } + const approval: Approval = { + id: ctx.newId('appr'), + proposal_digest: p0.digest, + decision: resumeData.decision === 'approve' ? 'APPROVE' : 'REJECT', + approver: resumeData.approver, + scope: { effect_type: p0.type, limits: {} }, + validity: { from: now, to: new Date(new Date(now).getTime() + ctx.config.bidPermitTtlMs).toISOString() }, + evidence_versions: { policy_pack_version: p0.lineage.policy_pack_version, ledger_version: ctx.ledger.read().version, data_snapshot_refs: p0.lineage.data_refs }, + ...(resumeData.comment ? { comment: resumeData.comment } : {}), + decided_at: now, + } + ctx.caseDesk.recordApproval(inputData.case_id, approval) + ctx.caseDesk.clearPending(p0.id) + if (approval.decision === 'REJECT') { + const p = transition(ctx, inputData.case_id, p0, 'REJECTED', { by: approval.approver.id, comment: resumeData.comment ?? null }) + return finish({ ...inputData, proposal: p, approvals: [approval] }, 'REJECTED', [`rejected by ${approval.approver.id}`]) + } + const p = transition(ctx, inputData.case_id, p0, 'APPROVED', { by: approval.approver.id }) + return { ...inputData, proposal: p, approvals: [approval] } + } + + // —— Automatic path. + let p = transition(ctx, inputData.case_id, p0, 'ENVELOPE_CHECK') + if (inputData.simulation_alert) { + // One vote: any simulation alert escalates to a human, no exceptions. + p = transition(ctx, inputData.case_id, p, 'PENDING_HUMAN', { because: 'simulation alert', alerts: inputData.simulation_alerts }) + ctx.caseDesk.requestApproval({ proposal_id: p.id, proposal_digest: p.digest, case_id: inputData.case_id, run_id: '', required_level: 'L2', reasons: inputData.simulation_alerts, since: now }) + return await suspend({ reason: 'simulation alert', proposal_id: p.id, required_level: 'L2', reasons: inputData.simulation_alerts }) + } + const match = ctx.envelopes.match(p, { now, priceBaseline: priceForecastFromLineage(ctx, p)?.quantiles.p50 ?? null }) + ctx.snapshots.put(match) + if (match.within) { + p = transition(ctx, inputData.case_id, { ...p, envelope_ref: match.envelope_id }, 'AUTO_APPROVED', { envelope_id: match.envelope_id }) + return { ...inputData, proposal: p, envelope_match: match } + } + p = transition(ctx, inputData.case_id, p, 'PENDING_HUMAN', { because: 'outside envelope', reasons: match.reasons }) + ctx.caseDesk.requestApproval({ proposal_id: p.id, proposal_digest: p.digest, case_id: inputData.case_id, run_id: '', required_level: match.required_level, reasons: match.reasons, since: now }) + return await suspend({ reason: 'outside envelope', proposal_id: p.id, required_level: match.required_level, reasons: match.reasons }) + }, + }) + + const freshCheckAndPermit = createStep({ + id: 'fresh-check', + inputSchema: Carry, + outputSchema: Carry, + execute: async ({ inputData }) => { + if (inputData.done) return inputData + const now = ctx.clock() + let p = transition(ctx, inputData.case_id, inputData.proposal, 'FRESH_CHECK') + const result = ctx.authority.authorize( + p, + { approvals: inputData.approvals, envelopeMatch: (inputData.envelope_match as Parameters[1]['envelopeMatch']) ?? null }, + { now, ledger: ctx.ledger, currentPolicyPackVersion: ctx.policy.current(ctx.config.policyPackId).version, offlineResources: [], permitTtlMs: ctx.config.bidPermitTtlMs }, + ) + if (isStaleDenial(result)) { + p = transition(ctx, inputData.case_id, p, 'STALE', { reasons: result.reasons }) + return finish({ ...inputData, proposal: p }, 'STALE', result.reasons) + } + ctx.caseDesk.recordPermit(inputData.case_id, result) + p = transition(ctx, inputData.case_id, p, 'AUTHORIZED', { permit_id: result.id, expires_at: result.expires_at }) + return { ...inputData, proposal: p, done: null, envelope_match: inputData.envelope_match, approvals: inputData.approvals, permit: result } as Carry & { permit: unknown } + }, + }) + + const release = createStep({ + id: 'release', + inputSchema: Carry.extend({ permit: z.unknown().optional() }), + outputSchema: LifecycleOutput, + execute: async ({ inputData }) => { + if (inputData.done) return inputData.done + const now = ctx.clock() + const permitId = (inputData.permit as { id: string } | undefined)?.id + const permit = permitId ? ctx.authority.permits.get(permitId)?.value : undefined + if (!permit) throw new Error('release reached without a permit — invariant I4 violated in workflow wiring') + try { + const receipt = ctx.gateway.dispatch(inputData.proposal, permit, now) + const p = transition(ctx, inputData.case_id, inputData.proposal, 'RELEASED', { receipt_id: receipt.receipt_id, artifact_ref: receipt.artifact_ref }) + ctx.events.append({ event_type: 'ExecutionReceipt', payload: receipt, correlation_id: inputData.case_id, causation_id: p.id }) + ctx.ledger.append({ + id: ctx.newId('pos'), + timescale: 'DAY_AHEAD', + period: p.type === 'BID' ? p.payload.market_date : now.slice(0, 10), + kind: 'BID_SUBMITTED', + energy_mwh: p.type === 'BID' ? p.payload.quantities_mwh.values.reduce((s, v) => (Number(s) + Number(v)).toFixed(3), '0') : '0', + curve: p.type === 'BID' ? p.payload.quantities_mwh : null, + source_ref: p.digest, + expected_version: ctx.ledger.read().version, + }) + ctx.caseDesk.close(inputData.case_id, receipt.receipt_id, true) + return { outcome: 'RELEASED' as const, proposal: p, permit_id: permit.id, receipt_id: receipt.receipt_id, reasons: [] } + } catch (e) { + if (e instanceof GatewayRejected) { + const p = transition(ctx, inputData.case_id, inputData.proposal, 'STALE', { gateway_reasons: e.reasons }) + return { outcome: 'STALE' as const, proposal: p, permit_id: permit.id, receipt_id: null, reasons: e.reasons } + } + throw e + } + }, + }) + + return createWorkflow({ + id: 'proposal-lifecycle', + inputSchema: LifecycleInput, + outputSchema: LifecycleOutput, + }) + .then(ruleCheck) + .then(simulation) + .then(envelopeGate) + .then(freshCheckAndPermit) + .then(release) + .commit() + +} + +export const ENVELOPE_GATE_STEP_ID = 'envelope-gate' diff --git a/packages/runtime/test/api.test.ts b/packages/runtime/test/api.test.ts new file mode 100644 index 0000000..6c54525 --- /dev/null +++ b/packages/runtime/test/api.test.ts @@ -0,0 +1,54 @@ +import type { AddressInfo } from 'node:net' +import { describe, expect, it } from 'vitest' +import { createApi } from '../src/api.js' +import { TriggerService } from '../src/trigger.js' +import { MARKET_DATE, harness } from './helpers.js' + +async function serve(h: Awaited>) { + const server = createApi(h.rt, new TriggerService(h.rt)) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const call = async (method: string, path: string, body?: unknown, headers: Record = {}): Promise<{ status: number; body: any }> => { + const init: RequestInit = { method, headers: { 'content-type': 'application/json', ...headers } } + if (body !== undefined) init.body = JSON.stringify(body) + const res = await fetch(base + path, init) + return { status: res.status, body: await res.json() } + } + return { call, close: () => new Promise((r) => server.close(() => r())) } +} + +describe('Case Desk HTTP API', () => { + it('inbox → decide (approve) → case view + lineage expansion', async () => { + const h = await harness({ llm: null }) + const { call, close } = await serve(h) + expect((await call('GET', '/health')).body).toEqual({ status: 'ok', llm: 'null' }) + + const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun() + const out = await run.start({ inputData: { market_date: MARKET_DATE } }) + if (out.status !== 'success') throw new Error(out.status) + + const inbox = await call('GET', '/inbox') + expect(inbox.body).toHaveLength(1) + const { run_id, proposal_id, case_id } = inbox.body[0] + + expect((await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve' })).status).toBe(401) + const refused = await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve' }, { 'x-user-id': 'trading-agent', 'x-user-role': 'senior-trader' }) + expect(refused.body.status).toBe('suspended') // I1: still waiting for a legitimate human + + const ok = await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve', comment: 'fine' }, { 'x-user-id': 'user-trader-01', 'x-user-role': 'senior-trader' }) + expect(ok.body).toMatchObject({ run_id, status: 'success', outcome: 'RELEASED' }) + expect((await call('GET', '/inbox')).body).toHaveLength(0) + + const view = await call('GET', `/cases/${case_id}`) + expect(view.body.case.status).toBe('CLOSED_DONE') + expect(view.body.approvals[0].approver.id).toBe('user-trader-01') + const lineage = await call('GET', `/proposals/${proposal_id}/lineage`) + expect(lineage.body.tool_calls.map((t: { tool: string }) => t.tool)).toContain('bid-optimization-milp') + expect((await call('GET', `/events?correlation_id=${case_id}`)).body.length).toBeGreaterThan(8) + expect((await call('GET', '/nope')).status).toBe(404) + expect((await call('POST', '/tasks', { message: 'hi' })).status).toBe(503) // router needs an LLM + await close() + await h.rt.close() + }) +}) diff --git a/packages/runtime/test/helpers.ts b/packages/runtime/test/helpers.ts new file mode 100644 index 0000000..6581a5e --- /dev/null +++ b/packages/runtime/test/helpers.ts @@ -0,0 +1,102 @@ +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { BidOptimizationRequest, BidOptimizationResult, Envelope, ForecastBundle, ForecastKind, ForecastRequest, ReportRequest, SkillReport } from '@vpp/domain' +import { MemoryTimeSeriesStore } from '@vpp/services' +import type { SkillClient } from '@vpp/services' +import { createRuntime } from '../src/runtime.js' +import type { RuntimeOptions } from '../src/runtime.js' +import type { LlmPort } from '../src/llm.js' + +const datasetPath = fileURLToPath(new URL('../../evals/datasets/synthetic-hubei-v0.json', import.meta.url)) + +interface Day { date: string; load_mw: string[]; pv_mw: string[]; price_yuan_per_mwh: string[]; adjustable_capacity_mw: string[] } + +export const MARKET_DATE = '2026-03-15' +export const NOW = '2026-03-14T00:00:00Z' // D-1 08:00 Asia/Shanghai + +/** + * Deterministic stand-in for the Python skill service: forecast = last + * history day with a ±spread band; bid = flat energy at the position's + * lower bound offered at `offer` — enough to steer the lifecycle down every + * branch (envelope in/out, simulation alert) from tests. + */ +export class StubSkills implements SkillClient { + spread = 0.1 + offer = '0.00' + energyFraction = 0 // 0 → E_min, 1 → E_max + calls: string[] = [] + + async skills() { + return ['load-forecast', 'pv-forecast', 'price-forecast', 'bid-optimization-milp'].map((id) => ({ id, version: '0.0.1', endpoint: '/stub' })) + } + async forecast(kind: ForecastKind, req: ForecastRequest): Promise { + this.calls.push(`forecast:${kind}`) + const last = req.history[req.history.length - 1]! + const scaled = (f: number) => ({ interval_minutes: 15 as const, date: req.market_date, values: last.values.map((v) => (Number(v) * f).toFixed(kind === 'PRICE' ? 2 : 3)) }) + return { + id: `fc-${kind.toLowerCase()}-${req.market_date}`, kind, market_date: req.market_date, unit: req.unit, + quantiles: { p10: scaled(1 - this.spread), p50: scaled(1), p90: scaled(1 + this.spread) }, + model: { name: `${kind.toLowerCase()}-forecast`, version: '0.0.1' }, features_snapshot_ref: req.features_snapshot_ref, generated_at: '2026-03-14T06:00:00Z', + } + } + async optimizeBid(req: BidOptimizationRequest): Promise { + this.calls.push('milp') + const lo = Number(req.position_bounds.daily_energy_min_mwh) + const hi = Number(req.position_bounds.daily_energy_max_mwh) + const per = ((lo + (hi - lo) * this.energyFraction) / 96).toFixed(3) + const q = { interval_minutes: 15 as const, date: req.market_date, values: Array(96).fill(per) as string[] } + const energy = (Number(per) * 96).toFixed(3) + const p50 = req.price_forecast.quantiles.p50.values + const revenue = p50.reduce((s, p) => s + (Number(this.offer) <= Number(p) ? Number(p) * Number(per) : 0), 0).toFixed(2) + return { + market_date: req.market_date, prices_yuan_per_mwh: { ...q, values: Array(96).fill(this.offer) }, quantities_mwh: q, daily_energy_mwh: energy, + expected_revenue_yuan: revenue, revenue_distribution_yuan: { p10: revenue, p50: revenue, p90: revenue }, position_bounds: req.position_bounds, + solver: { name: 'stub', version: '0', status: 'OPTIMAL', objective_value: revenue, wall_time_ms: 1 }, binding_constraints: ['daily_energy_min'], skill_version: '0.0.1', + } + } + async report(_r: ReportRequest): Promise { throw new Error('not used') } +} + +export function seededTimeseries(clock: () => string) { + const ts = new MemoryTimeSeriesStore(clock) + const days = (JSON.parse(readFileSync(datasetPath, 'utf8')) as { days: Day[] }).days + for (const d of days) { + ts.write('load:aggregate', { interval_minutes: 15, date: d.date, values: d.load_mw }, 'synthetic') + ts.write('pv:aggregate', { interval_minutes: 15, date: d.date, values: d.pv_mw }, 'synthetic') + ts.write('price:da', { interval_minutes: 15, date: d.date, values: d.price_yuan_per_mwh }, 'synthetic') + } + return ts +} + +export const envelope = (bounds: Record, over: Partial = {}): Envelope => ({ + id: 'env-bid-001', + scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' }, + bounds, + validity: { from: '2026-03-01T00:00:00Z', to: '2026-03-31T23:59:59Z' }, + approval: { level: 'L1', approved_by: ['user-ops-lead'] }, + escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' }, + status: 'ACTIVE', + ...over, +}) + +export interface Harness { rt: Awaited>; skills: StubSkills; dataDir: string; setNow: (iso: string) => void } + +/** Fresh runtime on a temp dir, seeded like docs/07 D-1: history, one storage resource, a monthly contract. */ +export async function harness(opts: { llm?: LlmPort | null; dataDir?: string; seed?: boolean; config?: RuntimeOptions['config'] } = {}): Promise { + let now = NOW + const clock = () => now + const skills = new StubSkills() + const dataDir = opts.dataDir ?? mkdtempSync(join(tmpdir(), 'vpp-rt-')) + const rt = await createRuntime({ dataDir, llm: opts.llm ?? null, skills, clock, timeseries: seededTimeseries(clock), ...(opts.config ? { config: opts.config } : {}) }) + if (opts.seed !== false) { + rt.ctx.resources.put({ + resource_id: 'res-storage-01', name: 'Wuhan storage #1', type: 'STORAGE', rated_power_mw: '30.0', certified_adjustable_mw: '24.0', confidence: '0.9', reliability_score: '0.9', + constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' }, evidence_refs: [], updated_at: NOW, + }) + // 24 MW × 0.9 × 0.25 h × 96 = 518.4 MWh sellable/day; contract 60% of it → daily share 311.04, band ±5% + rt.ctx.ledger.append({ id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: (311.04 * 31).toFixed(3), curve: null, source_ref: 'contract-2026-03-001', expected_version: 0 }) + } + return { rt, skills, dataDir, setNow: (iso) => (now = iso) } +} diff --git a/packages/runtime/test/lifecycle.test.ts b/packages/runtime/test/lifecycle.test.ts new file mode 100644 index 0000000..701af29 --- /dev/null +++ b/packages/runtime/test/lifecycle.test.ts @@ -0,0 +1,292 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import type { Proposal } from '@vpp/domain' +import { proposalDigest } from '@vpp/services' +import { ScriptedLlm } from '../src/llm.js' +import { TriggerService } from '../src/trigger.js' +import { MARKET_DATE, envelope, harness } from './helpers.js' + +const APPROVER = { id: 'user-trader-01', role: 'senior-trader' } + +async function submitBid(h: Awaited>) { + const wf = h.rt.mastra.getWorkflow('dayAheadBid') + const run = await wf.createRun() + const res = await run.start({ inputData: { market_date: MARKET_DATE } }) + if (res.status !== 'success') throw new Error(`bid workflow ${res.status}: ${JSON.stringify((res as { error?: unknown }).error ?? res)}`) + return res.result +} + +const statuses = (h: Awaited>, caseId: string) => + h.rt.ctx.events.list({ event_type: 'ProposalStatusChanged', correlation_id: caseId }).map((e) => (e.payload as { status: string }).status) + +describe('docs/07 timeline D-1 06:00 → 08:30 (LLM down: I6)', () => { + it('situation → bid → lifecycle end-to-end, auto-approved inside the envelope, released as a bid file', async () => { + const h = await harness({ llm: null }) + h.rt.ctx.envelopes.register(envelope({ price_deviation_pct: '5.0', max_energy_mwh: '1500.0' })) + + // 06:00 situation + const sit = await h.rt.mastra.getWorkflow('dayAheadSituation').createRun() + const sres = await sit.start({ inputData: { market_date: MARKET_DATE } }) + expect(sres.status).toBe('success') + if (sres.status !== 'success') return + expect(sres.result.llm_used).toBe(false) + expect(sres.result.report.findings[0]!.summary).toMatch(/template/) + expect(sres.result.report.risk_level).toBe('LOW') + expect(h.rt.ctx.events.list({ event_type: 'SituationPublished' })).toHaveLength(1) + + // 08:00 bid → 08:30 chain + const out = await submitBid(h) + expect(out.llm_used).toBe(false) + expect(out.lifecycle_status).toBe('success') + expect(out.lifecycle?.outcome).toBe('RELEASED') + expect(statuses(h, out.case_id)).toEqual(['DRAFT', 'RULE_CHECK', 'SIMULATION', 'ENVELOPE_CHECK', 'AUTO_APPROVED', 'FRESH_CHECK', 'AUTHORIZED', 'RELEASED']) + + // P2: every payload number traces to the MILP tool output; P7: the ledger recorded the bid. + const lin = h.rt.ctx.caseDesk.expandLineage(out.proposal_id) + expect(lin.tool_calls.map((t) => t.tool)).toEqual(['load-forecast', 'pv-forecast', 'price-forecast', 'bid-optimization-milp']) + expect((lin.tool_calls[3]!.outputs as { expected_revenue_yuan: string }).expected_revenue_yuan).toBe(out.proposal.payload.expected_revenue_yuan) + expect(h.rt.ctx.ledger.viewByTimescale('DAY_AHEAD')).toHaveLength(1) + expect(out.proposal.lineage.ledger_version).toBe(1) + + // I5: receipt + file; case closed with the receipt as outcome. + const view = h.rt.ctx.caseDesk.read(out.case_id) + expect(view.case.status).toBe('CLOSED_DONE') + expect(view.permits).toHaveLength(1) + expect(existsSync(h.rt.ctx.gateway.receipts.list()[0]!.value.artifact_ref)).toBe(true) + await h.rt.close() + }) + + it('with an LLM, the agents narrate but the numbers are byte-identical to the LLM-down run', async () => { + const draftFor = (prompt: string) => { + const tc = /tool call id is (tc-[\w-]+)/.exec(prompt)![1]! + return { market_date: MARKET_DATE, prices_ref: { tool_call_id: tc, path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: tc, path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: tc, path: 'expected_revenue_yuan' }, rationale: 'Evening peak carries the position; offer as price-taker.' } + } + const llm = new ScriptedLlm({ + 'analysis-agent': { findings: [{ kind: 'TREND', summary: 'Load near seasonal norm; no anomalies.' }] }, + 'trading-agent': draftFor, + }) + const withLlm = await harness({ llm }) + const without = await harness({ llm: null }) + for (const h of [withLlm, without]) h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' })) + const a = await submitBid(withLlm) + const b = await submitBid(without) + expect(a.llm_used).toBe(true) + expect(b.llm_used).toBe(false) + expect(a.proposal.payload).toEqual(b.proposal.payload) + expect(llm.prompts.map((p) => p.agentId)).toContain('trading-agent') + await withLlm.rt.close() + await without.rt.close() + }) + + it('an LLM draft that references a tool call outside lineage cannot become a proposal (P2)', async () => { + const llm = new ScriptedLlm({ + 'trading-agent': { market_date: MARKET_DATE, prices_ref: { tool_call_id: 'tc-forged', path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: 'tc-forged', path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: 'tc-forged', path: 'expected_revenue_yuan' }, rationale: 'x' }, + }) + const h = await harness({ llm }) + const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun() + const res = await run.start({ inputData: { market_date: MARKET_DATE } }) + expect(res.status).toBe('failed') + if (res.status === 'failed') expect(String(res.error.message)).toMatch(/not in this proposal's lineage/) + expect(h.rt.ctx.caseDesk.proposals.list()).toHaveLength(0) + await h.rt.close() + }) +}) + +describe('human approval path (PENDING_HUMAN → resume)', () => { + it('suspends outside the envelope, surfaces in the inbox, and a human approve releases it', async () => { + const h = await harness({ llm: null }) // no envelopes → everything manual + const out = await submitBid(h) + expect(out.lifecycle_status).toBe('suspended') + expect(statuses(h, out.case_id).at(-1)).toBe('PENDING_HUMAN') + const inbox = h.rt.ctx.caseDesk.inbox() + expect(inbox).toHaveLength(1) + expect(inbox[0]!.run_id).toBe(out.lifecycle_run_id) + expect(inbox[0]!.reasons[0]).toMatch(/no ACTIVE envelope/) + + const triggers = new TriggerService(h.rt) + const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER, comment: 'ok' }) + expect(res.status).toBe('success') + if (res.status === 'success') expect(res.result.outcome).toBe('RELEASED') + expect(statuses(h, out.case_id).slice(-4)).toEqual(['APPROVED', 'FRESH_CHECK', 'AUTHORIZED', 'RELEASED']) + const view = h.rt.ctx.caseDesk.read(out.case_id) + expect(view.approvals[0]!.approver).toEqual(APPROVER) + expect(view.approvals[0]!.proposal_digest).toBe(out.proposal.digest) + await h.rt.close() + }) + + it('I1: the AI (or anyone in the origination chain) cannot approve; the run stays suspended', async () => { + const h = await harness({ llm: null }) + const out = await submitBid(h) + const triggers = new TriggerService(h.rt) + for (const approver of [{ id: 'trading-agent', role: 'senior-trader' }, { id: 'user-x', role: 'agent' }, { id: 'bid-optimization-milp', role: 'ops-lead' }]) { + const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver }) + expect(res.status).toBe('suspended') + } + expect(h.rt.ctx.events.list({ event_type: 'ApprovalRefused' })).toHaveLength(3) + expect(h.rt.ctx.caseDesk.read(out.case_id).approvals).toHaveLength(0) + const still = h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status + expect(still).toBe('PENDING_HUMAN') + await h.rt.close() + }) + + it('a human reject ends the run as REJECTED', async () => { + const h = await harness({ llm: null }) + const out = await submitBid(h) + const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'reject', approver: APPROVER, comment: 'too aggressive' }) + expect(res.status).toBe('success') + if (res.status === 'success') expect(res.result.outcome).toBe('REJECTED') + expect(h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status).toBe('REJECTED') + await h.rt.close() + }) + + it('a simulation alert escalates to a human even inside the envelope (one vote)', async () => { + const h = await harness({ llm: null, config: { worstCaseLossBudgetYuan: '0', risk: { risk_aversion: '0.3', commitment_buffer_k: '0.9', min_block_mwh: '0.5', marginal_cost_yuan_per_mwh: '10000' } } }) + h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' })) + const out = await submitBid(h) + expect(out.lifecycle_status).toBe('suspended') + expect(h.rt.ctx.caseDesk.inbox()[0]!.reasons[0]).toMatch(/loss budget/) + await h.rt.close() + }) +}) + +describe('durability: a suspended approval survives process restart', () => { + it('resumes on a fresh runtime over the same data dir', async () => { + const h1 = await harness({ llm: null }) + const out = await submitBid(h1) + expect(out.lifecycle_status).toBe('suspended') + await h1.rt.close() + + const h2 = await harness({ llm: null, dataDir: h1.dataDir, seed: false }) + expect(h2.rt.ctx.caseDesk.inbox()[0]!.run_id).toBe(out.lifecycle_run_id) + expect(h2.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status).toBe('PENDING_HUMAN') + // The ledger is in-memory in phase 1: replay its monthly anchor so the fresh check sees the same version. + h2.rt.ctx.ledger.append({ id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: (311.04 * 31).toFixed(3), curve: null, source_ref: 'contract-2026-03-001', expected_version: 0 }) + const state = await h2.rt.mastra.getWorkflow('proposalLifecycle').getWorkflowRunById(out.lifecycle_run_id) + expect(state?.status).toBe('suspended') + + const res = await new TriggerService(h2.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER }) + expect(res.status).toBe('success') + if (res.status === 'success') expect(res.result.outcome).toBe('RELEASED') + expect(h2.rt.ctx.caseDesk.read(out.case_id).case.status).toBe('CLOSED_DONE') + await h2.rt.close() + }) +}) + +describe('staleness and permits (I3/I4)', () => { + it('approval goes STALE when the ledger moved between approval and fresh check', async () => { + const h = await harness({ llm: null }) + const out = await submitBid(h) + h.rt.ctx.ledger.append({ id: 'late', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 }) + // Approval evidence is captured at resume time; the auto path captured version 1 in lineage. + const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER }) + expect(res.status).toBe('success') + if (res.status === 'success') { + // The approval itself now references ledger v2 — consistent — so this passes fresh check; + // the *auto-approved* path is the one bound to lineage v1. Exercise it below. + expect(res.result.outcome).toBe('RELEASED') + } + await h.rt.close() + + const g = await harness({ llm: null }) + g.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' })) + // Move the ledger *during* the run: between fetch-context (lineage v1) and fresh check. + g.rt.ctx.events.subscribe('Lifecycle.AUTO_APPROVED', () => { + g.rt.ctx.ledger.append({ id: 'race', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: g.rt.ctx.ledger.read().version }) + }) + const out2 = await submitBid(g) + expect(out2.lifecycle?.outcome).toBe('STALE') + expect(out2.lifecycle?.reasons[0]).toMatch(/ledger version 1 at auto-approval, now 2/) + expect(g.rt.ctx.gateway.receipts.list()).toHaveLength(0) + await g.rt.close() + }) + + it('permit expiry blocks a late release', async () => { + const h = await harness({ llm: null }) + const out = await submitBid(h) + // Resume after the permit TTL would already have passed relative to approval: the gate issues + // the permit at `now`, then the gateway is asked at now + TTL + 1s. + const triggers = new TriggerService(h.rt) + h.rt.ctx.events.subscribe('Lifecycle.AUTHORIZED', () => h.setNow('2026-03-14T06:00:01Z')) // TTL is 6h + const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER }) + expect(res.status).toBe('success') + if (res.status === 'success') { + expect(res.result.outcome).toBe('STALE') + expect(res.result.reasons[0]).toMatch(/permit expired/) + } + expect(h.rt.ctx.gateway.receipts.list()).toHaveLength(0) + await h.rt.close() + }) + + it('a revoked permit is refused by the gateway (kill switch)', async () => { + const h = await harness({ llm: null }) + const out = await submitBid(h) + h.rt.ctx.events.subscribe('Lifecycle.AUTHORIZED', (evt) => { + h.rt.ctx.authority.revoke((evt.payload as { permit_id: string }).permit_id, 'L1 kill switch', h.rt.ctx.clock()) + }) + const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER }) + if (res.status === 'success') expect(res.result.reasons[0]).toMatch(/revoked/) + else throw new Error(res.status) + await h.rt.close() + }) +}) + +describe('replay (I7) and authoritative store (I8)', () => { + it('a released proposal re-checks identically from its snapshots and frozen pack version', async () => { + const h = await harness({ llm: null }) + h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' })) + const out = await submitBid(h) + const stored: Proposal = h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value + expect(proposalDigest(stored)).toBe(out.proposal.digest) + const originalCheck = h.rt.ctx.events.list({ event_type: 'RuleCheckCompleted' })[0]!.payload as { result_ref: string } + const recorded = h.rt.ctx.snapshots.get(originalCheck.result_ref) as { ok: boolean; rules_evaluated: string[] } + // Replay against a ledger reconstructed to the lineage version (the bid append moved it to 2). + const replayLedger = h.rt.ctx.ledger + const replay = h.rt.ctx.policy.check({ ...stored, status: 'DRAFT' }, { id: 'hubei-spot-bidding', version: stored.lineage.policy_pack_version }, { ledger: { version: stored.lineage.ledger_version, entries: [] }, ledgerService: replayLedger, snapshots: h.rt.ctx.snapshots, now: h.rt.ctx.clock() }) + expect(replay.rules_evaluated).toEqual(recorded.rules_evaluated) + expect(replay.ok).toBe(recorded.ok) + // I8: the ledger (not any agent memory) is the source the proposal cites. + expect(stored.lineage.ledger_version).toBe(1) + expect(h.rt.ctx.ledger.read().entries.find((e) => e.kind === 'BID_SUBMITTED')?.source_ref).toBe(stored.digest) + await h.rt.close() + }) +}) + +describe('trigger service', () => { + it('scheduled entries fire once per local day and need no LLM', async () => { + const h = await harness({ llm: null }) + h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' })) + const t = new TriggerService(h.rt) + expect(await t.tick('2026-03-13T21:00:00Z')).toEqual([]) // 05:00 Shanghai + expect(await t.tick('2026-03-13T22:30:00Z')).toEqual(['situation-0600']) + expect(await t.tick('2026-03-14T00:05:00Z')).toEqual(['bid-0800']) + expect(await t.tick('2026-03-14T00:10:00Z')).toEqual([]) + expect(h.rt.ctx.events.list({ event_type: 'WorkflowTriggered' }).map((e) => (e.payload as { workflow: string }).workflow)).toEqual(['dayAheadSituation', 'dayAheadBid']) + await h.rt.close() + }) + + it('manual requests route to a registered template; router refuses when the LLM is down', async () => { + const llm = new ScriptedLlm({ 'router-agent': { workflow_id: 'day-ahead-situation', params: { market_date: MARKET_DATE }, confidence: 'HIGH' } }) + const h = await harness({ llm }) + const t = new TriggerService(h.rt) + const r = await t.manual('明天的态势报告') + expect(r.decision.workflow_id).toBe('day-ahead-situation') + expect(r.run_id).not.toBeNull() + await h.rt.close() + + const down = await harness({ llm: null }) + await expect(new TriggerService(down.rt).manual('anything')).rejects.toThrow(/pick a template explicitly/) + await down.rt.close() + }) + + it('an EXTREME situation opens an abnormal-day case (event trigger)', async () => { + const h = await harness({ llm: null }) + h.skills.spread = 1.2 // p90/p50 = 2.2 ≥ extreme ratio 2.0 + const t = new TriggerService(h.rt) + t.startEventConsumers() + await t.startTemplate('dayAheadSituation', { market_date: MARKET_DATE }, 'MANUAL', 'test') + const cases = h.rt.ctx.caseDesk.cases.list().map((r) => r.value) + expect(cases.some((c) => c.kind === 'ADHOC_ANALYSIS' && /Abnormal-day/.test(c.objective))).toBe(true) + t.stop() + await h.rt.close() + }) +}) diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json new file mode 100644 index 0000000..35707f6 --- /dev/null +++ b/packages/runtime/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/packages/services/package.json b/packages/services/package.json index 156348d..de6b2c7 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -12,7 +12,8 @@ }, "dependencies": { "@vpp/domain": "*", - "decimal.js-light": "^2.5.1" + "decimal.js-light": "^2.5.1", + "zod": "^4.1.0" }, "devDependencies": { "@types/node": "^26.4.1", diff --git a/packages/services/src/authority.ts b/packages/services/src/authority.ts new file mode 100644 index 0000000..10a8f83 --- /dev/null +++ b/packages/services/src/authority.ts @@ -0,0 +1,127 @@ +import type { Approval, EnvelopeMatch, ExecutionPermit, Proposal, StaleDenial } from '@vpp/domain' +import { ExecutionPermit as PermitSchema } from '@vpp/domain' +import { Decimal } from './decimal.js' +import type { LedgerService } from './ledger.js' +import { MemoryRepository } from './relational.js' +import type { Repository } from './relational.js' + +/** + * Authority service (docs/03 §2.2, I3/I4): fresh-state check + permit issue + + * revocation. Approval ≠ execution: hours may pass, the world changes. The + * permit is the only credential a gateway accepts, short-lived and revocable. + */ +export interface FreshCheckContext { + now: string + ledger: LedgerService + currentPolicyPackVersion: string + /** Resources referenced by the proposal that are currently offline/de-rated. */ + offlineResources: string[] + /** Permit lifetime for this proposal type. Bids: until market deadline — OPEN-QUESTION A1. */ + permitTtlMs: number +} + +export interface ApprovalBasis { + /** Human approvals (may be multiple levels) — or none when auto-approved. */ + approvals: Approval[] + /** Envelope match that auto-approved, if any. */ + envelopeMatch: EnvelopeMatch | null +} + +export interface ApproverIdentity { + id: string + role: string +} + +/** + * I1/I2: an approver must be a human role outside the proposal's origination + * chain. Agents, the runtime, and any id appearing as originator are rejected. + */ +export function assertApproverIndependent(proposal: Proposal, approver: ApproverIdentity, humanRoles: string[]): void { + const chain = new Set([proposal.originator.agent, proposal.originator.task_id, ...proposal.lineage.tool_calls.map((t) => t.tool)]) + if (chain.has(approver.id)) throw new Error(`approver ${approver.id} is in the origination chain (I1)`) + if (!humanRoles.includes(approver.role)) throw new Error(`role '${approver.role}' may not approve (I1/I2: AI cannot approve)`) +} + +export class AuthorityService { + constructor( + readonly permits: Repository = new MemoryRepository(PermitSchema, (p) => p.id), + private readonly idGen: () => string = () => `permit-${Math.random().toString(36).slice(2, 10)}`, + ) {} + + /** + * Fresh-state check then permit. Denial reasons are exhaustive so the case + * view can show *why* an approval went stale. + */ + authorize(proposal: Proposal, basis: ApprovalBasis, ctx: FreshCheckContext): ExecutionPermit | StaleDenial { + const reasons: string[] = [] + const ledgerVersion = ctx.ledger.read().version + + if (basis.approvals.length === 0 && !basis.envelopeMatch?.within) { + reasons.push('no approval basis: neither human approval nor envelope auto-approval') + } + for (const a of basis.approvals) { + if (a.decision !== 'APPROVE') reasons.push(`approval ${a.id} is ${a.decision}`) + if (a.proposal_digest !== proposal.digest) reasons.push(`approval ${a.id} bound to digest ${a.proposal_digest.slice(0, 8)}…, proposal is ${proposal.digest.slice(0, 8)}…`) + if (ctx.now < a.validity.from || ctx.now > a.validity.to) reasons.push(`approval ${a.id} validity [${a.validity.from}, ${a.validity.to}] does not cover ${ctx.now}`) + if (a.evidence_versions.ledger_version !== ledgerVersion) reasons.push(`ledger version ${a.evidence_versions.ledger_version} at approval, now ${ledgerVersion}`) + if (a.evidence_versions.policy_pack_version !== ctx.currentPolicyPackVersion) reasons.push(`policy pack ${a.evidence_versions.policy_pack_version} at approval, now ${ctx.currentPolicyPackVersion}`) + } + if (basis.envelopeMatch?.within) { + if (basis.envelopeMatch.proposal_digest !== proposal.digest) reasons.push('envelope match bound to a different digest') + if (proposal.lineage.ledger_version !== ledgerVersion) reasons.push(`ledger version ${proposal.lineage.ledger_version} at auto-approval, now ${ledgerVersion}`) + if (proposal.lineage.policy_pack_version !== ctx.currentPolicyPackVersion) reasons.push(`policy pack ${proposal.lineage.policy_pack_version} at check, now ${ctx.currentPolicyPackVersion}`) + } + if (ctx.offlineResources.length > 0) reasons.push(`resources offline since approval: ${ctx.offlineResources.join(', ')}`) + + if (reasons.length > 0) return { proposal_digest: proposal.digest, reasons, checked_at: ctx.now } + + // Effect limits: narrowest of approval scopes; energy actually offered otherwise. + const limits: Record = {} + for (const a of basis.approvals) for (const [k, v] of Object.entries(a.scope.limits)) { + limits[k] = limits[k] === undefined || new Decimal(v).lt(limits[k]!) ? v : limits[k]! + } + if (proposal.type === 'BID' && limits['max_energy_mwh'] === undefined) { + limits['max_energy_mwh'] = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0)).toString() + } + const issuedAt = new Date(ctx.now) + let expires = new Date(issuedAt.getTime() + ctx.permitTtlMs) + for (const a of basis.approvals) if (new Date(a.validity.to) < expires) expires = new Date(a.validity.to) + + const permit: ExecutionPermit = { + id: this.idGen(), + proposal_digest: proposal.digest, + issued_at: issuedAt.toISOString(), + expires_at: expires.toISOString(), + effect_limits: limits, + revoked_at: null, + issuer: 'authority-service', + } + this.permits.put(permit) + return permit + } + + revoke(permitId: string, reason: string, now: string): void { + const row = this.permits.get(permitId) + if (!row) throw new Error(`permit ${permitId} not found`) + if (row.value.revoked_at) return + this.permits.put({ ...row.value, revoked_at: now }, row.version) + void reason + } + + /** Gateway-side validation (I4): digest match, in window, not revoked, on record. */ + validate(permit: ExecutionPermit, proposal: Proposal, now: string): string[] { + const reasons: string[] = [] + const stored = this.permits.get(permit.id)?.value + if (!stored) reasons.push(`permit ${permit.id} not on record`) + else if (stored.revoked_at) reasons.push(`permit ${permit.id} revoked at ${stored.revoked_at}`) + if (permit.proposal_digest !== proposal.digest) reasons.push('permit digest does not match proposal') + if (now < permit.issued_at || now >= permit.expires_at) reasons.push(`permit expired at ${permit.expires_at} (now ${now})`) + if (proposal.type === 'BID' && permit.effect_limits['max_energy_mwh'] !== undefined) { + const total = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0)) + if (total.gt(permit.effect_limits['max_energy_mwh'])) reasons.push(`energy ${total.toString()} exceeds permit limit ${permit.effect_limits['max_energy_mwh']}`) + } + return reasons + } +} + +export const isStaleDenial = (x: ExecutionPermit | StaleDenial): x is StaleDenial => 'reasons' in x diff --git a/packages/services/src/casedesk.ts b/packages/services/src/casedesk.ts new file mode 100644 index 0000000..707afb2 --- /dev/null +++ b/packages/services/src/casedesk.ts @@ -0,0 +1,180 @@ +import type { Approval, CaseKind, DecisionCase, ExecutionPermit, Proposal, ToolCallRef } from '@vpp/domain' +import { Approval as ApprovalSchema, DecisionCase as DecisionCaseSchema, Proposal as ProposalSchema } from '@vpp/domain' +import type { AuthorityService } from './authority.js' +import type { EventBus } from './events.js' +import { MemoryRepository } from './relational.js' +import type { Repository } from './relational.js' +import type { SnapshotStore } from './snapshot.js' + +/** + * Case Desk (docs/02 §5, ADR-0008): the operator's unit of accountability. + * A case references proposals/approvals/permits by id — never copies — and + * the inbox and case view are projections over those stores plus the event + * log. Approval itself is a workflow resume (docs/09 §2); the desk only + * records the human decision and hands it to the runtime's resume hook. + */ +export interface PendingApproval { + proposal_id: string + proposal_digest: string + case_id: string + run_id: string + required_level: string + reasons: string[] + since: string +} + +export interface CaseView { + case: DecisionCase + proposals: Array<{ proposal: Proposal; pending: PendingApproval | null }> + approvals: Approval[] + permits: ExecutionPermit[] + events: Array<{ event_id: string; event_type: string; occurred_at: string }> +} + +export interface LineageExpansion { + proposal_id: string + digest: string + ledger_version: number + policy_pack_version: string + tool_calls: Array + data_refs: Array<{ ref: string; present: boolean }> +} + +export interface CaseDeskDeps { + cases?: Repository + proposals?: Repository + approvals?: Repository + pending?: Repository + authority: AuthorityService + snapshots: SnapshotStore + events: EventBus + clock: () => string +} + +export class CaseDeskService { + readonly cases: Repository + readonly proposals: Repository + readonly approvals: Repository + readonly pending: Repository + + constructor(private readonly deps: CaseDeskDeps) { + this.cases = deps.cases ?? new MemoryRepository(DecisionCaseSchema, (c) => c.id) + this.proposals = deps.proposals ?? new MemoryRepository(ProposalSchema, (p) => p.id) + this.approvals = deps.approvals ?? new MemoryRepository(ApprovalSchema, (a) => a.id) + this.pending = deps.pending ?? new MemoryRepository(PendingApprovalSchema, (p) => p.proposal_id) + } + + open(input: { id: string; kind: CaseKind; objective: string; owner: string; deadline: string }): DecisionCase { + const now = this.deps.clock() + const c: DecisionCase = { + id: input.id, + kind: input.kind, + objective: input.objective, + owner: input.owner, + deadline: input.deadline, + status: 'OPEN', + refs: { evidence: [], proposals: [], approvals: [], permits: [], scenarios: [], outcome: null }, + opened_at: now, + closed_at: null, + } + this.cases.put(c) + this.deps.events.append({ event_type: 'CaseOpened', payload: { case_id: c.id, kind: c.kind }, correlation_id: c.id }) + return c + } + + private update(caseId: string, fn: (c: DecisionCase) => DecisionCase): DecisionCase { + const row = this.cases.get(caseId) + if (!row) throw new Error(`case ${caseId} not found`) + return this.cases.put(fn(structuredClone(row.value)), row.version).value + } + + addEvidence(caseId: string, refs: string[]): void { + this.update(caseId, (c) => ({ ...c, refs: { ...c.refs, evidence: [...new Set([...c.refs.evidence, ...refs])] }, status: c.status === 'OPEN' ? 'IN_PROGRESS' : c.status })) + } + + /** Called by the lifecycle workflow whenever the proposal changes status. */ + recordProposal(caseId: string, proposal: Proposal): void { + this.proposals.put(proposal) + this.update(caseId, (c) => ({ + ...c, + status: c.status === 'OPEN' ? 'IN_PROGRESS' : c.status, + refs: { ...c.refs, proposals: [...new Set([...c.refs.proposals, proposal.id])] }, + })) + this.deps.events.append({ event_type: 'ProposalStatusChanged', payload: { proposal_id: proposal.id, digest: proposal.digest, status: proposal.status }, correlation_id: caseId, causation_id: proposal.id }) + } + + /** Lifecycle suspended at the envelope gate: surface it in the inbox. */ + requestApproval(p: PendingApproval): void { + this.pending.put(p) + this.update(p.case_id, (c) => ({ ...c, status: 'AWAITING_APPROVAL' })) + this.deps.events.append({ event_type: 'ApprovalRequested', payload: p, correlation_id: p.case_id, causation_id: p.proposal_id }) + } + + inbox(): PendingApproval[] { + return this.pending.list().map((r) => r.value).sort((a, b) => (a.since < b.since ? -1 : 1)) + } + + recordApproval(caseId: string, approval: Approval): void { + this.approvals.put(approval) + const pending = this.pending.list().find((r) => r.value.proposal_digest === approval.proposal_digest) + if (pending) this.pending.put({ ...pending.value, run_id: '' }) // keep for audit; runtime clears + this.update(caseId, (c) => ({ ...c, status: 'IN_PROGRESS', refs: { ...c.refs, approvals: [...new Set([...c.refs.approvals, approval.id])] } })) + this.deps.events.append({ event_type: 'HumanDecision', payload: approval, correlation_id: caseId, causation_id: approval.proposal_digest }) + } + + clearPending(proposalId: string): void { + const row = this.pending.get(proposalId) + if (row) this.pending.put({ ...row.value, run_id: '' }, row.version) + // MemoryRepository has no delete; a cleared entry is one with run_id ''. + } + + recordPermit(caseId: string, permit: ExecutionPermit): void { + this.update(caseId, (c) => ({ ...c, refs: { ...c.refs, permits: [...new Set([...c.refs.permits, permit.id])] } })) + } + + close(caseId: string, outcome: string | null, done: boolean): DecisionCase { + const c = this.update(caseId, (c) => ({ ...c, status: done ? 'CLOSED_DONE' : 'CLOSED_ABORTED', refs: { ...c.refs, outcome }, closed_at: this.deps.clock() })) + this.deps.events.append({ event_type: 'CaseClosed', payload: { case_id: caseId, status: c.status, outcome }, correlation_id: caseId }) + return c + } + + read(caseId: string): CaseView { + const row = this.cases.get(caseId) + if (!row) throw new Error(`case ${caseId} not found`) + const c = row.value + const pendingByProposal = new Map(this.pending.list().filter((r) => r.value.run_id !== '').map((r) => [r.value.proposal_id, r.value])) + return { + case: c, + proposals: c.refs.proposals.map((id) => ({ proposal: this.proposals.get(id)!.value, pending: pendingByProposal.get(id) ?? null })), + approvals: c.refs.approvals.map((id) => this.approvals.get(id)!.value), + permits: c.refs.permits.map((id) => this.deps.authority.permits.get(id)!.value), + events: this.deps.events.list({ correlation_id: caseId }).map((e) => ({ event_id: e.event_id, event_type: e.event_type, occurred_at: e.occurred_at })), + } + } + + /** P5 in the UI: conclusion → tool outputs → data sources. */ + expandLineage(proposalId: string): LineageExpansion { + const row = this.proposals.get(proposalId) + if (!row) throw new Error(`proposal ${proposalId} not found`) + const p = row.value + return { + proposal_id: p.id, + digest: p.digest, + ledger_version: p.lineage.ledger_version, + policy_pack_version: p.lineage.policy_pack_version, + tool_calls: p.lineage.tool_calls.map((tc) => ({ ...tc, inputs: this.deps.snapshots.get(tc.inputs_ref), outputs: this.deps.snapshots.get(tc.outputs_ref) })), + data_refs: p.lineage.data_refs.map((ref) => ({ ref, present: this.deps.snapshots.has(ref) })), + } + } +} + +import { z } from 'zod' +export const PendingApprovalSchema = z.object({ + proposal_id: z.string().min(1), + proposal_digest: z.string().min(1), + case_id: z.string().min(1), + run_id: z.string(), + required_level: z.string().min(1), + reasons: z.array(z.string()), + since: z.string().min(1), +}) diff --git a/packages/services/src/digest.ts b/packages/services/src/digest.ts new file mode 100644 index 0000000..0f56064 --- /dev/null +++ b/packages/services/src/digest.ts @@ -0,0 +1,12 @@ +import type { Proposal } from '@vpp/domain' +import { canonicalJson, contentRef } from './snapshot.js' + +/** + * Proposal digest (docs/03 §1): sha256 over canonical payload + lineage. It is + * what approvals and permits bind to (I3/I4) — any change to a number or to + * its provenance yields a different digest and therefore a different approval. + */ +export const proposalDigest = (p: Pick): string => + contentRef(canonicalJson({ payload: p.payload, lineage: p.lineage })) + +export const digestMatches = (p: Proposal): boolean => proposalDigest(p) === p.digest diff --git a/packages/services/src/envelope.ts b/packages/services/src/envelope.ts new file mode 100644 index 0000000..53f2c4a --- /dev/null +++ b/packages/services/src/envelope.ts @@ -0,0 +1,107 @@ +import type { ApprovalLevel, Curve96, Envelope, EnvelopeMatch, Proposal } from '@vpp/domain' +import { Envelope as EnvelopeSchema } from '@vpp/domain' +import { Decimal } from './decimal.js' +import { MemoryRepository } from './relational.js' +import type { Repository } from './relational.js' + +/** + * Envelope gate (docs/03 §3, P3 tiered autonomy). Human-approved envelopes + * define what may auto-approve; anything outside, or with no ACTIVE envelope, + * needs a human. Bound keys for BID (docs/03 §3, values OPEN-QUESTION B1): + * price_deviation_pct — |offer − baseline| / baseline ≤ pct, per interval + * max_energy_mwh — Σ quantities ≤ max + * The empty envelope set is the "everything manual" autonomy dial position. + */ +export interface EnvelopeContext { + now: string + /** Baseline the price bound is relative to (the P50 price forecast in lineage). */ + priceBaseline: Curve96 | null + /** Level required when no envelope applies. OPEN-QUESTION B3. */ + fallbackLevel?: ApprovalLevel +} + +export class EnvelopeService { + constructor(readonly repo: Repository = new MemoryRepository(EnvelopeSchema, (e) => e.id)) {} + + register(envelope: Envelope): void { + this.repo.put(envelope) + } + + match(proposal: Proposal, ctx: EnvelopeContext): EnvelopeMatch { + const fallback = ctx.fallbackLevel ?? 'L2' + const candidates = this.repo + .list() + .map((r) => r.value) + .filter( + (e) => + e.status === 'ACTIVE' && + e.scope.proposal_type === proposal.type && + e.scope.timescales.includes(proposal.timescale) && + e.validity.from <= ctx.now && + ctx.now <= e.validity.to, + ) + if (candidates.length === 0) { + return { + proposal_digest: proposal.digest, + within: false, + envelope_id: null, + required_level: fallback, + reasons: [`no ACTIVE envelope covers ${proposal.type}/${proposal.timescale} at ${ctx.now}`], + checked_at: ctx.now, + } + } + + const reasonsByEnvelope: string[] = [] + for (const env of candidates) { + const reasons = this.checkBounds(proposal, env, ctx) + if (reasons.length === 0) { + return { proposal_digest: proposal.digest, within: true, envelope_id: env.id, required_level: env.approval.level, reasons: [], checked_at: ctx.now } + } + reasonsByEnvelope.push(...reasons.map((r) => `${env.id}: ${r}`)) + } + return { + proposal_digest: proposal.digest, + within: false, + envelope_id: null, + required_level: fallback, + reasons: reasonsByEnvelope, + checked_at: ctx.now, + } + } + + private checkBounds(proposal: Proposal, env: Envelope, ctx: EnvelopeContext): string[] { + if (proposal.type !== 'BID') return ['unsupported proposal type for envelope v1'] + const reasons: string[] = [] + const maxEnergy = env.bounds['max_energy_mwh'] + if (maxEnergy !== undefined) { + const total = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0)) + if (total.gt(maxEnergy)) reasons.push(`daily energy ${total.toString()} > max_energy_mwh ${maxEnergy}`) + } + const pct = env.bounds['price_deviation_pct'] + if (pct !== undefined) { + if (!ctx.priceBaseline) { + reasons.push('price_deviation_pct bound requires a price baseline in lineage') + } else { + const tol = new Decimal(pct).div(100) + let worst: { i: number; dev: Decimal } | null = null + proposal.payload.prices_yuan_per_mwh.values.forEach((v, i) => { + const base = new Decimal(ctx.priceBaseline!.values[i]!) + const offer = new Decimal(v) + // A price-taker offer (0) never exceeds the baseline; only offers above baseline are risky. + if (offer.lte(base)) return + if (base.eq(0)) { + worst = { i, dev: new Decimal('Infinity' as unknown as number) } + return + } + const dev = offer.sub(base).div(base) + if (dev.gt(tol) && (!worst || dev.gt(worst.dev))) worst = { i, dev } + }) + if (worst) { + const w = worst as { i: number; dev: Decimal } + reasons.push(`interval ${w.i}: offer deviates ${w.dev.mul(100).toFixed(2)}% above baseline, bound ${pct}%`) + } + } + } + return reasons + } +} diff --git a/packages/services/src/events.ts b/packages/services/src/events.ts new file mode 100644 index 0000000..7934b9e --- /dev/null +++ b/packages/services/src/events.ts @@ -0,0 +1,104 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname } from 'node:path' +import type { EventEnvelope } from '@vpp/domain' +import { EventEnvelope as EventEnvelopeSchema } from '@vpp/domain' + +/** + * Event store + bus (P5 event sourcing, ADR-0004 outbox). Append-only log of + * EventEnvelopes; consumers subscribe by type. Phase-1 backing is a JSONL + * file (survives restarts, trivially auditable); the Postgres outbox adapter + * implements the same interface without leaking storage specifics. + */ +export interface EventFilter { + event_type?: string + correlation_id?: string +} + +export type EventHandler = (evt: EventEnvelope) => void | Promise + +export interface EventBus { + append(input: { + event_type: string + payload: unknown + correlation_id: string + causation_id?: string + schema_version?: string + }): EventEnvelope + list(filter?: EventFilter): EventEnvelope[] + subscribe(event_type: string, handler: EventHandler): () => void +} + +export class MemoryEventBus implements EventBus { + protected readonly events: EventEnvelope[] = [] + private readonly handlers = new Map>() + private seq = 0 + + constructor( + protected readonly clock: () => string = () => new Date().toISOString(), + protected readonly prefix = 'evt', + ) {} + + append(input: { + event_type: string + payload: unknown + correlation_id: string + causation_id?: string + schema_version?: string + }): EventEnvelope { + const evt: EventEnvelope = EventEnvelopeSchema.parse({ + event_id: `${this.prefix}-${String(++this.seq).padStart(6, '0')}`, + event_type: input.event_type, + schema_version: input.schema_version ?? '1.0.0', + occurred_at: this.clock(), + causation_id: input.causation_id ?? input.correlation_id, + correlation_id: input.correlation_id, + payload: input.payload, + }) + this.persist(evt) + this.events.push(evt) + for (const h of this.handlers.get(evt.event_type) ?? []) void h(evt) + for (const h of this.handlers.get('*') ?? []) void h(evt) + return evt + } + + protected persist(_evt: EventEnvelope): void {} + + list(filter: EventFilter = {}): EventEnvelope[] { + return this.events.filter( + (e) => + (filter.event_type === undefined || e.event_type === filter.event_type) && + (filter.correlation_id === undefined || e.correlation_id === filter.correlation_id), + ) + } + + subscribe(event_type: string, handler: EventHandler): () => void { + const set = this.handlers.get(event_type) ?? new Set() + set.add(handler) + this.handlers.set(event_type, set) + return () => set.delete(handler) + } + + protected load(events: EventEnvelope[]): void { + this.events.push(...events) + this.seq = events.length + } +} + +/** JSONL-backed bus: every appended event is fsync'd to disk before handlers run. */ +export class FileEventBus extends MemoryEventBus { + constructor( + private readonly path: string, + clock?: () => string, + ) { + super(clock, 'evt') + mkdirSync(dirname(path), { recursive: true }) + if (existsSync(path)) { + const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean) + this.load(lines.map((l) => EventEnvelopeSchema.parse(JSON.parse(l)))) + } + } + + protected override persist(evt: EventEnvelope): void { + appendFileSync(this.path, JSON.stringify(evt) + '\n') + } +} diff --git a/packages/services/src/gateway.ts b/packages/services/src/gateway.ts new file mode 100644 index 0000000..8dc7988 --- /dev/null +++ b/packages/services/src/gateway.ts @@ -0,0 +1,75 @@ +import { mkdirSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import type { BidExportFile, ExecutionPermit, ExecutionReceipt, Proposal } from '@vpp/domain' +import { BidExportFile as BidExportFileSchema, ExecutionReceipt as ReceiptSchema } from '@vpp/domain' +import type { AuthorityService } from './authority.js' +import { MemoryRepository } from './relational.js' +import type { Repository } from './relational.js' + +export class GatewayRejected extends Error { + constructor(readonly reasons: string[]) { + super(`gateway rejected: ${reasons.join('; ')}`) + this.name = 'GatewayRejected' + } +} + +/** + * Gateway port (docs/03 §2.2, docs/11 §1.1): accepts only (Proposal, Permit) + * pairs, validates through the authority service, and is idempotent by + * (digest, permit) — re-dispatch returns the persisted receipt (I5). + */ +export interface GatewayPort { + dispatch(proposal: Proposal, permit: ExecutionPermit, now: string): ExecutionReceipt +} + +/** + * M3 release path: bid file for manual upload to the trading platform — the + * degraded channel by design (docs/06, ROADMAP M3). The programmatic channel + * is phase 2 (D2) and will implement the same port. + */ +export class FileExportGateway implements GatewayPort { + constructor( + private readonly authority: AuthorityService, + private readonly exportDir: string, + readonly receipts: Repository = new MemoryRepository(ReceiptSchema, (r) => r.idempotency_key), + ) { + mkdirSync(exportDir, { recursive: true }) + } + + dispatch(proposal: Proposal, permit: ExecutionPermit, now: string): ExecutionReceipt { + const key = `${proposal.digest}:${permit.id}` + const existing = this.receipts.get(key) + if (existing) return existing.value + + const reasons = this.authority.validate(permit, proposal, now) + if (reasons.length > 0) throw new GatewayRejected(reasons) + if (proposal.type !== 'BID') throw new GatewayRejected([`file export supports BID only, got ${proposal.type}`]) + + const file: BidExportFile = BidExportFileSchema.parse({ + format_version: '1.0.0', + proposal_id: proposal.id, + proposal_digest: proposal.digest, + permit_id: permit.id, + proposal_type: proposal.type, + market_date: proposal.payload.market_date, + prices_yuan_per_mwh: proposal.payload.prices_yuan_per_mwh, + quantities_mwh: proposal.payload.quantities_mwh, + exported_at: now, + }) + const name = `bid-${file.market_date}-${proposal.id}-${permit.id}.json` + const path = join(this.exportDir, name) + if (!existsSync(path)) writeFileSync(path, JSON.stringify(file, null, 2) + '\n', { flag: 'wx' }) + + const receipt: ExecutionReceipt = { + receipt_id: `rcpt-${proposal.id}-${permit.id}`, + proposal_digest: proposal.digest, + permit_id: permit.id, + channel: 'FILE_EXPORT', + idempotency_key: key, + artifact_ref: path, + accepted_at: now, + } + this.receipts.put(receipt) + return receipt + } +} diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index 81d259f..bf257bf 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -5,3 +5,13 @@ export * from './timeseries.js' export * from './relational.js' export * from './ingest.js' export { Decimal } from './decimal.js' +export * from './digest.js' +export * from './policy.js' +export * from './envelope.js' +export * from './authority.js' +export * from './gateway.js' +export * from './events.js' +export * from './lineage.js' +export * from './simulation.js' +export * from './casedesk.js' +export * from './skillclient.js' diff --git a/packages/services/src/lineage.ts b/packages/services/src/lineage.ts new file mode 100644 index 0000000..1262276 --- /dev/null +++ b/packages/services/src/lineage.ts @@ -0,0 +1,120 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { BidPayload, BidProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain' +import { BidPayload as BidPayloadSchema, Curve96, DecimalString } from '@vpp/domain' +import { proposalDigest } from './digest.js' +import type { SnapshotStore } from './snapshot.js' + +/** + * Lineage recorder (docs/09 §4): every skill call records + * {tool, version, inputs_ref, outputs_ref}; the buffer travels with the + * workflow run via AsyncLocalStorage and is frozen into the Proposal. + */ +export class LineageRecorder { + private readonly als = new AsyncLocalStorage<{ calls: ToolCallRef[]; dataRefs: Set }>() + + run(fn: () => T): T { + return this.als.run({ calls: [], dataRefs: new Set() }, fn) + } + + record(call: ToolCallRef): void { + const store = this.als.getStore() + if (!store) throw new Error('lineage.record called outside a lineage.run scope') + store.calls.push(call) + } + + addDataRef(ref: string): void { + this.als.getStore()?.dataRefs.add(ref) + } + + current(): { tool_calls: ToolCallRef[]; data_refs: string[] } { + const store = this.als.getStore() + if (!store) throw new Error('lineage.current called outside a lineage.run scope') + return { tool_calls: [...store.calls], data_refs: [...store.dataRefs].sort() } + } +} + +/** Wrap any deterministic skill call so it is snapshotted and recorded. */ +export function withLineage( + lineage: LineageRecorder, + snapshots: SnapshotStore, + def: { tool: string; version: string; idGen: () => string }, + call: (input: I) => O, +): (input: I) => { output: O; call: ToolCallRef } { + return (input: I) => { + const inputs_ref = snapshots.put(input) + const output = call(input) + const outputs_ref = snapshots.put(output) + const ref: ToolCallRef = { tool_call_id: def.idGen(), tool: def.tool, version: def.version, inputs_ref, outputs_ref } + lineage.record(ref) + return { output, call: ref } + } +} + +export class LineageRefError extends Error { + constructor(message: string) { + super(message) + this.name = 'LineageRefError' + } +} + +/** Dereference {tool_call_id, path} into the recorded output snapshot. */ +export function resolveRef(ref: LineageRef, calls: ToolCallRef[], snapshots: SnapshotStore): unknown { + const call = calls.find((c) => c.tool_call_id === ref.tool_call_id) + if (!call) throw new LineageRefError(`tool call ${ref.tool_call_id} is not in this proposal's lineage`) + const output = snapshots.get(call.outputs_ref) + if (output === undefined) throw new LineageRefError(`output snapshot for ${ref.tool_call_id} missing`) + let node: unknown = output + for (const part of ref.path.split('.')) { + if (node === null || typeof node !== 'object') throw new LineageRefError(`path ${ref.path} does not resolve in ${ref.tool_call_id}`) + node = Array.isArray(node) ? node[Number(part)] : (node as Record)[part] + if (node === undefined) throw new LineageRefError(`path ${ref.path} does not resolve in ${ref.tool_call_id}`) + } + return node +} + +/** + * The P2 assembler (docs/09 §3): builds a Proposal from an LLM draft that + * contains only references. Numbers are dereferenced from lineage snapshots; + * a draft with a literal number is rejected by the schema before we get here. + */ +export function assembleBidProposal( + draft: BidProposalDraft, + ctx: { + id: string + originator: Proposal['originator'] + lineage: { tool_calls: ToolCallRef[]; data_refs: string[] } + ledger_version: number + policy_pack_version: string + snapshots: SnapshotStore + now: string + }, +): Proposal { + const prices = Curve96.parse(resolveRef(draft.prices_ref, ctx.lineage.tool_calls, ctx.snapshots)) + const quantities = Curve96.parse(resolveRef(draft.quantities_ref, ctx.lineage.tool_calls, ctx.snapshots)) + const revenue = DecimalString.parse(resolveRef(draft.expected_revenue_ref, ctx.lineage.tool_calls, ctx.snapshots)) + const payload: BidPayload = BidPayloadSchema.parse({ + kind: 'BID', + market_date: draft.market_date, + prices_yuan_per_mwh: prices, + quantities_mwh: quantities, + expected_revenue_yuan: revenue, + }) + const lineage = { + tool_calls: ctx.lineage.tool_calls, + data_refs: ctx.lineage.data_refs, + ledger_version: ctx.ledger_version, + policy_pack_version: ctx.policy_pack_version, + } + const base = { + id: ctx.id, + type: 'BID' as const, + timescale: 'DAY_AHEAD' as const, + originator: ctx.originator, + payload, + lineage, + envelope_ref: null, + status: 'DRAFT' as const, + created_at: ctx.now, + } + return { ...base, digest: proposalDigest(base) } +} diff --git a/packages/services/src/policy.ts b/packages/services/src/policy.ts new file mode 100644 index 0000000..ade79bf --- /dev/null +++ b/packages/services/src/policy.ts @@ -0,0 +1,210 @@ +import type { LedgerView, Proposal, ValidationResult } from '@vpp/domain' +import { digestMatches } from './digest.js' +import type { LedgerService } from './ledger.js' +import type { SnapshotStore } from './snapshot.js' +import { Decimal } from './decimal.js' + +/** + * Policy engine (docs/05 §4.1, docs/03 §2 规则校核): versioned, tested, + * executable rule packs. Rules are deterministic functions of the proposal + * and its evidence context; the pack version is frozen into lineage so any + * historical check is replayable (I7). Old packs stay in the registry. + */ +export interface RuleContext { + ledger: LedgerView + ledgerService: LedgerService + snapshots: SnapshotStore + now: string +} + +export interface Violation { + rule_id: string + severity: 'REJECT' | 'WARN' + message: string +} + +export interface Rule { + id: string + description: string + /** Knowledge-base entries this rule implements (explainability: rule → source). */ + source_refs: string[] + check(proposal: Proposal, ctx: RuleContext): Violation[] +} + +export interface PolicyPack { + id: string + version: string + effective: { from: string; to: string | null } + rules: Rule[] +} + +export class PolicyEngine { + private readonly packs = new Map() + + constructor(packs: PolicyPack[]) { + for (const p of packs) this.packs.set(`${p.id}@${p.version}`, p) + } + + pack(id: string, version: string): PolicyPack { + const p = this.packs.get(`${id}@${version}`) + if (!p) throw new Error(`policy pack ${id}@${version} not registered`) + return p + } + + /** Latest pack by version string order (versions are YYYY.MM, lexicographically ordered). */ + current(id: string): PolicyPack { + const versions = [...this.packs.values()].filter((p) => p.id === id).sort((a, b) => (a.version < b.version ? 1 : -1)) + if (versions.length === 0) throw new Error(`no policy pack registered for ${id}`) + return versions[0]! + } + + check(proposal: Proposal, packRef: { id: string; version: string }, ctx: RuleContext): ValidationResult { + const pack = this.pack(packRef.id, packRef.version) + const violations: Violation[] = [] + const evaluated: string[] = [] + for (const rule of pack.rules) { + evaluated.push(rule.id) + violations.push(...rule.check(proposal, ctx)) + } + return { + proposal_digest: proposal.digest, + policy_pack: { id: pack.id, version: pack.version }, + ok: !violations.some((v) => v.severity === 'REJECT'), + violations, + rules_evaluated: evaluated, + checked_at: ctx.now, + } + } +} + +// --------------------------------------------------------------------------- +// Hubei spot bidding pack v1. Numeric limits below are placeholders pending +// OPEN-QUESTION D5 (首批转为政策包的湖北规则清单) and A1/A4/A5 — they are named +// config so the values can be replaced without touching rule logic. +// --------------------------------------------------------------------------- + +export interface HubeiBidPackConfig { + // OPEN-QUESTION D5: Hubei day-ahead price floor/cap for VPP offers. + priceFloorYuanPerMwh: string + priceCapYuanPerMwh: string + /** Agents permitted to originate each proposal type (I2: operate ≠ approve ≠ administer). */ + originators: Record +} + +export const HUBEI_BID_PACK_PLACEHOLDER: HubeiBidPackConfig = { + priceFloorYuanPerMwh: '0', // OPEN-QUESTION D5 + priceCapYuanPerMwh: '1500', // OPEN-QUESTION D5 + originators: { BID: ['trading-agent', 'operator'] }, +} + +const reject = (rule_id: string, message: string): Violation => ({ rule_id, severity: 'REJECT', message }) + +export function hubeiSpotBiddingPack(version: string, cfg: HubeiBidPackConfig): PolicyPack { + return { + id: 'hubei-spot-bidding', + version, + effective: { from: '2026-01-01T00:00:00Z', to: null }, + rules: [ + { + id: 'digest-valid', + description: 'digest equals sha256(canonical payload + lineage)', + source_refs: ['docs/03 §1'], + check: (p) => (digestMatches(p) ? [] : [reject('digest-valid', 'digest does not match payload + lineage')]), + }, + { + id: 'bid-format', + description: '96 intervals, curves dated on market_date', + source_refs: ['docs/11 §3.3', 'D5'], + check: (p) => { + if (p.type !== 'BID') return [] + const out: Violation[] = [] + for (const [name, c] of [['prices_yuan_per_mwh', p.payload.prices_yuan_per_mwh], ['quantities_mwh', p.payload.quantities_mwh]] as const) { + if (c.date !== p.payload.market_date) out.push(reject('bid-format', `${name} dated ${c.date}, market_date is ${p.payload.market_date}`)) + if (c.values.length !== 96) out.push(reject('bid-format', `${name} has ${c.values.length} values`)) + } + if (p.timescale !== 'DAY_AHEAD') out.push(reject('bid-format', `BID must be DAY_AHEAD, got ${p.timescale}`)) + return out + }, + }, + { + id: 'price-limits', + description: 'every offer price within the market floor/cap', + source_refs: ['D5'], + check: (p) => { + if (p.type !== 'BID') return [] + const floor = new Decimal(cfg.priceFloorYuanPerMwh) + const cap = new Decimal(cfg.priceCapYuanPerMwh) + const out: Violation[] = [] + p.payload.prices_yuan_per_mwh.values.forEach((v, i) => { + const d = new Decimal(v) + if (d.lt(floor) || d.gt(cap)) out.push(reject('price-limits', `interval ${i}: price ${v} outside [${floor}, ${cap}]`)) + }) + return out.slice(0, 5) + }, + }, + { + id: 'quantity-non-negative', + description: 'offer quantities are non-negative', + source_refs: ['D5'], + check: (p) => + p.type === 'BID' && p.payload.quantities_mwh.values.some((v) => new Decimal(v).lt(0)) + ? [reject('quantity-non-negative', 'negative quantity in bid')] + : [], + }, + { + id: 'ledger-consistency', + description: 'lineage ledger version is current and daily energy is inside the cascade band (P7)', + source_refs: ['docs/00 §4', 'A5'], + check: (p, ctx) => { + if (p.type !== 'BID') return [] + const out: Violation[] = [] + if (p.lineage.ledger_version !== ctx.ledger.version) { + out.push(reject('ledger-consistency', `lineage ledger_version ${p.lineage.ledger_version} ≠ current ${ctx.ledger.version}`)) + } + try { + const b = ctx.ledgerService.dayAheadBounds(p.payload.market_date) + const total = p.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0)) + if (total.lt(b.daily_energy_min_mwh) || total.gt(b.daily_energy_max_mwh)) { + out.push(reject('ledger-consistency', `daily energy ${total.toString()} outside [${b.daily_energy_min_mwh}, ${b.daily_energy_max_mwh}]`)) + } + } catch (e) { + out.push(reject('ledger-consistency', (e as Error).message)) + } + return out + }, + }, + { + id: 'lineage-integrity', + description: 'every payload number is found verbatim in a referenced tool output (P2, machine-checkable)', + source_refs: ['docs/03 §1', 'docs/01 P2'], + check: (p, ctx) => { + if (p.type !== 'BID') return [] + if (p.lineage.tool_calls.length === 0) return [reject('lineage-integrity', 'no tool calls in lineage')] + const outputs: Record[] = [] + for (const tc of p.lineage.tool_calls) { + const out = ctx.snapshots.get(tc.outputs_ref) + if (out === undefined) return [reject('lineage-integrity', `tool call ${tc.tool_call_id}: output snapshot ${tc.outputs_ref.slice(0, 8)}… missing`)] + if (out && typeof out === 'object') outputs.push(out as Record) + } + const found = (pred: (o: Record) => boolean) => outputs.some(pred) + const eqCurve = (a: unknown, b: { values: string[] }) => + !!a && typeof a === 'object' && JSON.stringify((a as { values?: unknown }).values) === JSON.stringify(b.values) + const out: Violation[] = [] + if (!found((o) => eqCurve(o['prices_yuan_per_mwh'], p.payload.prices_yuan_per_mwh))) out.push(reject('lineage-integrity', 'prices_yuan_per_mwh not traceable to a tool output')) + if (!found((o) => eqCurve(o['quantities_mwh'], p.payload.quantities_mwh))) out.push(reject('lineage-integrity', 'quantities_mwh not traceable to a tool output')) + if (!found((o) => o['expected_revenue_yuan'] === p.payload.expected_revenue_yuan)) out.push(reject('lineage-integrity', 'expected_revenue_yuan not traceable to a tool output')) + return out + }, + }, + { + id: 'originator-permission', + description: 'originating agent is allowed to originate this proposal type (I2)', + source_refs: ['docs/03 §2', 'docs/06 §4'], + check: (p) => + (cfg.originators[p.type] ?? []).includes(p.originator.agent) + ? [] + : [reject('originator-permission', `${p.originator.agent} may not originate ${p.type}`)], + }, + ], + } +} diff --git a/packages/services/src/relational.ts b/packages/services/src/relational.ts index 9fc4154..22d446d 100644 --- a/packages/services/src/relational.ts +++ b/packages/services/src/relational.ts @@ -1,3 +1,5 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' import type { z } from 'zod' import { ResourceProfile as ResourceProfileSchema } from '@vpp/domain' import type { ResourceProfile } from '@vpp/domain' @@ -53,6 +55,11 @@ export class MemoryRepository implements Repository { return [...this.rows.keys()].sort().map((k) => this.get(k)!) } + /** Load a persisted row verbatim (versions preserved). */ + protected restore(row: Row): void { + this.rows.set(row.key, row) + } + put(value: T, expectedVersion?: number): Row { const parsed = this.schema.parse(value) const key = this.keyOf(parsed) @@ -76,3 +83,30 @@ export type ResourceRegistry = Repository export const createResourceRegistry = (clock?: () => string): ResourceRegistry => new MemoryRepository(ResourceProfileSchema, (r) => r.resource_id, clock) + +/** + * JSON-file-backed repository: full rewrite on every put (row counts in + * phase 1 are tiny: cases, envelopes, permits). Enough for "a suspended + * approval survives process restart" until the Postgres adapter (M3+). + */ +export class FsRepository extends MemoryRepository { + constructor( + private readonly path: string, + schema: z.ZodType, + keyOf: (value: T) => string, + clock?: () => string, + ) { + super(schema, keyOf, clock) + mkdirSync(dirname(path), { recursive: true }) + if (existsSync(path)) { + const rows = JSON.parse(readFileSync(path, 'utf8')) as Row[] + for (const r of rows) this.restore({ ...r, value: schema.parse(r.value) }) + } + } + + override put(value: T, expectedVersion?: number): Row { + const row = super.put(value, expectedVersion) + writeFileSync(this.path, JSON.stringify(this.list(), null, 1) + '\n') + return row + } +} diff --git a/packages/services/src/simulation.ts b/packages/services/src/simulation.ts new file mode 100644 index 0000000..e21b646 --- /dev/null +++ b/packages/services/src/simulation.ts @@ -0,0 +1,105 @@ +import type { ForecastBundle, Proposal, SimulationResult } from '@vpp/domain' +import { Decimal } from './decimal.js' + +/** + * Simulation port (docs/03 §2 仿真验证; docs/05 §2.3 收益情景回测). Bids are + * stress-tested against many price scenarios drawn around the forecast band; + * any alert escalates to a human — one vote, no exceptions. + * + * v1 is deterministic (seeded) and in-process. Scenario paths are built from + * the P10/P50/P90 band by a seeded uniform draw per interval, interpolated + * piecewise-linearly, with per-day correlated shocks so extreme days exist. + */ +export interface SimulationPort { + simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult +} + +export interface SimulationContext { + now: string + priceForecast: ForecastBundle | null + marginalCostYuanPerMwh: string + /** + * Alert when the 5th-percentile scenario revenue net of cost falls below + * −budget. OPEN-QUESTION B5 (单日亏损预算) — placeholder. + */ + worstCaseLossBudgetYuan: string + scenarioCount?: number + seed?: number +} + +/** Small deterministic PRNG (mulberry32) — enough for scenario sampling. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export class RevenueScenarioSimulator implements SimulationPort { + static readonly NAME = 'revenue-scenarios' + static readonly VERSION = '1.0.0' + + simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult { + const seed = ctx.seed ?? 20260315 + const n = ctx.scenarioCount ?? 1000 + const alerts: string[] = [] + const base = { proposal_digest: proposal.digest, kind: 'REVENUE_SCENARIOS' as const, simulator: { name: RevenueScenarioSimulator.NAME, version: RevenueScenarioSimulator.VERSION, seed }, simulated_at: ctx.now } + + if (proposal.type !== 'BID') { + return { ...base, alert: true, alerts: ['revenue simulation only supports BID'], metrics: {}, scenario_count: 0 } + } + if (!ctx.priceForecast) { + return { ...base, alert: true, alerts: ['no price forecast in lineage to simulate against'], metrics: {}, scenario_count: 0 } + } + + const p10 = ctx.priceForecast.quantiles.p10.values.map(Number) + const p50 = ctx.priceForecast.quantiles.p50.values.map(Number) + const p90 = ctx.priceForecast.quantiles.p90.values.map(Number) + const offers = proposal.payload.prices_yuan_per_mwh.values.map(Number) + const qty = proposal.payload.quantities_mwh.values.map(Number) + const cost = Number(ctx.marginalCostYuanPerMwh) + const rand = mulberry32(seed) + + const revenues: number[] = [] + for (let s = 0; s < n; s++) { + const dayShock = rand() // correlated across intervals: a "high day" or "low day" + let rev = 0 + for (let t = 0; t < 96; t++) { + const u = 0.5 * dayShock + 0.5 * rand() + // Map u∈[0,1] onto the band: 0→below P10, 0.5→P50, 1→above P90 (tails extend 50%). + let price: number + if (u < 0.1) price = p10[t]! - (0.1 - u) / 0.1 * 0.5 * (p50[t]! - p10[t]!) + else if (u < 0.5) price = p10[t]! + (u - 0.1) / 0.4 * (p50[t]! - p10[t]!) + else if (u < 0.9) price = p50[t]! + (u - 0.5) / 0.4 * (p90[t]! - p50[t]!) + else price = p90[t]! + (u - 0.9) / 0.1 * 0.5 * (p90[t]! - p50[t]!) + if (offers[t]! <= price) rev += (price - cost) * qty[t]! + } + revenues.push(rev) + } + revenues.sort((a, b) => a - b) + const pct = (p: number) => revenues[Math.min(n - 1, Math.floor(p * n))]! + const p05 = pct(0.05) + const worstLoss = Math.max(0, -revenues[0]!) + const budget = Number(ctx.worstCaseLossBudgetYuan) + if (p05 < -budget) alerts.push(`P05 scenario revenue ${p05.toFixed(2)} below loss budget −${budget}`) + if (qty.every((q) => q === 0)) alerts.push('bid offers zero energy in every interval') + + const money = (x: number) => new Decimal(x.toFixed(2)).toFixed(2) + return { + ...base, + alert: alerts.length > 0, + alerts, + metrics: { + revenue_p05_yuan: money(p05), + revenue_p50_yuan: money(pct(0.5)), + revenue_p95_yuan: money(pct(0.95)), + worst_loss_yuan: money(worstLoss), + }, + scenario_count: n, + } + } +} diff --git a/packages/evals/src/client.ts b/packages/services/src/skillclient.ts similarity index 100% rename from packages/evals/src/client.ts rename to packages/services/src/skillclient.ts diff --git a/packages/services/test/chain.test.ts b/packages/services/test/chain.test.ts new file mode 100644 index 0000000..e35fff3 --- /dev/null +++ b/packages/services/test/chain.test.ts @@ -0,0 +1,293 @@ +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { BidProposalDraft, Proposal as ProposalSchema } from '@vpp/domain' +import { AuthorityService, assertApproverIndependent, isStaleDenial } from '../src/authority.js' +import { CaseDeskService } from '../src/casedesk.js' +import { proposalDigest } from '../src/digest.js' +import { EnvelopeService } from '../src/envelope.js' +import { FileEventBus, MemoryEventBus } from '../src/events.js' +import { FileExportGateway, GatewayRejected } from '../src/gateway.js' +import { LineageRefError, assembleBidProposal } from '../src/lineage.js' +import { FsRepository } from '../src/relational.js' +import { RevenueScenarioSimulator } from '../src/simulation.js' +import { MemorySnapshotStore } from '../src/snapshot.js' +import { DATE, NOW, PACK_VERSION, approvalFor, curve, envelope, priceForecast, world } from './helpers.js' + +const HUMAN_ROLES = ['senior-trader', 'ops-lead', 'risk-officer'] +const freshCtx = (w: ReturnType, over: Record = {}) => ({ + now: NOW, ledger: w.ledger, currentPolicyPackVersion: PACK_VERSION, offlineResources: [], permitTtlMs: 3_600_000, ...over, +}) + +describe('policy engine: hubei-spot-bidding pack (each rule has a pass and a fail case)', () => { + it('passes a well-formed, traceable, in-band bid', () => { + const w = world() + const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx()) + expect(r.ok, JSON.stringify(r.violations)).toBe(true) + expect(r.rules_evaluated).toContain('lineage-integrity') + expect(r.policy_pack.version).toBe(PACK_VERSION) + }) + + it('digest-valid: a tampered payload no longer matches its digest', () => { + const w = world() + const tampered = { ...w.proposal, payload: { ...w.proposal.payload, expected_revenue_yuan: '999.00' } } + const r = w.engine.check(tampered, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx()) + expect(r.violations.map((v) => v.rule_id)).toContain('digest-valid') + }) + + it('price-limits: offer above the cap is rejected', () => { + const w = world({ price: '1500.01' }) + const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx()) + expect(r.ok).toBe(false) + expect(r.violations[0]!.rule_id).toBe('price-limits') + }) + + it('ledger-consistency: energy outside the cascade band, or a stale ledger version, is rejected', () => { + const over = world({ qty: '14.0' }) // 1344 MWh > 1260 + let r = over.engine.check(over.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, over.ruleCtx()) + expect(r.violations.some((v) => v.rule_id === 'ledger-consistency' && /outside/.test(v.message))).toBe(true) + + const w = world() + w.ledger.append({ id: 'x', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 }) + r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx()) + expect(r.violations.some((v) => v.rule_id === 'ledger-consistency' && /ledger_version/.test(v.message))).toBe(true) + }) + + it('lineage-integrity: a number not present in any referenced tool output is rejected (P2)', () => { + const w = world() + const forged = { ...w.proposal, payload: { ...w.proposal.payload, quantities_mwh: curve('12.6') } } + forged.digest = proposalDigest(forged) // even a re-digested forgery fails: the number has no provenance + const r = w.engine.check(forged, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx()) + expect(r.violations.map((v) => v.rule_id)).toContain('lineage-integrity') + expect(r.violations.map((v) => v.rule_id)).not.toContain('digest-valid') + }) + + it('originator-permission: an agent outside the allowlist may not originate a BID (I2)', () => { + const w = world({ agent: 'load-control-agent' }) + const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx()) + expect(r.violations.map((v) => v.rule_id)).toContain('originator-permission') + }) + + it('old pack versions stay registered for replay (I7)', () => { + const w = world() + expect(w.engine.current('hubei-spot-bidding').version).toBe(PACK_VERSION) + expect(() => w.engine.pack('hubei-spot-bidding', '2025.01')).toThrow(/not registered/) + }) +}) + +describe('P2 assembler: numbers enter a proposal only by lineage reference', () => { + it('rejects a draft carrying a literal number at the schema boundary', () => { + const draft = { market_date: DATE, prices_ref: { tool_call_id: 'tc-1', path: 'p' }, quantities_ref: { tool_call_id: 'tc-1', path: 'q' }, expected_revenue_ref: '510600.00', rationale: 'x' } + expect(BidProposalDraft.safeParse(draft).success).toBe(false) + }) + + it('rejects a reference to a tool call that is not in lineage', () => { + const w = world() + expect(() => + assembleBidProposal( + { market_date: DATE, prices_ref: { tool_call_id: 'tc-99', path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: 'tc-99', path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: 'tc-99', path: 'expected_revenue_yuan' }, rationale: 'x' }, + { id: 'p', originator: w.proposal.originator, lineage: w.proposal.lineage, ledger_version: 1, policy_pack_version: PACK_VERSION, snapshots: w.snapshots, now: NOW }, + ), + ).toThrow(LineageRefError) + }) + + it('assembled proposal validates against the domain schema and its digest is reproducible', () => { + const w = world() + expect(ProposalSchema.safeParse(w.proposal).success).toBe(true) + expect(proposalDigest(w.proposal)).toBe(w.proposal.digest) + expect(w.proposal.lineage.tool_calls.map((t) => t.tool)).toEqual(['price-forecast', 'bid-optimization-milp']) + }) +}) + +describe('envelope gate (P3)', () => { + it('empty envelope set → everything goes to a human (autonomy dial at zero)', () => { + const w = world() + const m = new EnvelopeService().match(w.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 }) + expect(m.within).toBe(false) + expect(m.envelope_id).toBeNull() + expect(m.required_level).toBe('L2') + }) + + it('auto-approves inside bounds and reports which envelope', () => { + const w = world() + const svc = new EnvelopeService() + svc.register(envelope({ price_deviation_pct: '5.0', max_energy_mwh: '1500.0' })) + const m = svc.match(w.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 }) + expect(m.within).toBe(true) + expect(m.envelope_id).toBe('env-bid-001') + }) + + it('exceeding a bound, an expired window, or SUSPENDED status all fall out of the envelope', () => { + const w = world() + const outOfEnergy = new EnvelopeService(); outOfEnergy.register(envelope({ max_energy_mwh: '1000.0' })) + expect(outOfEnergy.match(w.proposal, { now: NOW, priceBaseline: null }).reasons[0]).toMatch(/max_energy_mwh/) + + const expired = new EnvelopeService(); expired.register(envelope({ max_energy_mwh: '1500.0' }, { validity: { from: '2026-01-01T00:00:00Z', to: '2026-02-01T00:00:00Z' } })) + expect(expired.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false) + + const suspended = new EnvelopeService(); suspended.register(envelope({ max_energy_mwh: '1500.0' }, { status: 'SUSPENDED' })) + expect(suspended.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false) + + const priced = world({ price: '450.00' }) // 5.8% above 425.50 baseline + const tight = new EnvelopeService(); tight.register(envelope({ price_deviation_pct: '5.0' })) + expect(tight.match(priced.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 }).reasons[0]).toMatch(/deviates/) + }) +}) + +describe('approval identity (I1/I2)', () => { + it('rejects the originating agent, any tool in the chain, and non-human roles', () => { + const w = world() + expect(() => assertApproverIndependent(w.proposal, { id: 'trading-agent', role: 'senior-trader' }, HUMAN_ROLES)).toThrow(/origination chain/) + expect(() => assertApproverIndependent(w.proposal, { id: 'bid-optimization-milp', role: 'senior-trader' }, HUMAN_ROLES)).toThrow(/origination chain/) + expect(() => assertApproverIndependent(w.proposal, { id: 'user-x', role: 'agent' }, HUMAN_ROLES)).toThrow(/AI cannot approve/) + expect(() => assertApproverIndependent(w.proposal, { id: 'user-x', role: 'senior-trader' }, HUMAN_ROLES)).not.toThrow() + }) +}) + +describe('authority: fresh check, permits, revocation (I3/I4)', () => { + it('issues a permit bound to the digest with the narrowest effect limits', () => { + const w = world() + const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1, { scope: { effect_type: 'BID', limits: { max_energy_mwh: '1250.0' } } })], envelopeMatch: null }, freshCtx(w)) + expect(isStaleDenial(permit)).toBe(false) + if (isStaleDenial(permit)) throw new Error() + expect(permit.proposal_digest).toBe(w.proposal.digest) + expect(permit.effect_limits['max_energy_mwh']).toBe('1250.0') + expect(permit.expires_at).toBe('2026-03-14T09:00:00.000Z') + }) + + it('goes STALE when the digest, window, ledger, policy pack or resources changed since approval (I3)', () => { + const w = world() + const a = approvalFor(w.proposal, 1) + const stale = (basis: Parameters[1], ctx = freshCtx(w)) => { + const r = w.authority.authorize(w.proposal, basis, ctx) + if (!isStaleDenial(r)) throw new Error('expected stale') + return r.reasons.join(' | ') + } + expect(stale({ approvals: [{ ...a, proposal_digest: 'e'.repeat(64) }], envelopeMatch: null })).toMatch(/digest/) + expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { now: '2026-03-15T10:00:00Z' }))).toMatch(/validity/) + expect(stale({ approvals: [{ ...a, evidence_versions: { ...a.evidence_versions, ledger_version: 0 } }], envelopeMatch: null })).toMatch(/ledger version/) + expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { currentPolicyPackVersion: '2026.04' }))).toMatch(/policy pack/) + expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { offlineResources: ['res-storage-01'] }))).toMatch(/offline/) + expect(stale({ approvals: [{ ...a, decision: 'REJECT' }], envelopeMatch: null })).toMatch(/REJECT/) + expect(stale({ approvals: [], envelopeMatch: null })).toMatch(/no approval basis/) + }) + + it('an auto-approval is a valid basis only while its evidence is current', () => { + const w = world() + const match = { proposal_digest: w.proposal.digest, within: true, envelope_id: 'env-bid-001', required_level: 'L1' as const, reasons: [], checked_at: NOW } + expect(isStaleDenial(w.authority.authorize(w.proposal, { approvals: [], envelopeMatch: match }, freshCtx(w)))).toBe(false) + w.ledger.append({ id: 'y', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 }) + expect(isStaleDenial(w.authority.authorize(w.proposal, { approvals: [], envelopeMatch: match }, freshCtx(w)))).toBe(true) + }) + + it('validate: expiry, revocation, digest mismatch and unknown permits are all rejected (I4)', () => { + const w = world() + const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w)) + if (isStaleDenial(permit)) throw new Error() + expect(w.authority.validate(permit, w.proposal, NOW)).toEqual([]) + expect(w.authority.validate(permit, w.proposal, '2026-03-14T09:00:00.000Z')[0]).toMatch(/expired/) + expect(w.authority.validate({ ...permit, proposal_digest: 'e'.repeat(64) }, w.proposal, NOW)[0]).toMatch(/digest/) + expect(w.authority.validate({ ...permit, id: 'permit-forged' }, w.proposal, NOW)[0]).toMatch(/not on record/) + w.authority.revoke(permit.id, 'storage offline', '2026-03-14T08:30:00Z') + expect(w.authority.validate(permit, w.proposal, NOW)[0]).toMatch(/revoked/) + }) +}) + +describe('gateway: only (Proposal, Permit) pairs; idempotent receipts (I4/I5)', () => { + it('exports a bid file once and returns the same receipt on re-dispatch', () => { + const w = world() + const dir = mkdtempSync(join(tmpdir(), 'vpp-export-')) + const gw = new FileExportGateway(w.authority, dir) + const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w)) + if (isStaleDenial(permit)) throw new Error() + const r1 = gw.dispatch(w.proposal, permit, NOW) + const r2 = gw.dispatch(w.proposal, permit, '2026-03-14T08:05:00Z') + expect(r2).toEqual(r1) + expect(gw.receipts.list()).toHaveLength(1) + const file = JSON.parse(readFileSync(r1.artifact_ref, 'utf8')) + expect(file.proposal_digest).toBe(w.proposal.digest) + expect(file.quantities_mwh.values[0]).toBe('12.5') + }) + + it('refuses a late release, a revoked permit, and a bare proposal with a forged permit', () => { + const w = world() + const gw = new FileExportGateway(w.authority, mkdtempSync(join(tmpdir(), 'vpp-export-'))) + const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w)) + if (isStaleDenial(permit)) throw new Error() + expect(() => gw.dispatch(w.proposal, permit, '2026-03-15T09:00:01Z')).toThrow(GatewayRejected) + expect(() => gw.dispatch(w.proposal, { ...permit, id: 'permit-forged' }, NOW)).toThrow(/not on record/) + w.authority.revoke(permit.id, 'kill switch', NOW) + expect(() => gw.dispatch(w.proposal, permit, NOW)).toThrow(/revoked/) + expect(gw.receipts.list()).toHaveLength(0) + }) +}) + +describe('revenue scenario simulation', () => { + it('is deterministic for a seed and does not alert on a sane price-taker bid', () => { + const w = world() + const sim = new RevenueScenarioSimulator() + const ctx = { now: NOW, priceForecast: priceForecast(), marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '100000' } + const a = sim.simulate(w.proposal, ctx) + const b = sim.simulate(w.proposal, ctx) + expect(a).toEqual(b) + expect(a.alert).toBe(false) + expect(a.scenario_count).toBe(1000) + expect(Number(a.metrics['revenue_p05_yuan'])).toBeLessThan(Number(a.metrics['revenue_p95_yuan'])) + }) + + it('alerts when the P05 scenario breaches the loss budget, and when there is no forecast', () => { + const w = world() + const sim = new RevenueScenarioSimulator() + const costly = sim.simulate(w.proposal, { now: NOW, priceForecast: priceForecast(), marginalCostYuanPerMwh: '600', worstCaseLossBudgetYuan: '1000' }) + expect(costly.alert).toBe(true) + expect(costly.alerts[0]).toMatch(/loss budget/) + expect(sim.simulate(w.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '1' }).alerts[0]).toMatch(/no price forecast/) + }) +}) + +describe('case desk projections', () => { + it('opens a case, tracks proposal/approval/permit refs, and expands lineage to tool IO', () => { + const w = world() + const desk = new CaseDeskService({ authority: w.authority, snapshots: w.snapshots, events: w.events, clock: () => NOW }) + const c = desk.open({ id: 'case-1', kind: 'DAY_AHEAD_BID', objective: 'D bid', owner: 'user-trader-01', deadline: '2026-03-14T09:00:00Z' }) + desk.recordProposal(c.id, w.proposal) + desk.requestApproval({ proposal_id: w.proposal.id, proposal_digest: w.proposal.digest, case_id: c.id, run_id: 'run-1', required_level: 'L2', reasons: ['no envelope'], since: NOW }) + expect(desk.inbox().map((p) => p.run_id)).toEqual(['run-1']) + expect(desk.read(c.id).case.status).toBe('AWAITING_APPROVAL') + + desk.recordApproval(c.id, approvalFor(w.proposal, 1)) + const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w)) + if (isStaleDenial(permit)) throw new Error() + desk.recordPermit(c.id, permit) + const view = desk.read(c.id) + expect(view.approvals[0]!.approver.id).toBe('user-trader-01') + expect(view.permits[0]!.id).toBe('permit-001') + expect(view.events.map((e) => e.event_type)).toEqual(['CaseOpened', 'ProposalStatusChanged', 'ApprovalRequested', 'HumanDecision']) + + const lin = desk.expandLineage(w.proposal.id) + expect(lin.tool_calls[1]!.tool).toBe('bid-optimization-milp') + expect((lin.tool_calls[1]!.outputs as { expected_revenue_yuan: string }).expected_revenue_yuan).toBe('510600.00') + }) +}) + +describe('persistence for restart survival', () => { + it('FsRepository and FileEventBus reload their state in a new instance', () => { + const dir = mkdtempSync(join(tmpdir(), 'vpp-persist-')) + const w = world() + const repo = new FsRepository(join(dir, 'proposals.json'), ProposalSchema, (p) => p.id) + repo.put(w.proposal) + repo.put({ ...w.proposal, status: 'PENDING_HUMAN' }, 1) + const reloaded = new FsRepository(join(dir, 'proposals.json'), ProposalSchema, (p) => p.id) + expect(reloaded.get('prop-001')?.version).toBe(2) + expect(reloaded.get('prop-001')?.value.status).toBe('PENDING_HUMAN') + + const bus = new FileEventBus(join(dir, 'events.jsonl'), () => NOW) + bus.append({ event_type: 'A', payload: { n: 1 }, correlation_id: 'c1' }) + bus.append({ event_type: 'B', payload: { n: 2 }, correlation_id: 'c1', causation_id: 'evt-000001' }) + const again = new FileEventBus(join(dir, 'events.jsonl'), () => NOW) + expect(again.list().map((e) => e.event_type)).toEqual(['A', 'B']) + expect(again.append({ event_type: 'C', payload: {}, correlation_id: 'c2' }).event_id).toBe('evt-000003') + expect(new MemoryEventBus().list()).toEqual([]) + }) +}) diff --git a/packages/services/test/helpers.ts b/packages/services/test/helpers.ts new file mode 100644 index 0000000..d05c4b0 --- /dev/null +++ b/packages/services/test/helpers.ts @@ -0,0 +1,99 @@ +import type { Approval, Curve96, Envelope, ForecastBundle, Proposal } from '@vpp/domain' +import { AuthorityService } from '../src/authority.js' +import { MemoryEventBus } from '../src/events.js' +import { LedgerService } from '../src/ledger.js' +import { LineageRecorder, assembleBidProposal, withLineage } from '../src/lineage.js' +import { HUBEI_BID_PACK_PLACEHOLDER, PolicyEngine, hubeiSpotBiddingPack } from '../src/policy.js' +import { MemorySnapshotStore } from '../src/snapshot.js' + +export const NOW = '2026-03-14T08:00:00Z' +export const DATE = '2026-03-15' +export const PACK_VERSION = '2026.03' + +export const curve = (v: string | string[], date = DATE): Curve96 => ({ + interval_minutes: 15, + date, + values: Array.isArray(v) ? v : Array.from({ length: 96 }, () => v), +}) + +export const priceForecast = (p50 = '425.50'): ForecastBundle => ({ + id: 'fc-price', + kind: 'PRICE', + market_date: DATE, + unit: 'yuan_per_mwh', + quantiles: { p10: curve('360.00'), p50: curve(p50), p90: curve('490.00') }, + model: { name: 'price-forecast', version: '1.0.0' }, + features_snapshot_ref: 'a'.repeat(64), + generated_at: NOW, +}) + +/** A consistent little world: ledger with a monthly contract, a recorded MILP output, a proposal built from refs. */ +export function world(opts: { qty?: string; price?: string; agent?: string } = {}) { + const snapshots = new MemorySnapshotStore() + const ledger = new LedgerService({ daMonthlyDeviationBand: '0.05', clock: () => NOW }) + ledger.append({ + id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', + energy_mwh: '37200.0', curve: null, source_ref: 'contract-2026-03-001', expected_version: 0, + }) // 1200 MWh/day → band [1140, 1260] + const engine = new PolicyEngine([hubeiSpotBiddingPack(PACK_VERSION, HUBEI_BID_PACK_PLACEHOLDER)]) + const lineage = new LineageRecorder() + const events = new MemoryEventBus(() => NOW) + const authority = new AuthorityService(undefined, () => 'permit-001') + + let n = 0 + const milp = withLineage(lineage, snapshots, { tool: 'bid-optimization-milp', version: '1.0.0', idGen: () => `tc-${++n}` }, (_input: unknown) => ({ + market_date: DATE, + prices_yuan_per_mwh: curve(opts.price ?? '0.00'), + quantities_mwh: curve(opts.qty ?? '12.5'), // 12.5 × 96 = 1200 MWh + expected_revenue_yuan: '510600.00', + })) + const forecastTool = withLineage(lineage, snapshots, { tool: 'price-forecast', version: '1.0.0', idGen: () => `tc-${++n}` }, (_i: unknown) => priceForecast()) + + const proposal = lineage.run(() => { + forecastTool({ kind: 'PRICE' }) + const { call } = milp({ market_date: DATE }) + return assembleBidProposal( + { + market_date: DATE, + prices_ref: { tool_call_id: call.tool_call_id, path: 'prices_yuan_per_mwh' }, + quantities_ref: { tool_call_id: call.tool_call_id, path: 'quantities_mwh' }, + expected_revenue_ref: { tool_call_id: call.tool_call_id, path: 'expected_revenue_yuan' }, + rationale: 'test', + }, + { + id: 'prop-001', + originator: { agent: opts.agent ?? 'trading-agent', trigger: 'SCHEDULED', task_id: 'task-001' }, + lineage: lineage.current(), + ledger_version: ledger.read().version, + policy_pack_version: PACK_VERSION, + snapshots, + now: NOW, + }, + ) + }) + const ruleCtx = () => ({ ledger: ledger.read(), ledgerService: ledger, snapshots, now: NOW }) + return { snapshots, ledger, engine, lineage, events, authority, proposal, ruleCtx } +} + +export const envelope = (bounds: Record, over: Partial = {}): Envelope => ({ + id: 'env-bid-001', + scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' }, + bounds, + validity: { from: '2026-03-01T00:00:00Z', to: '2026-03-31T23:59:59Z' }, + approval: { level: 'L1', approved_by: ['user-ops-lead'] }, + escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' }, + status: 'ACTIVE', + ...over, +}) + +export const approvalFor = (p: Proposal, ledgerVersion: number, over: Partial = {}): Approval => ({ + id: 'appr-001', + proposal_digest: p.digest, + decision: 'APPROVE', + approver: { id: 'user-trader-01', role: 'senior-trader' }, + scope: { effect_type: 'BID', limits: {} }, + validity: { from: NOW, to: '2026-03-15T09:00:00Z' }, + evidence_versions: { policy_pack_version: PACK_VERSION, ledger_version: ledgerVersion, data_snapshot_refs: [] }, + decided_at: NOW, + ...over, +}) diff --git a/skills-py/vpp_contracts/bid_export_file.py b/skills-py/vpp_contracts/bid_export_file.py new file mode 100644 index 0000000..4a2e1bd --- /dev/null +++ b/skills-py/vpp_contracts/bid_export_file.py @@ -0,0 +1,53 @@ +# generated by datamodel-codegen: +# filename: bid_export_file.json + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr + + +class ProposalType(StrEnum): + BID = 'BID' + INVITATION = 'INVITATION' + CONTROL_PLAN = 'CONTROL_PLAN' + DISPATCH_PLAN = 'DISPATCH_PLAN' + + +class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]): + root: constr(pattern=r'^-?\d+(\.\d+)?$') + + +class PricesYuanPerMwh(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class QuantitiesMwh(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + interval_minutes: Literal[15] + date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + values: list[Value] = Field(..., max_length=96, min_length=96) + + +class BidExportFile(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + format_version: Literal['1.0.0'] + proposal_id: constr(min_length=1) + proposal_digest: constr(pattern=r'^[0-9a-f]{64}$') + permit_id: constr(min_length=1) + proposal_type: ProposalType + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + prices_yuan_per_mwh: PricesYuanPerMwh + quantities_mwh: QuantitiesMwh + exported_at: AwareDatetime diff --git a/skills-py/vpp_contracts/bid_proposal_draft.py b/skills-py/vpp_contracts/bid_proposal_draft.py new file mode 100644 index 0000000..ae0901b --- /dev/null +++ b/skills-py/vpp_contracts/bid_proposal_draft.py @@ -0,0 +1,41 @@ +# generated by datamodel-codegen: +# filename: bid_proposal_draft.json + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, constr + + +class PricesRef(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + tool_call_id: constr(min_length=1) + path: constr(min_length=1) + + +class QuantitiesRef(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + tool_call_id: constr(min_length=1) + path: constr(min_length=1) + + +class ExpectedRevenueRef(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + tool_call_id: constr(min_length=1) + path: constr(min_length=1) + + +class BidProposalDraft(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + prices_ref: PricesRef + quantities_ref: QuantitiesRef + expected_revenue_ref: ExpectedRevenueRef + rationale: constr(min_length=1) diff --git a/skills-py/vpp_contracts/envelope_match.py b/skills-py/vpp_contracts/envelope_match.py new file mode 100644 index 0000000..1034d8e --- /dev/null +++ b/skills-py/vpp_contracts/envelope_match.py @@ -0,0 +1,26 @@ +# generated by datamodel-codegen: +# filename: envelope_match.json + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, constr + + +class RequiredLevel(StrEnum): + L1 = 'L1' + L2 = 'L2' + L3 = 'L3' + + +class EnvelopeMatch(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + proposal_digest: constr(pattern=r'^[0-9a-f]{64}$') + within: bool + envelope_id: constr(min_length=1) | None + required_level: RequiredLevel + reasons: list[str] + checked_at: AwareDatetime diff --git a/skills-py/vpp_contracts/execution_receipt.py b/skills-py/vpp_contracts/execution_receipt.py new file mode 100644 index 0000000..f91b9fc --- /dev/null +++ b/skills-py/vpp_contracts/execution_receipt.py @@ -0,0 +1,27 @@ +# generated by datamodel-codegen: +# filename: execution_receipt.json + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, constr + + +class Channel(StrEnum): + FILE_EXPORT = 'FILE_EXPORT' + TRADING_PLATFORM_API = 'TRADING_PLATFORM_API' + SIMULATION_GATEWAY = 'SIMULATION_GATEWAY' + + +class ExecutionReceipt(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + receipt_id: constr(min_length=1) + proposal_digest: constr(pattern=r'^[0-9a-f]{64}$') + permit_id: constr(min_length=1) + channel: Channel + idempotency_key: constr(min_length=1) + artifact_ref: constr(min_length=1) + accepted_at: AwareDatetime diff --git a/skills-py/vpp_contracts/router_decision.py b/skills-py/vpp_contracts/router_decision.py new file mode 100644 index 0000000..d4bc013 --- /dev/null +++ b/skills-py/vpp_contracts/router_decision.py @@ -0,0 +1,36 @@ +# generated by datamodel-codegen: +# filename: router_decision.json + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, constr + + +class WorkflowId(StrEnum): + day_ahead_situation = 'day-ahead-situation' + day_ahead_bid = 'day-ahead-bid' + adhoc_analysis = 'adhoc-analysis' + + +class Params(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + + +class Confidence(StrEnum): + HIGH = 'HIGH' + MEDIUM = 'MEDIUM' + LOW = 'LOW' + + +class RouterDecision(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + workflow_id: WorkflowId + params: Params + confidence: Confidence diff --git a/skills-py/vpp_contracts/simulation_result.py b/skills-py/vpp_contracts/simulation_result.py new file mode 100644 index 0000000..a377a2e --- /dev/null +++ b/skills-py/vpp_contracts/simulation_result.py @@ -0,0 +1,36 @@ +# generated by datamodel-codegen: +# filename: simulation_result.json + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, conint, constr + + +class Kind(StrEnum): + REVENUE_SCENARIOS = 'REVENUE_SCENARIOS' + POWER_BALANCE = 'POWER_BALANCE' + + +class Simulator(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + name: constr(min_length=1) + version: constr(min_length=1) + seed: conint(ge=-9007199254740991, le=9007199254740991) + + +class SimulationResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + proposal_digest: constr(pattern=r'^[0-9a-f]{64}$') + kind: Kind + alert: bool + alerts: list[str] + metrics: dict[str, constr(pattern=r'^-?\d+(\.\d+)?$')] + scenario_count: conint(ge=0, le=9007199254740991) + simulator: Simulator + simulated_at: AwareDatetime diff --git a/skills-py/vpp_contracts/stale_denial.py b/skills-py/vpp_contracts/stale_denial.py new file mode 100644 index 0000000..327c1f8 --- /dev/null +++ b/skills-py/vpp_contracts/stale_denial.py @@ -0,0 +1,19 @@ +# generated by datamodel-codegen: +# filename: stale_denial.json + +from __future__ import annotations + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr + + +class Reason(RootModel[constr(min_length=1)]): + root: constr(min_length=1) + + +class StaleDenial(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + proposal_digest: constr(pattern=r'^[0-9a-f]{64}$') + reasons: list[Reason] = Field(..., min_length=1) + checked_at: AwareDatetime diff --git a/skills-py/vpp_contracts/validation_result.py b/skills-py/vpp_contracts/validation_result.py new file mode 100644 index 0000000..78761d8 --- /dev/null +++ b/skills-py/vpp_contracts/validation_result.py @@ -0,0 +1,42 @@ +# generated by datamodel-codegen: +# filename: validation_result.json + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, constr + + +class PolicyPack(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(min_length=1) + version: constr(min_length=1) + + +class Severity(StrEnum): + REJECT = 'REJECT' + WARN = 'WARN' + + +class Violation(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + rule_id: constr(min_length=1) + severity: Severity + message: constr(min_length=1) + + +class ValidationResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + proposal_digest: constr(pattern=r'^[0-9a-f]{64}$') + policy_pack: PolicyPack + ok: bool + violations: list[Violation] + rules_evaluated: list[constr(min_length=1)] + checked_at: AwareDatetime