Ctrl K
ComunidadGuías…

scripts/china-coverage-health.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { unwrapEnvelope } from './_seed-envelope-source.mjs';
import { getOptionalUpstashCreds } from './_upstash-rest.mjs';
import {
  CHINA_COVERAGE_ENTRIES,
  CHINA_COVERAGE_REASON_CODES as REASON,
  CHINA_COVERAGE_SUMMARY_KEY,
  chinaCoverageRedisKeys,
} from './china-coverage-manifest.mjs';

const MINUTE_MS = 60_000;

function valuesAtPath(root, path = []) {
  let values = [root];
  for (const segment of path) {
    const next = [];
    for (const value of values) {
      if (segment === '*') {
        if (Array.isArray(value)) next.push(...value);
        else if (value && typeof value === 'object') next.push(...Object.values(value));
      } else if (value && typeof value === 'object' && segment in value) {
        next.push(value[segment]);
      }
    }
    values = next;
  }
  return values;
}

function timestampMs(value, semantics) {
  if (semantics === 'imf-weo-forecast-year') {
    const year = typeof value === 'string' ? Number(value) : value;
    if (Number.isInteger(year) && year >= 1900 && year <= 2200) {
      // Matches imfForecastYearToMs(): a WEO horizon for N is backed by the
      // most recently observed period at the end of N - 1.
      return Date.UTC(year - 1, 11, 31, 23, 59, 59, 999);
    }
  }
  if (typeof value === 'number' && Number.isFinite(value)) {
    if (value >= 1_000_000_000_000) return value;
    if (value >= 1_000_000_000) return value * 1_000;
    if (Number.isInteger(value) && value >= 1900 && value <= 2200) return Date.UTC(value, 11, 31);
  }
  if (typeof value !== 'string' || value.trim() === '') return null;
  const token = value.trim();
  if (/^\d{4}$/.test(token)) return Date.UTC(Number(token), 11, 31);
  if (/^\d{4}-\d{2}$/.test(token)) {
    const [year, month] = token.split('-').map(Number);
    return Date.UTC(year, month, 0, 23, 59, 59, 999);
  }
  const parsed = Date.parse(token);
  return Number.isFinite(parsed) ? parsed : null;
}

function newestTimestamp(items, timestampPaths, semantics) {
  const timestamps = [];
  for (const item of items) {
    for (const path of timestampPaths ?? []) {
      for (const value of valuesAtPath(item, path)) {
        const parsed = timestampMs(value, semantics);
        if (parsed != null) timestamps.push(parsed);
      }
    }
  }
  return timestamps.length > 0 ? Math.max(...timestamps) : null;
}

function hasSubstantiveValue(value, ignoredFields = new Set()) {
  if (value == null) return false;
  if (typeof value === 'number') return Number.isFinite(value);
  if (typeof value === 'string') return value.trim() !== '';
  if (typeof value === 'boolean') return true;
  if (Array.isArray(value)) return value.some((item) => hasSubstantiveValue(item, ignoredFields));
  if (typeof value === 'object') {
    return Object.entries(value).some(([key, child]) => !ignoredFields.has(key) && hasSubstantiveValue(child, ignoredFields));
  }
  return false;
}

function probeContent(payload, probe) {
  if (!payload || typeof payload !== 'object') return { status: 'missing', rows: [] };

  if (probe.kind === 'object') {
    if (probe.requiredTruthyPaths?.some((path) => !valuesAtPath(payload, path).some(Boolean))) {
      return { status: 'empty', rows: [payload] };
    }
    if (
      Array.isArray(probe.validStatusValues)
      && !valuesAtPath(payload, probe.statusPath ?? ['status'])
        .some((value) => probe.validStatusValues.includes(String(value)))
    ) {
      return { status: 'partial', rows: [payload] };
    }
    return { status: 'present', rows: [payload] };
  }
  if (probe.kind === 'object-property') {
    const value = valuesAtPath(payload, probe.path)[0];
    return value && typeof value === 'object' ? { status: 'present', rows: [value] } : { status: 'missing', rows: [] };
  }

  const source = valuesAtPath(payload, probe.path)[0];
  if (!Array.isArray(source)) return { status: 'missing', rows: [] };
  const wanted = new Set((probe.values ?? []).map(String));
  const matched = source.filter((row) => wanted.has(String(row?.[probe.field] ?? '')));

  if (probe.kind === 'array-coverage') {
    const validRows = probe.validValues
      ? matched.filter((row) => probe.validValues.includes(String(row?.[probe.validField ?? 'status'] ?? '')))
      : matched;
    const presentValues = new Set(validRows.map((row) => String(row?.[probe.field] ?? '')));
    if (presentValues.size === 0) return { status: 'missing', rows: [], required: wanted.size, present: 0 };
    if (presentValues.size < wanted.size) {
      return { status: 'partial', rows: validRows, required: wanted.size, present: presentValues.size };
    }
    return { status: 'present', rows: validRows, required: wanted.size, present: presentValues.size };
  }

  return matched.length > 0 ? { status: 'present', rows: matched } : { status: 'missing', rows: [] };
}

