mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-24 13:08:10 +00:00
Deploy hourly autonomous paper runtime
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { chromium } from "playwright";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const AGENTS_KEY = "pma_agents_v2";
|
||||
export const SUGGESTIONS_KEY = "pma_suggestions_v5";
|
||||
export const ALLOWED_RUNTIME_KEYS = Object.freeze([AGENTS_KEY, SUGGESTIONS_KEY]);
|
||||
const FORBIDDEN_RUNTIME_KEYS = Object.freeze([
|
||||
"pma_paper_accounts_v1",
|
||||
"pma_trade_email_alerts_v1",
|
||||
"pma_invest_allocations_v1",
|
||||
"pma_live_readiness_v1",
|
||||
"pma_agent_chat_v1",
|
||||
"pma_paid_agent_chat_v1",
|
||||
]);
|
||||
const MAX_STATE_BYTES = 900_000;
|
||||
|
||||
function required(name, fallback = "") {
|
||||
const value = process.env[name] || fallback;
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function validateRuntimeSnapshot(snapshot, expectedBuild = 0) {
|
||||
if (!snapshot || typeof snapshot !== "object" || !snapshot.items || typeof snapshot.items !== "object") {
|
||||
throw new Error("Autonomous export is not a state snapshot");
|
||||
}
|
||||
const keys = Object.keys(snapshot.items).sort();
|
||||
const allowed = [...ALLOWED_RUNTIME_KEYS].sort();
|
||||
if (JSON.stringify(keys) !== JSON.stringify(allowed)) {
|
||||
throw new Error(`Autonomous export contains unexpected keys: ${keys.join(", ")}`);
|
||||
}
|
||||
for (const key of FORBIDDEN_RUNTIME_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(snapshot.items, key)) throw new Error(`Private key ${key} cannot enter autonomous state`);
|
||||
}
|
||||
for (const key of ALLOWED_RUNTIME_KEYS) {
|
||||
if (typeof snapshot.items[key] !== "string") throw new Error(`Runtime item ${key} must be serialized JSON`);
|
||||
JSON.parse(snapshot.items[key]);
|
||||
}
|
||||
const agents = JSON.parse(snapshot.items[AGENTS_KEY]);
|
||||
const suggestions = JSON.parse(snapshot.items[SUGGESTIONS_KEY]);
|
||||
if (!agents.agents || Object.keys(agents.agents).length !== 10) throw new Error("Runtime snapshot must contain ten public agents");
|
||||
if (!agents.seeded || !agents.last_cycle_hour) throw new Error("Runtime snapshot has not completed a cycle");
|
||||
if (expectedBuild && Number(snapshot.build_version) !== Number(expectedBuild)) {
|
||||
throw new Error(`Expected Build ${expectedBuild}, received Build ${snapshot.build_version}`);
|
||||
}
|
||||
if ((suggestions.suggestions || []).length > 300) throw new Error("Runtime suggestion snapshot exceeds the 300-item public limit");
|
||||
const bytes = Buffer.byteLength(JSON.stringify(snapshot));
|
||||
if (bytes > MAX_STATE_BYTES) throw new Error(`Runtime snapshot is ${bytes} bytes; limit is ${MAX_STATE_BYTES}`);
|
||||
return { snapshot, bytes, agents, suggestions };
|
||||
}
|
||||
|
||||
async function githubRequest(path, options = {}) {
|
||||
const token = required("GITHUB_TOKEN");
|
||||
const response = await fetch(`https://api.github.com${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "polymarket-arena-autonomous-runtime",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
if (response.status === 404 && options.allowNotFound) return null;
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`GitHub API ${options.method || "GET"} ${path} failed with HTTP ${response.status}: ${body?.message || "unknown error"}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function ensureRuntimeBranch(repository, branch) {
|
||||
const encoded = encodeURIComponent(`heads/${branch}`);
|
||||
const current = await githubRequest(`/repos/${repository}/git/ref/${encoded}`, { allowNotFound: true });
|
||||
if (current) return;
|
||||
await githubRequest(`/repos/${repository}/git/refs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: required("GITHUB_SHA") }),
|
||||
});
|
||||
}
|
||||
|
||||
async function readRuntimeFile(repository, branch, pathname) {
|
||||
const file = await githubRequest(`/repos/${repository}/contents/${pathname}?ref=${encodeURIComponent(branch)}`, { allowNotFound: true });
|
||||
if (!file || file.type !== "file" || !file.content) return { snapshot: null, sha: null };
|
||||
try {
|
||||
return { snapshot: JSON.parse(Buffer.from(file.content.replace(/\s/g, ""), "base64").toString("utf8")), sha: file.sha };
|
||||
} catch {
|
||||
throw new Error("Existing runtime state is not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRuntimeFile(repository, branch, pathname, snapshot, sha) {
|
||||
const body = {
|
||||
message: `Update autonomous paper cycle ${snapshot.last_cycle_hour}`,
|
||||
content: Buffer.from(`${JSON.stringify(snapshot, null, 2)}\n`).toString("base64"),
|
||||
branch,
|
||||
};
|
||||
if (sha) body.sha = sha;
|
||||
await githubRequest(`/repos/${repository}/contents/${pathname}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForProductionBuild(page, arenaUrl, expectedBuild) {
|
||||
const deadline = Date.now() + 12 * 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
await page.goto(`${arenaUrl}?automation=1&build=${expectedBuild}&t=${Date.now()}`, { waitUntil: "domcontentloaded", timeout: 90_000 });
|
||||
await page.waitForFunction(() => Boolean(window.PMA_AUTOMATION), null, { timeout: 20_000 }).catch(() => {});
|
||||
const status = await page.evaluate(() => window.PMA_AUTOMATION?.status?.() || null);
|
||||
if (Number(status?.build) === expectedBuild) return status;
|
||||
await sleep(20_000);
|
||||
}
|
||||
throw new Error(`Production did not reach Build ${expectedBuild} before the autonomous cycle deadline`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const repository = required("GITHUB_REPOSITORY", "theodore-song/polymarket-analyst");
|
||||
const branch = process.env.RUNTIME_BRANCH || "runtime-state";
|
||||
const pathname = process.env.RUNTIME_STATE_PATH || "runtime/state.json";
|
||||
const arenaUrl = (process.env.ARENA_URL || "https://polymarket-site-eta.vercel.app").replace(/\/$/, "");
|
||||
const expectedBuild = Number(required("EXPECTED_BUILD", "88"));
|
||||
await ensureRuntimeBranch(repository, branch);
|
||||
const prior = await readRuntimeFile(repository, branch, pathname);
|
||||
if (prior.snapshot) validateRuntimeSnapshot(prior.snapshot);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ serviceWorkers: "block" });
|
||||
if (prior.snapshot) {
|
||||
await context.addInitScript(({ snapshot, allowedKeys }) => {
|
||||
try {
|
||||
for (const key of allowedKeys) {
|
||||
if (typeof snapshot.items?.[key] === "string") localStorage.setItem(key, snapshot.items[key]);
|
||||
}
|
||||
} catch {}
|
||||
}, { snapshot: prior.snapshot, allowedKeys: ALLOWED_RUNTIME_KEYS });
|
||||
}
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", error => pageErrors.push(String(error?.message || error)));
|
||||
await waitForProductionBuild(page, arenaUrl, expectedBuild);
|
||||
await page.waitForFunction(() => {
|
||||
const status = window.PMA_AUTOMATION?.status?.();
|
||||
return Boolean(status?.seeded && !status?.running);
|
||||
}, null, { timeout: 12 * 60_000 });
|
||||
await page.evaluate(() => window.PMA_AUTOMATION.runCycle());
|
||||
await page.waitForFunction(() => !window.PMA_AUTOMATION?.status?.().running, null, { timeout: 12 * 60_000 });
|
||||
const snapshot = await page.evaluate(() => window.PMA_AUTOMATION.exportShared());
|
||||
const validated = validateRuntimeSnapshot(snapshot, expectedBuild);
|
||||
await writeRuntimeFile(repository, branch, pathname, snapshot, prior.sha);
|
||||
const status = await page.evaluate(() => window.PMA_AUTOMATION.status());
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
build: status.build,
|
||||
cycle: snapshot.last_cycle_hour,
|
||||
suggestions: validated.suggestions.suggestions?.length || 0,
|
||||
bytes: validated.bytes,
|
||||
agent_summary: snapshot.summary?.agents || [],
|
||||
page_errors: pageErrors.slice(0, 3),
|
||||
}));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch(error => {
|
||||
console.error(error?.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { ALLOWED_RUNTIME_KEYS, validateRuntimeSnapshot } from "./run-autonomous-cycle.mjs";
|
||||
|
||||
const index = fs.readFileSync(new URL("../index.html", import.meta.url), "utf8");
|
||||
const api = fs.readFileSync(new URL("../api/state.js", import.meta.url), "utf8");
|
||||
const workflow = fs.readFileSync(new URL("../.github/workflows/autonomous-cycle.yml", import.meta.url), "utf8");
|
||||
const runner = fs.readFileSync(new URL("./run-autonomous-cycle.mjs", import.meta.url), "utf8");
|
||||
const build = Number(index.match(/const BUILD_VERSION = (\d+);/)?.[1]);
|
||||
|
||||
assert.equal(build, 88);
|
||||
assert.deepEqual([...ALLOWED_RUNTIME_KEYS].sort(), ["pma_agents_v2", "pma_suggestions_v5"]);
|
||||
assert.match(index, /function collectPublicRuntimeItems\(\)/);
|
||||
assert.match(index, /const PUBLIC_RUNTIME_KEYS=Object\.freeze\(\[AGENTS_KEY,SUG_KEY\]\)/);
|
||||
assert.match(index, /suggestions:300/);
|
||||
assert.match(index, /window\.PMA_AUTOMATION=Object\.freeze/);
|
||||
assert.match(index, /if\(CLOUD_STATE_HEALTH\.read_only\)return false/);
|
||||
assert.match(api, /runtime-state\/runtime\/state\.json/);
|
||||
assert.match(api, /sanitizeGithubRuntimeState/);
|
||||
assert.match(workflow, /cron: "7 \* \* \* \*"/);
|
||||
assert.match(workflow, /contents: write/);
|
||||
assert.match(workflow, /EXPECTED_BUILD: "88"/);
|
||||
assert.match(runner, /MAX_STATE_BYTES = 900_000/);
|
||||
|
||||
const agentIds = ["value", "momentum", "favorite", "longshot", "diversifier", "catalyst", "reversal", "breakout", "tailalpha", "conviction"];
|
||||
const agents = Object.fromEntries(agentIds.map(id => [id, { cash: 10000, positions: [] }]));
|
||||
const snapshot = {
|
||||
schema_version: 1,
|
||||
build_version: build,
|
||||
generated_at: new Date().toISOString(),
|
||||
last_cycle_hour: "2026-08-21T20|v59",
|
||||
items: {
|
||||
pma_agents_v2: JSON.stringify({ seeded: true, last_cycle_hour: "2026-08-21T20|v59", agents }),
|
||||
pma_suggestions_v5: JSON.stringify({ suggestions: [] }),
|
||||
},
|
||||
};
|
||||
assert.equal(validateRuntimeSnapshot(snapshot, build).agents.seeded, true);
|
||||
assert.throws(() => validateRuntimeSnapshot({ ...snapshot, items: { ...snapshot.items, pma_paper_accounts_v1: "{}" } }, build), /unexpected keys/);
|
||||
assert.throws(() => validateRuntimeSnapshot({ ...snapshot, build_version: build - 1 }, build), /Expected Build/);
|
||||
|
||||
console.log(`autonomous runtime verified for Build ${build}`);
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { databaseConnectionDiagnostics, databaseUrl, databaseUrls, normalizeDatabaseUrl } from "../lib/db.js";
|
||||
import { compactAgentState, compactSuggestion, providerErrorCode } from "../api/state.js";
|
||||
import { compactAgentState, compactSuggestion, providerErrorCode, sanitizeGithubRuntimeState } from "../api/state.js";
|
||||
|
||||
assert.equal(normalizeDatabaseUrl("psql 'postgresql://user:pass@example.test/db?sslmode=require'"),
|
||||
"postgresql://user:pass@example.test/db?sslmode=require");
|
||||
@@ -63,6 +63,27 @@ const compactedState = compactAgentState({
|
||||
assert.equal(compactedState.signal_ledger.expired_ungraded, 7);
|
||||
assert.equal(compactedState.agents.reversal.shock_fade_shadows[0].shock_strategy_version, 3);
|
||||
assert.equal(compactedState.agents.reversal.shock_fade_outcomes[0].net_return, 0.08);
|
||||
|
||||
const publicAgents = Object.fromEntries([
|
||||
"value", "momentum", "favorite", "longshot", "diversifier", "catalyst", "reversal", "breakout", "tailalpha", "conviction",
|
||||
].map(id => [id, { cash: 10000, positions: [], closed: [], history: [], snapshots: [] }]));
|
||||
const sanitizedRuntime = sanitizeGithubRuntimeState({
|
||||
schema_version: 1,
|
||||
build_version: 88,
|
||||
generated_at: "2026-08-21T20:00:00.000Z",
|
||||
items: {
|
||||
pma_agents_v2: JSON.stringify({ seeded: true, last_cycle_hour: "2026-08-21T20|v59", agents: publicAgents }),
|
||||
pma_suggestions_v5: JSON.stringify({ suggestions: [] }),
|
||||
pma_paper_accounts_v1: JSON.stringify({ password: "must-not-survive" }),
|
||||
pma_trade_email_alerts_v1: JSON.stringify({ email: "private@example.com" }),
|
||||
pma_live_readiness_v1: JSON.stringify({ wallet: "private" }),
|
||||
},
|
||||
});
|
||||
assert.deepEqual(Object.keys(sanitizedRuntime.items).sort(), ["pma_agents_v2", "pma_suggestions_v5"]);
|
||||
assert.equal(sanitizedRuntime.read_only, true);
|
||||
assert.equal(sanitizedRuntime.source, "github-actions");
|
||||
assert.equal(sanitizedRuntime.build_version, 88);
|
||||
assert.throws(() => sanitizeGithubRuntimeState({ items: { pma_suggestions_v5: "{}" } }), /missing agent portfolios/);
|
||||
assert.equal(providerErrorCode(new Error("403 Forbidden")), "authorization_failed");
|
||||
assert.equal(providerErrorCode(new Error("Error connecting to database: HTTP status 402")), "provider_payment_required");
|
||||
assert.equal(providerErrorCode(new Error("invalid connection string")), "invalid_connection_string");
|
||||
|
||||
Reference in New Issue
Block a user