* feat(simulation): MiroFish Phase 2 — theater-limited simulation runner Adds the simulation execution layer that consumes simulation-package.json and produces simulation-outcome.json for maritime chokepoint + energy/logistics theaters, closing the WorldMonitor → MiroFish handoff loop. Changes: - scripts/seed-forecasts.mjs: 2-round LLM simulation runner (prompt builders, JSON extractor, runTheaterSimulation, writeSimulationOutcome, task queue with NX dedup lock, runSimulationWorker poll loop) - scripts/process-simulation-tasks.mjs: standalone worker entry point - proto: GetSimulationOutcome RPC + make generate - server/worldmonitor/forecast/v1/get-simulation-outcome.ts: RPC handler - server/gateway.ts: slow tier for get-simulation-outcome - api/health.js: simulationOutcomeLatest in STANDALONE + ON_DEMAND keys - tests: 14 new tests for simulation runner functions * fix(simulation): address P1/P2 code review findings from PR #2220 Security (P1 #018): - sanitizeForPrompt() applied to all entity/seed fields interpolated into Round 1 prompt (entityId, class, stance, seedId, type, timing) - sanitizeForPrompt() applied to actorId and entityIds in Round 2 prompt - sanitizeForPrompt() + length caps applied to all LLM array fields written to R2 (dominantReactions, stabilizers, invalidators, keyActors, timingMarkers) Validation (P1 #019): - Added validateRunId() regex guard - Applied in enqueueSimulationTask() and processNextSimulationTask() loop Type safety (P1 #020): - Added isOutcomePointer() and isPackagePointer() type guards in TS handlers - Replaced unsafe as-casts with runtime-validated guards in both handlers Correctness (P2 #022): - Log warning when pkgPointer.runId does not match task runId Architecture (P2 #024): - isMaritimeChokeEnergyCandidate() accepts both flat and nested topBucketId - Call site simplified to pass theater directly Performance (P2 #025): - SIMULATION_ROUND1_MAX_TOKENS raised 1800 to 2200 - Added max 3 initialReactions instruction to Round 1 prompt Maintainability (P2 #026): - Simulation pointer keys exported from server/_shared/cache-keys.ts - Both TS handlers import from shared location Documentation (P2 #027): - Strengthened runId no-op description in proto and OpenAPI spec * fix(todos): add blank lines around lists in markdown todo files * style(api): reformat openapi yaml to match linter output * test(simulation): add flat-shape filter test + getSimulationOutcome handler coverage Two tests identified as missing during PR #2220 review: 1. isMaritimeChokeEnergyCandidate flat-shape tests — covers the || candidate.topBucketId normalization added in the P1/P2 review pass. The existing tests only used the nested marketContext.topBucketId shape; this adds the flat root-field shape that arrives from the simulation-package.json JSON (selectedTheaters entries have topBucketId at root). 2. getSimulationOutcome handler structural tests — verifies the isOutcomePointer guard, found:false NOT_FOUND return, found:true success path, note population on runId mismatch, and redis_unavailable error string. Follows the readSrc static-analysis pattern used elsewhere in server-handlers.test.mjs (handler imports Redis so full integration test would require a test Redis instance).
3.8 KiB
status, priority, issue_id, tags
| status | priority | issue_id | tags | ||||
|---|---|---|---|---|---|---|---|
| pending | p1 | 021 |
|
No HTTP endpoint to trigger a simulation run — agents cannot initiate simulations
Problem Statement
Simulation runs can only be triggered by a human operator running node scripts/process-simulation-tasks.mjs --once in the Railway environment. enqueueSimulationTask(runId) and runSimulationWorker are exported from scripts/seed-forecasts.mjs but are only callable from worker processes, not via HTTP. Agents operating through the HTTP API (AI Market Implications panel, future orchestration agents, LLM tool calls) have read-only access to the system — they can discover the latest simulation outcome pointer but cannot trigger a new simulation. For a feature described as AI-driven forecasting, agents being permanently blocked from initiating analysis is a design gap.
Findings
F-1 (P1): No POST /api/forecast/v1/trigger-simulation or equivalent endpoint exists.
F-2 (P1): enqueueSimulationTask(runId) is exported and callable, but only from Node.js processes — no HTTP surface.
F-3 (P2): Compounded by runId filter being a no-op in getSimulationOutcome — even if an agent knew its trigger succeeded, it cannot verify its specific run completed vs. a concurrent run superseding it.
Capability map:
| Action | Human | Agent (HTTP) |
|---|---|---|
| Check outcome exists | ✅ | ✅ |
| Read outcome pointer | ✅ | ✅ |
| Trigger simulation run | ✅ (Railway CLI) | ❌ |
| Check if run in progress | ✅ (logs) | ❌ |
| Verify specific run completed | ✅ | ❌ (runId filter no-op) |
Proposed Solutions
Option A: Add POST /api/forecast/v1/trigger-simulation (Recommended)
A thin Vercel handler following the same proto pattern:
- New proto message:
TriggerSimulationRequest { string run_id = 1; },TriggerSimulationResponse { bool queued = 1; string run_id = 2; string reason = 3; } - New handler: reads
SIMULATION_PACKAGE_LATEST_KEYfrom Redis to deriverunIdif not supplied, callsenqueueSimulationTask(runId), returns{ queued, runId, reason } - The actual execution remains Railway-side (existing poll loop picks it up) — the endpoint only enqueues
- Rate-limit to 1 trigger per 5 minutes to prevent spam (can reuse existing rate-limit pattern)
Estimated effort: 1 proto file + 1 handler file + 1 service.proto entry + make generate — same scope as get-simulation-outcome.ts.
Option B: Webhook trigger from deep forecast completion
When processNextDeepForecastTask completes and writes a simulation package, automatically call enqueueSimulationTask. This makes simulation trigger automatic rather than agent-driven. Simpler but removes on-demand triggering flexibility.
Effort: Small | Risk: Low — no new HTTP surface, but agents still can't trigger ad-hoc
Acceptance Criteria
POST /api/forecast/v1/trigger-simulationreturns{ queued: true, runId }when package is available- Returns
{ queued: false, reason: 'no_package' }when no simulation package exists - Returns
{ queued: false, reason: 'duplicate' }when the same runId is already queued - Rate limited to prevent spam
- Agent-native: an agent calling the trigger endpoint then polling
getSimulationOutcomecan complete a trigger-and-verify workflow
Technical Details
- Would-be handler:
server/worldmonitor/forecast/v1/trigger-simulation.ts - Entry point:
enqueueSimulationTask(runId)inscripts/seed-forecasts.mjs(already exported) - Pattern reference:
get-simulation-outcome.tsfor handler structure,service.protofor RPC addition - Related: todo #029 (runId filter no-op) — fix both for complete trigger-and-verify loop
Work Log
- 2026-03-24: Found by compound-engineering:review:agent-native-reviewer in PR #2220 review