fix(photon): surface npm install failures in check_requirements() diagnostic chain

Problema
--------
Quando o npm install do sidecar Photon falhava, o Hermes descartava toda
evidencia e continuava normalmente - deixando o adapter de iMessage
silenciosamente ausente, sem nenhuma mensagem de erro acionavel.

Tres falhas independentes formavam o caminho de falha silenciosa:

1. check_requirements() sem logging
   Cada branch de return False retornava sem emitir nenhum log. O core em
   platform_registry.py so consome o bool de check_fn() e loga uma mensagem
   generica com o install_hint - sem acesso ao motivo real da falha.

     if not HTTPX_AVAILABLE:     return False  # sem log
     if not shutil.which(node):  return False  # sem log
     if not node_modules.exists: return False  # sem log

2. node_modules/ parcialmente criado passava o guard (Risk 2)
   npm cria node_modules/ antes de abortar em ENOSPC, timeout de rede ou
   EACCES. O diretorio existia, check_requirements() retornava True (falso
   positivo), o adapter era registrado, e o crash acontecia em runtime com
   um erro de modulo ausente aparentemente nao relacionado ao setup.

3. stderr do npm descartado (Risk 3)
   subprocess.run sem stderr=PIPE. O output de erro aparecia no terminal
   durante o setup e sumia depois - diagnostico impossivel em CI/CD, Docker,
   VPS headless, e qualquer reinstalacao posterior.

Correcoes
---------
adapter.py - check_requirements() agora loga por branch:
  - httpx ausente       -> logger.warning com nome do pacote
  - node nao no PATH    -> logger.warning com nome do binario e env var
  - spectrum-ts ausente -> logger.debug com path do sidecar + ultimo erro npm
    (DEBUG nao WARNING: estado normal pre-setup; check_fn() e chamado de
    5 hot paths do core incluindo polling do /api/status)

adapter.py - content check em vez de existence check (Risk 2):
  antes:  if not (_SIDECAR_DIR / node_modules).exists()
  depois: if not (_SIDECAR_DIR / node_modules / spectrum-ts).exists()
  spectrum-ts e a unica dependencia do package.json. Checar sua presenca
  garante que instalacao parcial/abortada e detectada no boot do gateway,
  nao na primeira mensagem recebida via gRPC.

cli.py - stderr capturado e persistido (Risk 3):
  subprocess.run passa agora stderr=subprocess.PIPE, text=True em ambas as
  chamadas (npm ci e npm install fallback). O stderr capturado e:
    - impresso em sys.stderr imediatamente (output visivel no terminal)
    - persistido em _NPM_ERROR_LOG = sidecar/.photon-npm-error.log se
      returncode != 0, limitado a 300 chars
    - apagado de _NPM_ERROR_LOG se returncode == 0 (evita erro stale)
  check_requirements() le _NPM_ERROR_LOG quando spectrum-ts esta ausente e
  inclui o conteudo no DEBUG log - o erro do npm sobrevive ao terminal, ao
  restart do gateway e a reinicializacao da maquina.

sidecar/.gitignore - adicionado node_modules/ e .photon-npm-error.log.

Isolamento - sem impacto no core:
  - check_fn() continua retornando apenas bool; core nao e modificado
  - Logging usa namespace plugins.platforms.photon.adapter, isolado de
    gateway.* e hermes_cli.*
  - Cada plugin tem seu proprio check_requirements() independente
  - OSError no write/read de _NPM_ERROR_LOG e silenciado - nunca propaga

Testes - 24/24 passando:
  test_check_requirements_risks.py (7 testes):
    WARNING emitido quando httpx ausente
    WARNING emitido quando node nao no PATH
    DEBUG emitido (nao WARNING) quando spectrum-ts ausente, com path
    node_modules/ vazio agora retorna False (Risk 2 resolvido)
    _NPM_ERROR_LOG escrito no stderr do npm em falha
    _NPM_ERROR_LOG apagado apos npm bem-sucedido
    erro npm aparece no DEBUG log quando node_modules ausente

  test_npm_error_log_regression.py (9 testes - vetores de falha da solucao):
    return code contrato intacto (0 em sucesso, nao-zero em falha)
    OSError no write do log silenciado, exit code ainda propagado
    OSError no read do log silenciado, check_requirements() retorna False
    stderr vazio nao cria arquivo de log
    proc.stderr=None nao lanca AttributeError
    log stale apagado apos reinstall bem-sucedido
    DEBUG emitido mesmo sem log de erro (setup pela primeira vez)

