Files
worldmonitor/todos/021-pending-p1-simulation-no-http-trigger-endpoint.md
Elie Habib 01f6057389 feat(simulation): MiroFish Phase 2 — theater-limited simulation runner (#2220)
* 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).
2026-03-25 13:55:59 +04:00

3.8 KiB

status, priority, issue_id, tags
status priority issue_id tags
pending p1 021
code-review
agent-native
simulation-runner
api

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:

  1. New proto message: TriggerSimulationRequest { string run_id = 1; }, TriggerSimulationResponse { bool queued = 1; string run_id = 2; string reason = 3; }
  2. New handler: reads SIMULATION_PACKAGE_LATEST_KEY from Redis to derive runId if not supplied, calls enqueueSimulationTask(runId), returns { queued, runId, reason }
  3. The actual execution remains Railway-side (existing poll loop picks it up) — the endpoint only enqueues
  4. 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-simulation returns { 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 getSimulationOutcome can complete a trigger-and-verify workflow

Technical Details

  • Would-be handler: server/worldmonitor/forecast/v1/trigger-simulation.ts
  • Entry point: enqueueSimulationTask(runId) in scripts/seed-forecasts.mjs (already exported)
  • Pattern reference: get-simulation-outcome.ts for handler structure, service.proto for 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