SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
8.4 KB · 174 lines typescript
Raw Blame History
1#!/usr/bin/env tsx2/**3 * CancerIndex operator CLI.4 *5 *   pnpm cix connectors                      list connectors + health6 *   pnpm cix run <id> [--mode full|incremental|dry_run] [--max-records N] [--max-minutes M] [--reset-cursor]7 *   pnpm cix run-all [--max-minutes M]       run every active connector in registry order8 *   pnpm cix health <id>                     source liveness probe9 *   pnpm cix sources:sync                    manifests → sources table10 *   pnpm cix counters                        recompute entity counters11 *   pnpm cix rank [--scope …]                recompute ranking snapshots12 *   pnpm cix stats                           table counts13 *   pnpm cix trace <table> <id>              lineage of a value (rankings/epidemiology/frequency)14 *   pnpm cix doctor [--json] [--no-disk]     readiness report (env, DB, migrations, connectors, alerts…); exit 1 on hard failure15 *   pnpm cix alerts [--status open|acknowledged|resolved|active] [--connector id] [--json]16 *   pnpm cix alerts ack|resolve <id>         acknowledge / resolve one alert17 */18import { existsSync } from 'node:fs';19import path from 'node:path';20import { sql } from 'drizzle-orm';2122for (const candidate of [path.resolve(process.cwd(), '.env')]) {23  if (existsSync(candidate)) {24    try {25      process.loadEnvFile(candidate);26    } catch {27      /* ignore */28    }29  }30}3132const [, , cmd, ...rest] = process.argv;33const flags: Record<string, string | boolean> = {};34const positional: string[] = [];35for (let i = 0; i < rest.length; i++) {36  const a = rest[i]!;37  if (a.startsWith('--')) {38    const k = a.slice(2);39    const next = rest[i + 1];40    if (next && !next.startsWith('--')) {41      flags[k] = next;42      i++;43    } else flags[k] = true;44  } else positional.push(a);45}4647async function main() {48  const { getDb, closeDb } = await import('@cancerindex/database');49  const db = getDb();50  try {51    switch (cmd) {52      case 'connectors': {53        const { CONNECTORS } = await import('@cancerindex/connectors');54        const rows = await db.execute<{ connector_id: string; health: string; last_success_at: string | null; cursor: unknown }>(sql`SELECT connector_id, health, last_success_at, cursor FROM connector_cursors`);55        const byId = new Map(rows.map((r) => [r.connector_id, r]));56        for (const c of CONNECTORS) {57          const h = byId.get(c.manifest.id);58          console.log(`${c.manifest.id.padEnd(18)} tier${c.manifest.tier} ${c.manifest.status.padEnd(20)} license=${c.manifest.licenseStatus.padEnd(10)} health=${(h?.health ?? 'never-run').padEnd(12)} last_ok=${h?.last_success_at ?? '-'}`);59        }60        break;61      }62      case 'sources:sync': {63        const { syncSources, PLANNED_MANIFESTS } = await import('@cancerindex/connectors');64        const r = await syncSources(db, PLANNED_MANIFESTS);65        console.log(`[sources] created=${r.created} updated=${r.updated}`);66        break;67      }68      case 'health': {69        const { getConnector, RunContext } = await import('@cancerindex/connectors');70        const c = getConnector(positional[0] ?? '');71        if (!c) throw new Error(`unknown connector ${positional[0]}`);72        const [src] = await db.execute<{ id: string }>(sql`SELECT id FROM sources WHERE slug = ${c.manifest.id}`);73        const ctx = new RunContext(db, c.manifest, src?.id ?? 'CI-SOURCE-00000000', 'probe', { maxMinutes: 1 }, 0);74        console.log(await c.healthCheck(ctx));75        break;76      }77      case 'run': {78        const { getConnector, runConnector } = await import('@cancerindex/connectors');79        const c = getConnector(positional[0] ?? '');80        if (!c) throw new Error(`unknown connector ${positional[0]}`);81        const r = await runConnector(db, c, {82          mode: (flags.mode as never) ?? undefined,83          maxRecords: flags['max-records'] ? Number(flags['max-records']) : undefined,84          maxMinutes: flags['max-minutes'] ? Number(flags['max-minutes']) : undefined,85          resetCursor: !!flags['reset-cursor'],86        });87        console.log(`[run] ${r.runId} ${r.status}`, r.counters);88        if (r.status === 'failed') process.exitCode = 1;89        break;90      }91      case 'run-all': {92        const { CONNECTORS, runConnector } = await import('@cancerindex/connectors');93        for (const c of CONNECTORS) {94          if (c.manifest.status !== 'active') {95            console.log(`[skip] ${c.manifest.id} (${c.manifest.status})`);96            continue;97          }98          const r = await runConnector(db, c, { maxMinutes: flags['max-minutes'] ? Number(flags['max-minutes']) : undefined });99          console.log(`[run] ${r.runId} ${r.status}`, r.counters);100        }101        break;102      }103      case 'counters': {104        const { refreshCounters } = await import('@cancerindex/ranking');105        const n = await refreshCounters(db);106        console.log(`[counters] refreshed ${n} entities`);107        break;108      }109      case 'intel': {110        const { computeIntelligence } = await import('@cancerindex/ranking');111        const r = await computeIntelligence(db, (m, extra) => console.log(`[intel] ${m}`, extra ?? ''));112        console.log(`[intel] trial_intelligence=${r.trialIntelligenceRows} site_countries=${r.trialSiteCountryRows} drug_pipeline=${r.drugPipelineRows} research_gap=${r.researchGapRows} (${r.ms} ms)`);113        break;114      }115      case 'reconcile-drugs': {116        const { reconcileInterventionDrugs } = await import('@cancerindex/connectors');117        const r = await reconcileInterventionDrugs(db, { remap: !!flags.remap });118        console.log('[reconcile-drugs]', r);119        break;120      }121      case 'rank': {122        const { computeAllRankings } = await import('@cancerindex/ranking');123        const res = await computeAllRankings(db);124        for (const r of res) console.log(`[rank] ${r.metricSlug} ${r.scopeKey} eligible=${r.eligible}`);125        break;126      }127      case 'stats': {128        const tables = ['sources', 'ingest_runs', 'source_records', 'provenance', 'cancers', 'cancer_aliases', 'cancer_hierarchy', 'cancer_codes', 'genes', 'variants', 'drugs', 'clinical_trials', 'trial_conditions', 'publications', 'literature_counts', 'civic_evidence_items', 'genomic_cohorts', 'cancer_gene_frequencies', 'epidemiology_observations', 'survival_observations', 'knowledge_edges', 'unresolved_labels', 'rankings', 'ranking_snapshots'];129        for (const t of tables) {130          const [r] = await db.execute<{ n: string }>(sql.raw(`SELECT count(*)::text AS n FROM ${t}`));131          console.log(`${t.padEnd(28)} ${r?.n}`);132        }133        break;134      }135      case 'trace': {136        const { traceValue } = await import('@cancerindex/ranking');137        console.log(JSON.stringify(await traceValue(db, positional[0] ?? '', positional[1] ?? ''), null, 2));138        break;139      }140      case 'doctor': {141        const { runDoctor, formatDoctorReport } = await import('@cancerindex/connectors');142        const report = await runDoctor(db, { skipDisk: !!flags['no-disk'] });143        console.log(flags.json ? JSON.stringify(report, null, 2) : formatDoctorReport(report));144        if (report.hardFailures > 0) process.exitCode = 1;145        break;146      }147      case 'alerts': {148        const { listAlerts } = await import('@cancerindex/database');149        const { formatAlerts } = await import('@cancerindex/connectors');150        const sub = positional[0];151        if ((sub === 'ack' || sub === 'resolve') && positional[1]) {152          const status = sub === 'ack' ? 'acknowledged' : 'resolved';153          const rows = await db.execute<{ id: number }>(sql`UPDATE system_alerts SET status = ${status}, resolved_at = CASE WHEN ${status} = 'resolved' THEN now() ELSE resolved_at END WHERE id = ${Number(positional[1])} AND status <> 'resolved' RETURNING id`);154          console.log(rows.length ? `[alerts] #${positional[1]} → ${status}` : `[alerts] #${positional[1]} not found or already resolved`);155          break;156        }157        const status = (flags.status as string | undefined) ?? 'active';158        const alerts = await listAlerts(db, { status: status as never, connectorId: flags.connector as string | undefined, limit: flags.limit ? Number(flags.limit) : 200 });159        console.log(flags.json ? JSON.stringify(alerts, null, 2) : formatAlerts(alerts));160        break;161      }162      default:163        console.log('usage: pnpm cix <connectors|run|run-all|health|sources:sync|counters|intel|reconcile-drugs [--remap]|rank|stats|trace|doctor|alerts>');164    }165  } finally {166    await closeDb();167  }168}169170main().catch((e) => {171  console.error(e);172  process.exit(1);173});174