Closes #50981

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joaomarcos 2026-06-22 17:18:35 -03:00 committed by Teknium
parent 703de9bacb
commit de5c39c033
5 changed files with 541 additions and 4 deletions

View file

@ -86,6 +86,7 @@ _DEDUP_WINDOW_SECONDS = 48 * 3600
_FFFC_WAIT_SECONDS = 15.0 # Timeout for waiting on an attachment after a U+FFFC placeholder.
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
_NPM_ERROR_LOG = _SIDECAR_DIR / ".photon-npm-error.log"
# Cap on a self-heal `npm ci`/`npm install` of the sidecar deps. A cold
# install of the pinned spectrum-ts tree normally takes well under a minute;
@ -134,13 +135,43 @@ def _coerce_port(value: Any, default: int) -> int:
def check_requirements() -> bool:
"""Return True when both Python deps and the Node sidecar are available."""
if not HTTPX_AVAILABLE:
logger.warning("photon: httpx not installed — pip install httpx")
return False
if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"):
logger.warning(
"photon: node binary '%s' not found on PATH",
os.getenv("PHOTON_NODE_BIN") or "node",
)
return False
if not (_SIDECAR_DIR / "node_modules").exists():
# spectrum-ts not installed yet — `hermes photon setup` will
# install it. check_fn still returns False so the gateway
# surfaces the missing-deps state in `hermes setup` / status.
if not (_SIDECAR_DIR / "node_modules" / "spectrum-ts").exists():
# spectrum-ts not installed yet, or node_modules/ was partially created
# by an aborted npm install (ENOSPC, network timeout, EACCES).
# Checking spectrum-ts presence — not just node_modules/ existence —
# prevents a false positive where an empty/broken node_modules/ dir
# causes check_requirements() to return True while the sidecar crashes
# at runtime with an unrelated-looking missing-module error.
# DEBUG (not WARNING): this is the normal pre-setup state.
# check_fn() is called from multiple hot paths in the core
# (load_gateway_config, hermes status, GET /api/status polling) —
# WARNING here would spam logs on every probe for unconfigured photon.
npm_error = ""
try:
if _NPM_ERROR_LOG.exists():
npm_error = _NPM_ERROR_LOG.read_text(encoding="utf-8").strip()[:300]
except OSError:
pass
if npm_error:
logger.debug(
"photon: spectrum-ts not installed at %s "
"(last npm error: %s) — run: hermes photon setup",
_SIDECAR_DIR,
npm_error,
)
else:
logger.debug(
"photon: spectrum-ts not installed at %s — run: hermes photon setup",
_SIDECAR_DIR,
)
return False
return True

View file

@ -31,6 +31,9 @@ from hermes_cli.colors import Colors, color
from . import auth as photon_auth
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
# Written on npm failure so check_requirements() can surface the root cause
# when called later (gateway start, hermes status). Cleared on success.
_NPM_ERROR_LOG = _SIDECAR_DIR / ".photon-npm-error.log"
# ---------------------------------------------------------------------------
@ -432,20 +435,42 @@ def _install_sidecar() -> int:
# `npm install` when the lockfile is missing or drifted (e.g. a dev
# checkout mid-upgrade).
print(f" $ cd {_SIDECAR_DIR} && {npm} ci")
# stdout is not captured so npm progress prints to the terminal in real
# time. stderr is captured so we can persist the failure reason for
# check_requirements() to surface after the process exits.
proc = subprocess.run( # noqa: S603
[npm, "ci"],
cwd=str(_SIDECAR_DIR),
check=False,
stderr=subprocess.PIPE,
text=True,
)
if proc.stderr:
print(proc.stderr, end="", file=sys.stderr)
if proc.returncode != 0:
print(f" npm ci failed — falling back to: {npm} install")
proc = subprocess.run( # noqa: S603
[npm, "install"],
cwd=str(_SIDECAR_DIR),
check=False,
stderr=subprocess.PIPE,
text=True,
)
if proc.stderr:
print(proc.stderr, end="", file=sys.stderr)
if proc.returncode != 0:
print("npm install failed", file=sys.stderr)
error = (proc.stderr or "").strip()
if error:
try:
_NPM_ERROR_LOG.write_text(error, encoding="utf-8")
except OSError:
pass
else:
try:
_NPM_ERROR_LOG.unlink()
except FileNotFoundError:
pass
return proc.returncode

View file

@ -0,0 +1,2 @@
node_modules/
.photon-npm-error.log