From 83e4ea82034e93a9359f4146f2d253395dad48fb Mon Sep 17 00:00:00 2001 From: Theodore Song Date: Fri, 21 Aug 2026 17:10:26 -0400 Subject: [PATCH] Deploy hourly autonomous paper runtime --- .github/workflows/autonomous-cycle.yml | 40 ++++++ README.md | 12 ++ api/state.js | 82 +++++++++++- index.html | 72 +++++++--- package-lock.json | 57 +++++++- package.json | 6 +- scripts/run-autonomous-cycle.mjs | 176 +++++++++++++++++++++++++ scripts/test-autonomous-runtime.mjs | 41 ++++++ scripts/test-server-state.mjs | 23 +++- sw.js | 2 +- 10 files changed, 482 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/autonomous-cycle.yml create mode 100644 scripts/run-autonomous-cycle.mjs create mode 100644 scripts/test-autonomous-runtime.mjs diff --git a/.github/workflows/autonomous-cycle.yml b/.github/workflows/autonomous-cycle.yml new file mode 100644 index 0000000..0c38c92 --- /dev/null +++ b/.github/workflows/autonomous-cycle.yml @@ -0,0 +1,40 @@ +name: Autonomous paper cycle + +on: + schedule: + - cron: "7 * * * *" + workflow_dispatch: + push: + branches: [main] + paths: + - index.html + - api/state.js + - scripts/run-autonomous-cycle.mjs + - .github/workflows/autonomous-cycle.yml + +permissions: + contents: write + +concurrency: + group: autonomous-paper-cycle + cancel-in-progress: false + +jobs: + cycle: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: node scripts/run-autonomous-cycle.mjs + env: + ARENA_URL: https://polymarket-site-eta.vercel.app + EXPECTED_BUILD: "88" + RUNTIME_BRANCH: runtime-state + RUNTIME_STATE_PATH: runtime/state.json + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 892569f..5c12dcd 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,18 @@ market snapshots. During an outage, cycles continue locally; cached entries are allowed for 90 minutes, older snapshots become mark-only, and all cached data expires after 24 hours. +Build 88 adds a headless GitHub Actions runtime that checks the production site +at seven minutes past every hour, continues from the previous agent snapshot, +and runs the next due paper cycle even when no browser is open. It writes a +sanitized snapshot to the `runtime-state` branch and `/api/state` uses that as a +read-only fallback while Neon or Vercel Blob is unavailable. The public snapshot +contains only `pma_agents_v2` and `pma_suggestions_v5`, with suggestions capped at +300 to keep cross-device loads small. Paper accounts, passwords, email settings, +investment allocations, chat history, wallet information, and live-money +settings are explicitly excluded. Manual cycles can still run locally, but the +next autonomous hourly result is the shared public authority until managed +storage is restored. + Build 73 distinguishes a temporary order-book pause from settlement. Exact market refreshes still mark paused positions to the latest published price, but the engine cannot simulate a stop, policy exit, or settlement while diff --git a/api/state.js b/api/state.js index d59f862..8eb919d 100644 --- a/api/state.js +++ b/api/state.js @@ -4,6 +4,8 @@ import { databaseConnectionDiagnostics, hasDatabase, readSharedAppState, writeSh const STATE_PATH = process.env.PMA_STATE_PATH || "shared/state.json"; const STATE_VERSION_PREFIX = process.env.PMA_STATE_VERSION_PREFIX || "shared/state-versions/"; const DATABASE_STATE_KEY = process.env.PMA_DATABASE_STATE_KEY || "polymarket-arena"; +const GITHUB_RUNTIME_STATE_URL = process.env.PMA_GITHUB_RUNTIME_STATE_URL + || "https://raw.githubusercontent.com/theodore-song/polymarket-analyst/runtime-state/runtime/state.json"; const AGENTS_KEY = "pma_agents_v2"; const SUG_KEY = "pma_suggestions_v5"; const PAPER_KEY = "pma_paper_accounts_v1"; @@ -11,6 +13,7 @@ const LIVE_KEY = "pma_live_readiness_v1"; const AGENT_IDS = ["value", "momentum", "favorite", "longshot", "diversifier", "catalyst", "reversal", "breakout", "tailalpha", "conviction"]; const LIMITS = { closed: 80, history: 160, snapshots: 240, suggestions: 900, paperHistory: 120, paperSnapshots: 120, audit: 120 }; const SIGNAL_LEDGER_LIMITS = { pending: 300, outcomes: 500 }; +const RUNTIME_ALLOWED_KEYS = new Set([AGENTS_KEY, SUG_KEY]); function withBlobAuth(options = {}) { const token = process.env.BLOB_READ_WRITE_TOKEN; @@ -80,19 +83,48 @@ async function readJsonBlob() { if (!primaryError) primaryError = err; } - if (primaryError && !databaseAvailable) { + let githubError = null; + try { + const runtime = await readGithubRuntimeState(); + if (runtime) return runtime; + } catch (err) { + githubError = err; + } + + if ((primaryError || githubError) && !databaseAvailable) { const error = new Error("Shared state providers are unavailable"); error.providers = { database: hasDatabase() ? { ...providerErrorMetadata(databaseError), ...databaseConnectionDiagnostics() } : { status: "not_configured", candidates: 0, urls: [] }, blob: providerErrorMetadata(primaryError), + github_runtime: providerErrorMetadata(githubError), }; throw error; } return null; } +async function readGithubRuntimeState() { + if (!GITHUB_RUNTIME_STATE_URL) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + const url = new URL(GITHUB_RUNTIME_STATE_URL); + url.searchParams.set("minute", String(Math.floor(Date.now() / 60000))); + const response = await fetch(url, { + cache: "no-store", + headers: { Accept: "application/json", "Cache-Control": "no-cache" }, + signal: controller.signal, + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`GitHub runtime state returned HTTP ${response.status}`); + return sanitizeGithubRuntimeState(await response.json()); + } finally { + clearTimeout(timeout); + } +} + async function persistState(state) { let databaseSaved = false; let databaseError = null; @@ -275,6 +307,33 @@ function compactItems(items) { return out; } +export function sanitizeGithubRuntimeState(payload) { + if (!payload || typeof payload !== "object" || !payload.items || typeof payload.items !== "object") { + throw new Error("Invalid GitHub runtime state"); + } + const items = {}; + for (const key of RUNTIME_ALLOWED_KEYS) { + if (typeof payload.items[key] === "string") items[key] = payload.items[key]; + } + const agents = agentStateFromItems(items); + if (!agents || !agents.agents) throw new Error("GitHub runtime state is missing agent portfolios"); + const suggestions = suggestionStateFromItems(items); + items[AGENTS_KEY] = JSON.stringify(compactAgentState(agents)); + if (suggestions) items[SUG_KEY] = JSON.stringify(compactSuggestions(suggestions)); + return { + version: 1, + schema_version: Number(payload.schema_version || 1), + build_version: Number(payload.build_version || agents.engine_version || 0), + strategy_version: Number(payload.strategy_version || agents.strategy_version || 0), + generated_at: payload.generated_at || payload.updated_at || null, + updated_at: payload.updated_at || payload.generated_at || null, + last_cycle_hour: payload.last_cycle_hour || agents.last_cycle_hour || null, + source: "github-actions", + read_only: true, + items, + }; +} + function conflictResponse(res, error, current) { return res.status(409).json({ ok: false, error, state: current }); } @@ -282,15 +341,17 @@ function conflictResponse(res, error, current) { export default async function handler(req, res) { res.setHeader("Cache-Control", "no-store"); try { - if (!hasDatabase() && !process.env.BLOB_READ_WRITE_TOKEN && !process.env.VERCEL_OIDC_TOKEN) { - return res.status(503).json({ ok: false, error: "Cloud state is not configured" }); - } - if (req.method === "GET") { try { const state = await readJsonBlob(); if (state && state.items) state.items = compactItems(state.items); - return res.status(200).json({ ok: true, state, degraded: false }); + return res.status(200).json({ + ok: true, + state, + degraded: false, + read_only: Boolean(state && state.read_only), + source: state && state.source || "managed-storage", + }); } catch (err) { // A storage outage must not prevent the installed app from using its local paper state. return res.status(200).json({ @@ -304,6 +365,15 @@ export default async function handler(req, res) { } if (req.method === "POST") { + if (!hasDatabase() && !process.env.BLOB_READ_WRITE_TOKEN && !process.env.VERCEL_OIDC_TOKEN) { + return res.status(503).json({ + ok: false, + degraded: true, + read_only: true, + source: "github-actions", + error: "Managed cloud state is not configured; autonomous shared state is read-only", + }); + } const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {}); if (!body || typeof body !== "object" || !body.items || typeof body.items !== "object") { return res.status(400).json({ ok: false, error: "Invalid state payload" }); diff --git a/index.html b/index.html index db72aca..56ee3b6 100644 --- a/index.html +++ b/index.html @@ -341,7 +341,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color