function evaluateTransport(entry, data, meta, now) {
  const cfg = entry.transport;
  const source = cfg.key.startsWith('seed-meta:') ? meta[cfg.key] : data[cfg.key];
  if (!source || typeof source !== 'object') return { status: 'missing', ageMin: null, maxAgeMin: cfg.maxAgeMin };
  if (source.status === 'error') return { status: 'error', ageMin: null, maxAgeMin: cfg.maxAgeMin };
  const fetchedAt = newestTimestamp([source], cfg.timestampPaths);
  if (fetchedAt == null) return { status: 'missing', ageMin: null, maxAgeMin: cfg.maxAgeMin };
  const ageMin = Math.round((now - fetchedAt) / MINUTE_MS);
  return {
    status: ageMin < 0 || ageMin > cfg.maxAgeMin ? 'stale' : 'fresh',
    ageMin,
    maxAgeMin: cfg.maxAgeMin,
  };
}

function evaluateContent(entry, data, now) {
  const cfg = entry.content;
  const probed = probeContent(data[cfg.key], cfg.probe);
  const result = {
    status: probed.status,
    ageMin: null,
    maxAgeMin: cfg.maxAgeMin,
    ...(probed.required != null ? { required: probed.required, present: probed.present } : {}),
  };
  if (probed.status === 'missing' || probed.status === 'partial' || probed.status === 'empty') return result;

  const ignored = new Set([
    cfg.probe.field,
    ...(cfg.probe.timestampPaths ?? [])
      .map((path) => path[path.length - 1])
      .filter((part) => part && part !== '*'),
  ]);
  if (!probed.rows.some((row) => hasSubstantiveValue(row, ignored))) return { ...result, status: 'empty' };

  const observedAt = newestTimestamp(probed.rows, cfg.probe.timestampPaths, cfg.probe.timestampSemantics);
  if (observedAt == null) return { ...result, status: 'timestamp_missing' };
  const ageMin = Math.round((now - observedAt) / MINUTE_MS);
  return { ...result, status: ageMin < 0 || ageMin > cfg.maxAgeMin ? 'stale' : 'fresh', ageMin };
}

function reasonCodesFor(transport, content) {
  const reasons = [];
  if (transport.status === 'missing') reasons.push(REASON.TRANSPORT_MISSING);
  if (transport.status === 'stale') reasons.push(REASON.TRANSPORT_STALE);
  if (transport.status === 'error') reasons.push(REASON.TRANSPORT_ERROR);
  if (content.status === 'missing') reasons.push(REASON.CHINA_ROW_MISSING);
  if (content.status === 'empty') reasons.push(REASON.CHINA_ROW_EMPTY);
  if (content.status === 'partial') reasons.push(REASON.CHINA_COVERAGE_PARTIAL);
  if (content.status === 'timestamp_missing') reasons.push(REASON.CONTENT_TIMESTAMP_MISSING);
  if (content.status === 'stale') reasons.push(REASON.CONTENT_STALE);
  return reasons;
}

export function normalizeChinaProblemIdentity(entries) {
  const problems = entries
    .filter((entry) => entry?.launchStatus === 'launched' && entry?.status !== 'healthy')
    .map((entry) => ({
      id: typeof entry.id === 'string' ? entry.id : '',
      status: typeof entry.status === 'string' ? entry.status : '',
      reasonCodes: [...new Set(
        Array.isArray(entry.reasonCodes)
          ? entry.reasonCodes.filter((reason) => typeof reason === 'string')
          : [],
      )].sort(),
    }))
    .sort((left, right) => {
      const leftKey = JSON.stringify(left);
      const rightKey = JSON.stringify(right);
      return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
    });
  return problems.length > 0 ? JSON.stringify(problems) : null;
}

