Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const STANDALONE_KEYS = {
simulationPackageLatest: 'forecast:simulation-package:latest',
simulationOutcomeLatest: 'forecast:simulation-outcome:latest',
newsThreatSummary: 'news:threat:summary:v1',
regulatoryActions: 'regulatory:actions:v1',
};

const SEED_META = {
Expand Down Expand Up @@ -180,6 +181,7 @@ const SEED_META = {
blsSeries: { key: 'seed-meta:economic:bls-series', maxStaleMin: 2880 }, // daily seed; 2880min = 48h = 2x interval
sanctionsPressure: { key: 'seed-meta:sanctions:pressure', maxStaleMin: 720 },
crossSourceSignals: { key: 'seed-meta:intelligence:cross-source-signals', maxStaleMin: 30 }, // 15min cron; 30min = 2x interval
regulatoryActions: { key: 'seed-meta:regulatory:actions', maxStaleMin: 240 },
sanctionsEntities: { key: 'seed-meta:sanctions:entities', maxStaleMin: 1440 }, // 12h cron; 1440min = 24h = 2x interval
radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 },
groceryBasket: { key: 'seed-meta:economic:grocery-basket', maxStaleMin: 10080 }, // weekly seed; 10080 = 7 days
Expand Down
57 changes: 41 additions & 16 deletions scripts/railway-set-watch-paths.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Sets watchPatterns and validates startCommand on all Railway seed services.
* Sets watchPatterns, validates startCommand, and syncs cronSchedule on Railway seed services.
*
* All seed services use rootDirectory="scripts", so the correct startCommand
* is `node seed-<name>.mjs` (NOT `node scripts/seed-<name>.mjs` — that path
Expand All @@ -22,6 +22,10 @@ const DRY_RUN = process.argv.includes('--dry-run');
const PROJECT_ID = '29419572-0b0d-437f-8e71-4fa68daf514f';
const ENV_ID = '91a05726-0b83-4d44-a33e-6aec94e58780';
const API = 'https://backboard.railway.app/graphql/v2';
const REQUIRED_SEED_SERVICES = new Set(['seed-regulatory-actions']);
const EXPECTED_CRON_SCHEDULES = new Map([
['seed-regulatory-actions', '0 */2 * * *'],
]);

// Seeds that use loadSharedConfig (depend on scripts/shared/*.json)
const USES_SHARED_CONFIG = new Set([
Expand Down Expand Up @@ -50,6 +54,21 @@ async function gql(token, query, variables = {}) {
return json.data;
}

function buildExpectedPatterns(serviceName) {
const scriptFile = `scripts/${serviceName}.mjs`;
const patterns = [scriptFile, 'scripts/_seed-utils.mjs', 'scripts/package.json'];

if (USES_SHARED_CONFIG.has(serviceName)) {
patterns.push('scripts/shared/**', 'shared/**');
}

if (serviceName === 'seed-iran-events') {
patterns.push('scripts/data/iran-events-latest.json');
}

return patterns;
}

async function main() {
const token = getToken();

Expand All @@ -66,15 +85,22 @@ async function main() {
.map(e => e.node)
.filter(s => s.name.startsWith('seed-'));

const missingRequiredServices = [...REQUIRED_SEED_SERVICES].filter(
(name) => !services.some((service) => service.name === name)
);
if (missingRequiredServices.length > 0) {
throw new Error(`Missing required seed service(s): ${missingRequiredServices.join(', ')}`);
}

console.log(`Found ${services.length} seed services\n`);

// 2. Check each service's watchPatterns and startCommand
// 2. Check each service's watchPatterns, startCommand, and cronSchedule
for (const svc of services) {
const { service } = await gql(token, `
query ($id: String!, $envId: String!) {
service(id: $id) {
serviceInstances(first: 1, environmentId: $envId) {
edges { node { watchPatterns startCommand } }
edges { node { watchPatterns startCommand cronSchedule } }
}
}
}
Expand All @@ -83,26 +109,20 @@ async function main() {
const instance = service.serviceInstances.edges[0]?.node || {};
const currentPatterns = instance.watchPatterns || [];
const currentStartCmd = instance.startCommand || '';
const currentCronSchedule = instance.cronSchedule || '';

// rootDirectory="scripts" so startCommand must NOT include the scripts/ prefix
const expectedStartCmd = `node ${svc.name}.mjs`;
const startCmdOk = currentStartCmd === expectedStartCmd;
const expectedCronSchedule = EXPECTED_CRON_SCHEDULES.get(svc.name) || '';
const hasExpectedCronSchedule = EXPECTED_CRON_SCHEDULES.has(svc.name);
const cronScheduleOk = !hasExpectedCronSchedule || currentCronSchedule === expectedCronSchedule;

// Build expected watch patterns (relative to git repo root)
const scriptFile = `scripts/${svc.name}.mjs`;
const patterns = [scriptFile, 'scripts/_seed-utils.mjs', 'scripts/package.json'];

if (USES_SHARED_CONFIG.has(svc.name)) {
patterns.push('scripts/shared/**', 'shared/**');
}
const patterns = buildExpectedPatterns(svc.name);
const patternsOk = JSON.stringify([...currentPatterns].sort()) === JSON.stringify([...patterns].sort());

if (svc.name === 'seed-iran-events') {
patterns.push('scripts/data/iran-events-latest.json');
}

const patternsOk = JSON.stringify(currentPatterns.sort()) === JSON.stringify([...patterns].sort());

if (patternsOk && startCmdOk) {
if (patternsOk && startCmdOk && cronScheduleOk) {
console.log(` ${svc.name}: already correct`);
continue;
}
Expand All @@ -116,6 +136,10 @@ async function main() {
console.log(` watchPatterns current: ${currentPatterns.length ? currentPatterns.join(', ') : '(none)'}`);
console.log(` watchPatterns setting: ${patterns.join(', ')}`);
}
if (hasExpectedCronSchedule && !cronScheduleOk) {
console.log(` cronSchedule current: ${currentCronSchedule || '(none)'}`);
console.log(` cronSchedule expected: ${expectedCronSchedule}`);
}

if (DRY_RUN) {
console.log(` [DRY RUN] skipped\n`);
Expand All @@ -126,6 +150,7 @@ async function main() {
const input = {};
if (!patternsOk) input.watchPatterns = patterns;
if (!startCmdOk) input.startCommand = expectedStartCmd;
if (hasExpectedCronSchedule && !cronScheduleOk) input.cronSchedule = expectedCronSchedule;

await gql(token, `
mutation ($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) {
Expand Down
41 changes: 41 additions & 0 deletions scripts/seed-cross-source-signals.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const SOURCE_KEYS = [
'gdelt:intel:tone:maritime',
'weather:alerts:v1',
'risk:scores:sebuf:stale:v1',
'regulatory:actions:v1',
];

// ── Theater classification helpers ────────────────────────────────────────────
Expand Down Expand Up @@ -109,6 +110,7 @@ const TYPE_CATEGORY = {
CROSS_SOURCE_SIGNAL_TYPE_WEATHER_EXTREME: 'natural',
CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION: 'information',
CROSS_SOURCE_SIGNAL_TYPE_RISK_SCORE_SPIKE: 'intelligence',
CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION: 'policy',
};

// Base severity weights for each signal type
Expand Down Expand Up @@ -139,6 +141,7 @@ const BASE_WEIGHT = {
CROSS_SOURCE_SIGNAL_TYPE_FORECAST_DETERIORATION: 1.5, // predictive — lower confidence
CROSS_SOURCE_SIGNAL_TYPE_WEATHER_EXTREME: 1.5, // environmental — regional
CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION: 1.5, // sentiment — lagging
CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION: 2.0, // policy action — direct market impact
};

function scoreTier(score) {
Expand Down Expand Up @@ -713,6 +716,43 @@ function extractRiskScoreSpike(d) {
});
}

function extractRegulatoryAction(d) {
const payload = d['regulatory:actions:v1'];
if (!payload) return [];
const cutoff = Date.now() - 48 * 3600 * 1000;
const tierPriority = { high: 0, medium: 1 };
const recent = (payload.actions || [])
.map((action) => ({
action,
publishedAtTs: safeNum(Date.parse(action.publishedAt)),
}))
.filter(({ action, publishedAtTs }) => (action.tier === 'high' || action.tier === 'medium') && publishedAtTs > cutoff)
.sort((a, b) => {
const tierOrder = tierPriority[a.action.tier] - tierPriority[b.action.tier];
if (tierOrder !== 0) return tierOrder;
return b.publishedAtTs - a.publishedAtTs;
})
.slice(0, 3);
if (recent.length === 0) return [];
return recent.map(({ action, publishedAtTs }) => {
const tierMult = action.tier === 'high' ? 1.5 : 1.0;
const score = BASE_WEIGHT.CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION * tierMult;
return {
id: `regulatory:${action.id}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION',
// Temporary mapping for the current US-only regulator set; make this
// agency-aware when non-US regulatory feeds are added.
theater: 'Global Markets',
summary: `${action.agency}: ${action.title}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: publishedAtTs || Date.now(),
contributingTypes: [],
signalCount: 0,
};
});
}

// ── Composite escalation detector ─────────────────────────────────────────────
// Fires when >=3 signals from DIFFERENT categories share the same theater.
function detectCompositeEscalation(signals) {
Expand Down Expand Up @@ -790,6 +830,7 @@ async function aggregateCrossSourceSignals() {
extractWeatherExtreme,
extractMediaToneDeterioration,
extractRiskScoreSpike,
extractRegulatoryAction,
];

for (const extractor of extractors) {
Expand Down
Loading
Loading