Compare commits

..
37 Commits
Author SHA1 Message Date
Theodore Song ab333dd045 Replace forced exposure with confirmed signals 2026-08-09 15:47:06 -04:00
Theodore Song 0ef05ea734 Update exposure engine labels 2026-08-02 15:22:59 -04:00
Theodore Song cfecd2e78b Add durable portfolio exposure controller 2026-08-02 15:21:26 -04:00
Theodore Song 6233efc098 Move shared cycle state to Neon fallback 2026-08-02 14:45:06 -04:00
Theodore Song ecbdbf4cbf Use explicit Blob credentials for state sync 2026-08-02 14:41:49 -04:00
Theodore Song a4402134ff Restore resilient cloud state sync 2026-08-02 14:40:02 -04:00
Theodore Song e57d84af1d Let core agents join qualified probes 2026-08-01 17:54:42 -04:00
Theodore Song c7c8359f09 Fix audit churn and idle core portfolios 2026-08-01 17:49:15 -04:00
Theodore Song 8dc7c9506c Let aggressive agents press qualified signals 2026-08-01 12:18:14 -04:00
Theodore Song 313c4f9326 Add five aggressive strategy agents 2026-08-01 12:15:24 -04:00
Theodore Song 1effdd8373 Reject stale strategy data from old clients 2026-08-01 09:50:22 -04:00
Theodore Song 4630bd482b Add structured strategy audit records 2026-08-01 09:47:38 -04:00
Theodore Song 91a6c5678c Replace forced deployment with quality-first trading 2026-08-01 09:44:56 -04:00
Theodore Song 8a897b9a6b Let core agents share strong probe trades 2026-07-30 15:10:50 -04:00
Theodore Song 2a2f8af765 Increase core agent capital deployment 2026-07-30 15:06:51 -04:00
Theodore Song a352e86a2b Restore shared portfolio momentum metrics 2026-07-30 15:01:35 -04:00
Theodore Song 21f89a9fea Focus arena on five active core agents 2026-07-30 14:52:43 -04:00
Theodore Song f8e3799f21 Show rolling 24 hour portfolio changes 2026-07-29 16:11:47 -04:00
Theodore Song 5f57608ee7 Expand tiered trade-ready pool 2026-07-29 15:00:12 -04:00
Theodore Song 697830a782 Restore risk-capped agent deployment 2026-07-29 14:54:21 -04:00
Theodore Song cb4a539256 Add durable agent risk controls 2026-07-28 14:26:57 -04:00
Theodore Song 09b4eac736 Limit agent analysis to top 500 active markets 2026-07-27 15:55:08 -04:00
Theodore Song c7ce683db9 Read latest versioned cloud state 2026-07-27 15:45:51 -04:00
Theodore Song d04ed59088 Preserve suggestion quality fields 2026-07-27 15:40:26 -04:00
Theodore Song 82a1e0c831 Enable selective buys for agents 2026-07-27 15:37:55 -04:00
Theodore Song 67beab0144 Make top 5000 analysis visible 2026-07-27 15:27:48 -04:00
Theodore Song 3c39cfe9f3 Force visible top 5000 build 2026-07-26 21:13:02 -04:00
Theodore Song 744387199e Analyze top 5000 active markets 2026-07-26 20:10:39 -04:00
Theodore Song 49888c7b56 Fix agent hold rules and chart artifacts 2026-07-26 17:39:39 -04:00
Theodore Song e36ef12e6a Add evidence based agent scoring 2026-07-26 16:55:21 -04:00
Theodore Song 29f9dcf29b Reduce agent trade overlap 2026-07-26 16:39:30 -04:00
Theodore Song 1f1e0c39b8 Fill suggestion list with broad watch ideas 2026-07-26 16:29:23 -04:00
Theodore Song b5fed7980f Expand suggestions with scout ideas 2026-07-26 16:23:54 -04:00
Theodore Song 4b3e03a353 Use keyset pagination for full market scans 2026-07-26 16:15:48 -04:00
Theodore Song 734b989072 Expand active market scan and suggestions 2026-07-26 16:07:08 -04:00
Theodore Song 8d69619dad Tighten agent entry and recovery rules 2026-07-26 15:52:34 -04:00
Theodore Song 018d1d9294 Add SMTP support for trade emails 2026-07-26 14:13:34 -04:00
7 changed files with 1153 additions and 511 deletions
+7 -1
View File
@@ -85,11 +85,17 @@ CUSTOMER_SUPPORT_EMAIL=
RISK_ADMIN_WALLET= RISK_ADMIN_WALLET=
# Trade email alerts # Trade email alerts
# Use either Resend directly or a webhook from Zapier/Make/another email automation. # Use Resend, SMTP, or a webhook from Zapier/Make/another email automation.
RESEND_API_KEY= RESEND_API_KEY=
TRADE_EMAIL_FROM=Poly Arena <alerts@yourdomain.com> TRADE_EMAIL_FROM=Poly Arena <alerts@yourdomain.com>
TRADE_EMAIL_REPLY_TO= TRADE_EMAIL_REPLY_TO=
TRADE_EMAIL_WEBHOOK_URL= TRADE_EMAIL_WEBHOOK_URL=
SMTP_HOST=
SMTP_PORT=
SMTP_SECURE=true
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
# Agent chat # Agent chat
OPENAI_API_KEY= OPENAI_API_KEY=
+40
View File
@@ -2,6 +2,7 @@ import { neon } from "@neondatabase/serverless";
let sqlClient; let sqlClient;
let schemaReady; let schemaReady;
let sharedStateSchemaReady;
export function databaseUrl() { export function databaseUrl() {
return process.env.DATABASE_URL || process.env.NEON_DATABASE_URL || ""; return process.env.DATABASE_URL || process.env.NEON_DATABASE_URL || "";
@@ -20,6 +21,45 @@ export function sql() {
return sqlClient; return sqlClient;
} }
async function ensureSharedStateSchema() {
if (sharedStateSchemaReady) return;
const db = sql();
await db`
create table if not exists shared_app_state (
state_key text primary key,
payload jsonb not null,
updated_at timestamptz not null default now()
)
`;
sharedStateSchemaReady = true;
}
export async function readSharedAppState(stateKey) {
await ensureSharedStateSchema();
const db = sql();
const rows = await db`
select payload, updated_at
from shared_app_state
where state_key = ${stateKey}
limit 1
`;
return rows[0] || null;
}
export async function writeSharedAppState(stateKey, payload) {
await ensureSharedStateSchema();
const db = sql();
const rows = await db`
insert into shared_app_state (state_key, payload, updated_at)
values (${stateKey}, ${JSON.stringify(payload)}::jsonb, now())
on conflict (state_key) do update set
payload = excluded.payload,
updated_at = now()
returning updated_at
`;
return rows[0] || null;
}
export async function ensureSchema() { export async function ensureSchema() {
if (schemaReady) return; if (schemaReady) return;
const db = sql(); const db = sql();
+147 -7
View File
@@ -9,6 +9,7 @@ import {
updateRealPositionMark, updateRealPositionMark,
updateTradeTicketStatus, updateTradeTicketStatus,
} from "./_db.js"; } from "./_db.js";
import nodemailer from "nodemailer";
const REQUIRED_ENV = [ const REQUIRED_ENV = [
["PRODUCTION_APP_URL", "Production app URL"], ["PRODUCTION_APP_URL", "Production app URL"],
@@ -173,6 +174,28 @@ function stackStatus() {
}); });
} }
function tradeEmailStatus() {
const resend = Boolean(process.env.RESEND_API_KEY);
const webhook = Boolean(process.env.TRADE_EMAIL_WEBHOOK_URL);
const smtpMissing = ["SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASS"].filter((key) => !process.env[key]);
const smtp = smtpMissing.length === 0;
const provider = resend ? "resend" : (smtp ? "smtp" : (webhook ? "webhook" : null));
return {
configured: Boolean(provider),
provider,
options: {
resend: { configured: resend, missing: resend ? [] : ["RESEND_API_KEY"] },
smtp: { configured: smtp, missing: smtpMissing },
webhook: { configured: webhook, missing: webhook ? [] : ["TRADE_EMAIL_WEBHOOK_URL"] },
},
missing_any_one_of: provider ? [] : [
"RESEND_API_KEY",
"or SMTP_HOST + SMTP_PORT + SMTP_USER + SMTP_PASS",
"or TRADE_EMAIL_WEBHOOK_URL",
],
};
}
function liveTradingReady() { function liveTradingReady() {
const requiredConfigured = providerStatus().every((x) => x.configured); const requiredConfigured = providerStatus().every((x) => x.configured);
const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true"; const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true";
@@ -214,6 +237,7 @@ function baseStatus() {
: "Live trading is locked until eligibility, payments, wallet/deposit-wallet signing, Polymarket CLOB credentials, audit storage, monitoring, and LIVE_TRADING_ENABLED=true are configured.", : "Live trading is locked until eligibility, payments, wallet/deposit-wallet signing, Polymarket CLOB credentials, audit storage, monitoring, and LIVE_TRADING_ENABLED=true are configured.",
providers, providers,
provider_stack: stackStatus(), provider_stack: stackStatus(),
trade_email: tradeEmailStatus(),
personal_requirements: personal, personal_requirements: personal,
launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })), launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })),
webhooks: WEBHOOK_ROUTES.map(([provider, label, path]) => ({ webhooks: WEBHOOK_ROUTES.map(([provider, label, path]) => ({
@@ -336,6 +360,31 @@ async function sendTradeEmail({ to, events, appUrl, test }) {
return { status: 200, body: { ok: true, provider: "resend", id: data?.id || null, sent: events.length } }; return { status: 200, body: { ok: true, provider: "resend", id: data?.id || null, sent: events.length } };
} }
if (process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS) {
const port = Number(process.env.SMTP_PORT);
const secure = String(process.env.SMTP_SECURE || "").toLowerCase() === "true" || port === 465;
const from = process.env.TRADE_EMAIL_FROM || process.env.SMTP_FROM || process.env.SMTP_USER;
const replyTo = process.env.TRADE_EMAIL_REPLY_TO || process.env.CUSTOMER_SUPPORT_EMAIL || undefined;
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port,
secure,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const info = await transporter.sendMail({
from,
to: email,
replyTo,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
return { status: 200, body: { ok: true, provider: "smtp", id: info?.messageId || null, sent: events.length } };
}
if (process.env.TRADE_EMAIL_WEBHOOK_URL) { if (process.env.TRADE_EMAIL_WEBHOOK_URL) {
const response = await fetch(process.env.TRADE_EMAIL_WEBHOOK_URL, { const response = await fetch(process.env.TRADE_EMAIL_WEBHOOK_URL, {
method: "POST", method: "POST",
@@ -350,7 +399,8 @@ async function sendTradeEmail({ to, events, appUrl, test }) {
status: 501, status: 501,
body: { body: {
ok: false, ok: false,
error: "Email alerts need RESEND_API_KEY plus TRADE_EMAIL_FROM, or TRADE_EMAIL_WEBHOOK_URL, in Vercel environment variables.", error: "Email alerts need a delivery provider in Vercel: RESEND_API_KEY, or SMTP_HOST + SMTP_PORT + SMTP_USER + SMTP_PASS, or TRADE_EMAIL_WEBHOOK_URL.",
trade_email: tradeEmailStatus(),
}, },
}; };
} }
@@ -600,6 +650,83 @@ function validateCapitalAction(action) {
return errors; return errors;
} }
function stripTags(value) {
return String(value || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
function decodeXml(value) {
return String(value || "")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, "\"")
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
function cleanMarketContextText(value, max = 220) {
return String(value || "").replace(/\s+/g, " ").trim().slice(0, max);
}
function contextQuery(market) {
const q = cleanMarketContextText(market?.question, 140)
.replace(/\bwill\b/ig, "")
.replace(/\?/g, "")
.replace(/\s+/g, " ")
.trim();
const cat = cleanMarketContextText(market?.category, 40);
if (cat === "Sports") return `${q} injury lineup odds`;
if (cat === "Politics") return `${q} poll election news`;
if (cat === "Crypto") return `${q} crypto market news`;
if (cat === "Economy") return `${q} economy fed inflation news`;
if (cat === "Pop Culture") return `${q} entertainment latest`;
return `${q} latest news`;
}
async function fetchNewsContext(market) {
const query = contextQuery(market);
if (!query || query.length < 8) return { source_count: 0, latest_title: "", query };
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 900);
try {
const response = await fetch(url, {
headers: { "User-Agent": "PolyArenaContext/1.0" },
signal: controller.signal,
});
if (!response.ok) return { source_count: 0, latest_title: "", query };
const xml = await response.text();
const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].slice(0, 5);
const titles = items.map((item) => {
const match = item[1].match(/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>|<title>([\s\S]*?)<\/title>/);
return cleanMarketContextText(decodeXml(stripTags(match ? (match[1] || match[2]) : "")), 160);
}).filter(Boolean);
return { source_count: titles.length, latest_title: titles[0] || "", query };
} catch (err) {
return { source_count: 0, latest_title: "", query, error: err?.name === "AbortError" ? "timeout" : "unavailable" };
} finally {
clearTimeout(timer);
}
}
async function handleMarketContext(body, res) {
const markets = (Array.isArray(body.markets) ? body.markets : []).slice(0, 120).map((market) => ({
id: cleanMarketContextText(market?.id, 80),
question: cleanMarketContextText(market?.question, 220),
category: cleanMarketContextText(market?.category, 60),
tags: Array.isArray(market?.tags) ? market.tags.slice(0, 6).map((tag) => cleanMarketContextText(tag, 40)) : [],
})).filter((market) => market.id && market.question);
const signals = {};
const batchSize = 24;
for (let i = 0; i < markets.length; i += batchSize) {
const batch = markets.slice(i, i + batchSize);
const results = await Promise.all(batch.map(fetchNewsContext));
results.forEach((signal, idx) => {
signals[batch[idx].id] = signal;
});
}
return res.status(200).json({ ok: true, count: Object.keys(signals).length, signals });
}
export default async function handler(req, res) { export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store"); res.setHeader("Cache-Control", "no-store");
@@ -627,12 +754,21 @@ export default async function handler(req, res) {
if (body.action === "trade_email") { if (body.action === "trade_email") {
const events = normalizeTradeEmailEvents(body.events); const events = normalizeTradeEmailEvents(body.events);
const result = await sendTradeEmail({ let result;
to: body.to, try {
events, result = await sendTradeEmail({
appUrl: body.app_url, to: body.to,
test: Boolean(body.test), events,
}); appUrl: body.app_url,
test: Boolean(body.test),
});
} catch (err) {
return res.status(502).json({
ok: false,
error: err?.response || err?.message || "Email provider failed to send the alert.",
trade_email: tradeEmailStatus(),
});
}
if (result.body?.ok) { if (result.body?.ok) {
await recordAuditEvent("TRADE_EMAIL_ALERT_SENT", { await recordAuditEvent("TRADE_EMAIL_ALERT_SENT", {
to_domain: String(body.to || "").split("@")[1] || null, to_domain: String(body.to || "").split("@")[1] || null,
@@ -648,6 +784,10 @@ export default async function handler(req, res) {
return handleAgentChat(body, res); return handleAgentChat(body, res);
} }
if (body.action === "market_context") {
return handleMarketContext(body, res);
}
if (body.action === "ticket") { if (body.action === "ticket") {
const ticket = normalizeTicket(body); const ticket = normalizeTicket(body);
const ticketErrors = validateTicket(ticket); const ticketErrors = validateTicket(ticket);
+162 -18
View File
@@ -1,20 +1,130 @@
import { get, put } from "@vercel/blob"; import { get, list, put } from "@vercel/blob";
import { hasDatabase, readSharedAppState, writeSharedAppState } from "./_db.js";
const STATE_PATH = process.env.PMA_STATE_PATH || "shared/state.json"; 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 AGENTS_KEY = "pma_agents_v2"; const AGENTS_KEY = "pma_agents_v2";
const SUG_KEY = "pma_suggestions_v5"; const SUG_KEY = "pma_suggestions_v5";
const PAPER_KEY = "pma_paper_accounts_v1"; const PAPER_KEY = "pma_paper_accounts_v1";
const LIVE_KEY = "pma_live_readiness_v1"; const LIVE_KEY = "pma_live_readiness_v1";
const AGENT_IDS = ["value", "momentum", "favorite", "longshot", "diversifier", "copycat", "whale1", "whale2", "whale3", "whale4"]; const AGENT_IDS = ["value", "momentum", "favorite", "longshot", "diversifier", "catalyst", "reversal", "breakout", "tailalpha", "conviction"];
const LIMITS = { closed: 80, history: 160, snapshots: 240, suggestions: 120, paperHistory: 120, paperSnapshots: 120, audit: 120 }; const LIMITS = { closed: 80, history: 160, snapshots: 240, suggestions: 900, paperHistory: 120, paperSnapshots: 120, audit: 120 };
function withBlobAuth(options = {}) {
const token = process.env.BLOB_READ_WRITE_TOKEN;
return token ? { ...options, token } : options;
}
async function readJsonBlob() { async function readJsonBlob() {
const blob = await get(STATE_PATH, { access: "private" }); let databaseAvailable = false;
if (hasDatabase()) {
try {
const row = await readSharedAppState(DATABASE_STATE_KEY);
databaseAvailable = true;
if (row && row.payload) return row.payload;
} catch {
databaseAvailable = false;
}
}
let primaryError = null;
try {
const primary = await readBlobJson(STATE_PATH);
if (primary) return primary;
} catch (err) {
primaryError = err;
}
try {
const latest = await latestVersionedStateBlob();
if (latest) return latest;
} catch (err) {
if (!primaryError) primaryError = err;
}
if (primaryError && !databaseAvailable) throw primaryError;
return null;
}
async function persistState(state) {
let databaseSaved = false;
let databaseError = null;
if (hasDatabase()) {
try {
await writeSharedAppState(DATABASE_STATE_KEY, state);
databaseSaved = true;
} catch (err) {
databaseError = err;
}
}
let blobSaved = false;
let blobError = null;
try {
await put(STATE_PATH, JSON.stringify(state), withBlobAuth({
access: "private",
allowOverwrite: true,
contentType: "application/json",
cacheControlMaxAge: 0,
}));
blobSaved = true;
} catch (err) {
blobError = err;
}
if (!databaseSaved && !blobSaved) throw databaseError || blobError || new Error("No state provider is available");
return { databaseSaved, blobSaved };
}
async function readBlobJson(pathname) {
const blob = await get(pathname, withBlobAuth({
access: "private",
headers: { "cache-control": "no-cache" },
}));
if (!blob || blob.statusCode !== 200 || !blob.stream) return null; if (!blob || blob.statusCode !== 200 || !blob.stream) return null;
const text = await new Response(blob.stream).text(); const text = await new Response(blob.stream).text();
return text ? JSON.parse(text) : null; return text ? JSON.parse(text) : null;
} }
async function latestVersionedStateBlob() {
let cursor;
let newest = null;
do {
const page = await list(withBlobAuth({ prefix: STATE_VERSION_PREFIX, limit: 1000, cursor }));
for (const blob of page.blobs || []) {
if (!newest || new Date(blob.uploadedAt).getTime() > new Date(newest.uploadedAt).getTime()) newest = blob;
}
cursor = page.cursor;
} while (cursor);
if (!newest) return null;
return readBlobJson(newest.pathname);
}
function cycleVersion(cycle = "") {
const m = String(cycle).match(/\|v(\d+)$/);
return m ? Number(m[1]) : 0;
}
function openPositionCount(st) {
if (!st || !st.agents) return 0;
return AGENT_IDS.reduce((sum, id) => sum + (Array.isArray(st.agents[id]?.positions) ? st.agents[id].positions.length : 0), 0);
}
function shouldRejectStaleAgentWrite(currentAgents, incomingAgents) {
if (!currentAgents || !incomingAgents) return false;
const currentCycle = currentAgents.last_cycle_hour || "";
const incomingCycle = incomingAgents.last_cycle_hour || "";
if (!currentCycle || !incomingCycle) return false;
const currentVersion = cycleVersion(currentCycle);
const incomingVersion = cycleVersion(incomingCycle);
if (incomingVersion < currentVersion) return true;
if (incomingVersion === currentVersion && incomingCycle < currentCycle) return true;
const currentOpen = openPositionCount(currentAgents);
const incomingOpen = openPositionCount(incomingAgents);
return incomingVersion === currentVersion && incomingCycle === currentCycle && currentOpen > 0 && incomingOpen === 0;
}
function agentStateFromItems(items) { function agentStateFromItems(items) {
if (!items || !items[AGENTS_KEY]) return null; if (!items || !items[AGENTS_KEY]) return null;
try { try {
@@ -24,6 +134,15 @@ function agentStateFromItems(items) {
} }
} }
function suggestionStateFromItems(items) {
if (!items || !items[SUG_KEY]) return null;
try {
return JSON.parse(items[SUG_KEY]);
} catch {
return null;
}
}
function compactPortfolio(p) { function compactPortfolio(p) {
if (!p || typeof p !== "object") return p; if (!p || typeof p !== "object") return p;
const out = { ...p }; const out = { ...p };
@@ -41,7 +160,8 @@ function compactAgentState(st) {
for (const id of AGENT_IDS) { for (const id of AGENT_IDS) {
out.agents[id] = compactPortfolio(st.agents && st.agents[id]); out.agents[id] = compactPortfolio(st.agents && st.agents[id]);
} }
out.whales = st.whales || {}; delete out.whales;
delete out.copycatLeader;
return out; return out;
} }
@@ -51,7 +171,11 @@ function compactSuggestion(s) {
market_id: s.market_id, question: s.question, event: s.event, url: s.url, category: s.category, market_id: s.market_id, question: s.question, event: s.event, url: s.url, category: s.category,
clob_yes: s.clob_yes, clob_no: s.clob_no, yes_price: s.yes_price, no_price: s.no_price, clob_yes: s.clob_yes, clob_no: s.clob_no, yes_price: s.yes_price, no_price: s.no_price,
fair_value: s.fair_value, edge: s.edge, side: s.side, entry_price: s.entry_price, fair_value: s.fair_value, edge: s.edge, side: s.side, entry_price: s.entry_price,
conviction: s.conviction, volume: s.volume, volume_24hr: s.volume_24hr, net_edge: s.net_edge, friction: s.friction, chase_penalty: s.chase_penalty,
evidence_score: s.evidence_score, evidence_source_count: s.evidence_source_count, quality: s.quality,
conviction: s.conviction, volume: s.volume, volume_24hr: s.volume_24hr, liquidity: s.liquidity,
spread: s.spread, price_change_1h: s.price_change_1h, price_change_1d: s.price_change_1d, price_change_1w: s.price_change_1w,
momentum_strength: s.momentum_strength, trade_ready: s.trade_ready, watch_only: s.watch_only, jump_risk: s.jump_risk,
days_to_resolution: s.days_to_resolution, drivers: s.drivers, rationale: s.rationale, days_to_resolution: s.days_to_resolution, drivers: s.drivers, rationale: s.rationale,
}; };
} }
@@ -96,7 +220,7 @@ function conflictResponse(res, error, current) {
export default async function handler(req, res) { export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store"); res.setHeader("Cache-Control", "no-store");
try { try {
if (!process.env.BLOB_READ_WRITE_TOKEN && !process.env.VERCEL_OIDC_TOKEN) { 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" }); return res.status(503).json({ ok: false, error: "Cloud state is not configured" });
} }
@@ -112,30 +236,50 @@ export default async function handler(req, res) {
return res.status(400).json({ ok: false, error: "Invalid state payload" }); return res.status(400).json({ ok: false, error: "Invalid state payload" });
} }
const current = await readJsonBlob(); const current = await readJsonBlob();
const incomingItems = { ...body.items };
const currentAgents = agentStateFromItems(current && current.items); const currentAgents = agentStateFromItems(current && current.items);
const incomingAgents = agentStateFromItems(body.items); let incomingAgents = agentStateFromItems(incomingItems);
const staleAgentWrite = shouldRejectStaleAgentWrite(currentAgents, incomingAgents);
if (body.force && staleAgentWrite && current?.items?.[AGENTS_KEY]) {
incomingItems[AGENTS_KEY] = current.items[AGENTS_KEY];
incomingAgents = currentAgents;
}
const currentSuggestions = suggestionStateFromItems(current && current.items);
const incomingSuggestions = suggestionStateFromItems(incomingItems);
if (currentSuggestions && incomingSuggestions
&& Number(incomingSuggestions.engine_version || 0) < Number(currentSuggestions.engine_version || 0)) {
incomingItems[SUG_KEY] = current.items[SUG_KEY];
}
if (!body.force && currentAgents) { if (!body.force && currentAgents) {
const currentCycle = currentAgents.last_cycle_hour || ""; const currentCycle = currentAgents.last_cycle_hour || "";
const incomingCycle = incomingAgents && incomingAgents.last_cycle_hour ? incomingAgents.last_cycle_hour : ""; const incomingCycle = incomingAgents && incomingAgents.last_cycle_hour ? incomingAgents.last_cycle_hour : "";
if (currentCycle && (!incomingAgents || !incomingCycle)) { if (currentCycle && (!incomingAgents || !incomingCycle)) {
return conflictResponse(res, "Cloud already has a cycle result; refusing unscheduled local state", current); return conflictResponse(res, "Cloud already has a cycle result; refusing unscheduled local state", current);
} }
if (currentCycle && incomingCycle && incomingCycle < currentCycle) {
return conflictResponse(res, "Incoming state is older than the shared cloud result", current);
}
const sameCycle = currentCycle && currentCycle === incomingCycle; const sameCycle = currentCycle && currentCycle === incomingCycle;
const differentRun = currentAgents.last_run && incomingAgents.last_run && currentAgents.last_run !== incomingAgents.last_run; const differentRun = currentAgents.last_run && incomingAgents.last_run && currentAgents.last_run !== incomingAgents.last_run;
if (sameCycle && differentRun) { if (sameCycle && differentRun) {
return conflictResponse(res, "This cycle already has a cloud result", current); return conflictResponse(res, "This cycle already has a cloud result", current);
} }
} }
const state = { version: 1, updated_at: new Date().toISOString(), items: compactItems(body.items) }; if (!body.force && staleAgentWrite) {
await put(STATE_PATH, JSON.stringify(state), { return conflictResponse(res, "Incoming state would replace newer active positions with stale cash-only data", current);
access: "private", }
allowOverwrite: true, const state = { version: 1, updated_at: new Date().toISOString(), items: compactItems(incomingItems) };
contentType: "application/json", const saved = await persistState(state);
cacheControlMaxAge: 60, if (saved.blobSaved && process.env.PMA_ENABLE_STATE_HISTORY === "true") {
}); const versionedPath = `${STATE_VERSION_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
try {
await put(versionedPath, JSON.stringify(state), withBlobAuth({
access: "private",
allowOverwrite: false,
contentType: "application/json",
cacheControlMaxAge: 0,
}));
} catch {
// The shared state is authoritative; backup retention must not block a cycle.
}
}
return res.status(200).json({ ok: true, state }); return res.status(200).json({ ok: true, state });
} }
+786 -485
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"@neondatabase/serverless": "^1.1.0", "@neondatabase/serverless": "^1.1.0",
"@vercel/blob": "2.5.0", "@vercel/blob": "2.5.0",
"nodemailer": "^9.0.3",
"svix": "^1.96.1" "svix": "^1.96.1"
} }
}, },
@@ -222,6 +223,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/npm-run-path": { "node_modules/npm-run-path": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+1
View File
@@ -2,6 +2,7 @@
"dependencies": { "dependencies": {
"@neondatabase/serverless": "^1.1.0", "@neondatabase/serverless": "^1.1.0",
"@vercel/blob": "2.5.0", "@vercel/blob": "2.5.0",
"nodemailer": "^9.0.3",
"svix": "^1.96.1" "svix": "^1.96.1"
} }
} }