function isConsistentCoverageSummary(summary, currentEntries, evaluatedAt, now) {
  if (
    !Array.isArray(summary?.entries)
    || summary.entries.length === 0
    || summary.entries.length > 100
  ) return false;

  const ids = new Set();
  for (const entry of summary.entries) {
    if (
      !entry
      || typeof entry !== 'object'
      || typeof entry.id !== 'string'
      || entry.id.length === 0
      || entry.id.length > 100
      || ids.has(entry.id)
      || !['launched', 'planned', 'blocked'].includes(entry.launchStatus)
      || !Array.isArray(entry.reasonCodes)
      || entry.reasonCodes.length > 16
      || entry.reasonCodes.some((reason) => typeof reason !== 'string' || reason.length > 64)
      || (entry.launchStatus === 'launched'
        ? !['healthy', 'degraded', 'unavailable'].includes(entry.status)
        : entry.status !== entry.launchStatus)
    ) {
      return false;
    }
    ids.add(entry.id);
  }

  const launched = summary.entries.filter((entry) => entry.launchStatus === 'launched');
  const currentLaunchedIds = currentEntries
    .filter((entry) => entry.launchStatus === 'launched')
    .map((entry) => entry.id)
    .sort();
  const previousLaunchedIds = launched.map((entry) => entry.id).sort();
  if (
    currentLaunchedIds.length !== previousLaunchedIds.length
    || currentLaunchedIds.some((id, index) => id !== previousLaunchedIds[index])
  ) {
    return false;
  }

  const healthy = launched.filter((entry) => entry.status === 'healthy').length;
  const degraded = launched.filter((entry) => entry.status === 'degraded').length;
  const unavailable = launched.filter((entry) => entry.status === 'unavailable').length;
  const expectedStatus = unavailable === launched.length
    ? 'unavailable'
    : degraded > 0 || unavailable > 0
      ? 'degraded'
      : 'healthy';
  return summary?.schemaVersion === 1
    && summary?.countryCode === 'CN'
    && launched.length > 0
    && healthy + degraded + unavailable === launched.length
    && summary.status === expectedStatus
    && summary?.counts?.total === summary.entries.length
    && summary?.counts?.launched === launched.length
    && summary.counts.planned === summary.entries.filter((entry) => entry.launchStatus === 'planned').length
    && summary.counts.blocked === summary.entries.filter((entry) => entry.launchStatus === 'blocked').length
    && summary.counts.healthy === healthy
    && summary.counts.degraded === degraded
    && summary.counts.unavailable === unavailable
    && Number.isSafeInteger(evaluatedAt)
    && evaluatedAt > 0
    && evaluatedAt <= now;
}

export function evaluateChinaCoverage({
  entries = CHINA_COVERAGE_ENTRIES,
  data = {},
  meta = {},
  now = Date.now(),
  previous = null,
} = {}) {
  const evaluated = entries.map((entry) => {
    if (entry.launchStatus !== 'launched') {
      return {
        id: entry.id,
        label: entry.label,
        ownerIssue: entry.ownerIssue,
        launchStatus: entry.launchStatus,
        status: entry.launchStatus,
        transport: { status: 'not_applicable', ageMin: null, maxAgeMin: null },
        content: { status: 'not_applicable', ageMin: null, maxAgeMin: null },
        reasonCodes: [REASON.NOT_LAUNCHED, ...(entry.blockedReason ? [entry.blockedReason] : [])],
      };
    }

    const transport = evaluateTransport(entry, data, meta, now);
    const content = evaluateContent(entry, data, now);
    const reasonCodes = reasonCodesFor(transport, content);
    const bothMissing = transport.status === 'missing' && content.status === 'missing';
    return {
      id: entry.id,
      label: entry.label,
      ownerIssue: entry.ownerIssue,
      launchStatus: entry.launchStatus,
      status: reasonCodes.length === 0 ? 'healthy' : bothMissing ? 'unavailable' : 'degraded',
      transport,
      content,
      reasonCodes,
    };
  });

  const launched = evaluated.filter((entry) => entry.launchStatus === 'launched');
  const counts = {
    total: evaluated.length,
    launched: launched.length,
    planned: evaluated.filter((entry) => entry.launchStatus === 'planned').length,
    blocked: evaluated.filter((entry) => entry.launchStatus === 'blocked').length,
    healthy: launched.filter((entry) => entry.status === 'healthy').length,
    degraded: launched.filter((entry) => entry.status === 'degraded').length,
    unavailable: launched.filter((entry) => entry.status === 'unavailable').length,
  };
  let status = 'healthy';
  if (launched.length > 0 && counts.unavailable === launched.length) status = 'unavailable';
  else if (counts.degraded > 0 || counts.unavailable > 0) status = 'degraded';

  // Keep the instantaneous status truthful while retaining the last proven
  // healthy clock. Non-healthy evaluations never advance it.
  const degradedProblemKey = normalizeChinaProblemIdentity(evaluated);
  const previousStreak = Number.isInteger(previous?.degradedStreak)
    && previous.degradedStreak > 0
    && previous.degradedProblemKey === degradedProblemKey
    ? previous.degradedStreak
    : 0;
  const degradedStreak = status === 'healthy' ? 0 : previousStreak + 1;
  const previousEvaluatedAt = Date.parse(previous?.evaluatedAt ?? '');
  const explicitLastHealthyAt = Number.isSafeInteger(previous?.lastHealthyAt)
    && previous.lastHealthyAt > 0
    && previous.lastHealthyAt <= previousEvaluatedAt
    && previous.lastHealthyAt <= now
      ? previous.lastHealthyAt
      : null;
  const previousSummaryValid = isConsistentCoverageSummary(previous, evaluated, previousEvaluatedAt, now);
  const legacyLastHealthyAt = previousSummaryValid && previous.status === 'healthy'
      ? previousEvaluatedAt
      : null;
  const lastHealthyAt = status === 'healthy'
    ? now
    : previousSummaryValid ? explicitLastHealthyAt ?? legacyLastHealthyAt : null;

  return {
    schemaVersion: 1,
    countryCode: 'CN',
    status,
    degradedStreak,
    degradedProblemKey,
    lastHealthyAt,
    evaluatedAt: new Date(now).toISOString(),
    counts,
    entries: evaluated,
  };
}

function parseRedisJson(raw) {
  if (raw == null || raw === '') return null;
  if (typeof raw !== 'string') throw new Error('Redis coverage value was not JSON text');
  try {
    return unwrapEnvelope(JSON.parse(raw)).data;
  } catch {
    throw new Error('Redis coverage value was malformed JSON');
  }
}

export async function readChinaCoverageInputs(entries = CHINA_COVERAGE_ENTRIES) {
  const credentials = getOptionalUpstashCreds();
  if (!credentials) throw new Error('Redis not configured');
  const keys = chinaCoverageRedisKeys(entries);
  // The previous summary rides the same pipeline: one extra GET, and the streak
  // cannot be computed without it.
  const ordered = [...keys.data, ...keys.meta, CHINA_COVERAGE_SUMMARY_KEY];
  const response = await fetch(`${credentials.restUrl}/pipeline`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${credentials.token}`,
      'Content-Type': 'application/json',
      'User-Agent': 'worldmonitor-ops/1.0 (+https://worldmonitor.app)',
    },
    body: JSON.stringify(chinaCoverageReadCommands(ordered)),
    signal: AbortSignal.timeout(15_000),
  });
  if (!response.ok) throw new Error(`Redis pipeline failed: HTTP ${response.status}`);
  const results = await response.json();
  if (!Array.isArray(results) || results.length !== ordered.length) {
    throw new Error('Redis pipeline returned an incomplete response');
  }
  const errorCount = results.filter((result) => result?.error).length;
  if (errorCount > 0) throw new Error(`Redis pipeline returned ${errorCount} command error(s)`);
  const data = {};
  const meta = {};
  let previous = null;
  for (let index = 0; index < ordered.length; index++) {
    const key = ordered[index];
    if (index === ordered.length - 1) {
      try {
        previous = parseRedisJson(results[index]?.result);
      } catch {
        // History is advisory. A corrupt prior summary must not prevent this
        // run from publishing a fresh value that repairs the Redis slot.
        previous = null;
      }
      continue;
    }
    const value = parseRedisJson(results[index]?.result);
    if (index < keys.data.length) data[key] = value;
    else meta[key] = value;
  }
  return { data, meta, previous };
}

export function chinaCoverageReadCommands(keys) {
  return keys.map((key) => ['GET', key]);
}

export function formatChinaCoverageHuman(summary) {
  const lines = [
    `China coverage: ${String(summary.status).toUpperCase()} (${summary.counts.healthy}/${summary.counts.launched} launched healthy; ${summary.counts.planned} planned; ${summary.counts.blocked} blocked)`,
    `Evaluated: ${summary.evaluatedAt}`,
  ];
  for (const entry of summary.entries) {
    const reasons = entry.reasonCodes.length > 0 ? ` [${entry.reasonCodes.join(',')}]` : '';
    lines.push(`- ${entry.id}: ${entry.status} transport=${entry.transport.status} content=${entry.content.status}${reasons}`);
  }
  return lines.join('\n');
}