@mmmbuto/nexuscrew 0.9.31 → 0.9.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DaAL7P-m.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-Uco-EbNb.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-DqCfZFGi.css">
16
16
  </head>
17
17
  <body>
@@ -1 +1 @@
1
- {"version":"0.9.31"}
1
+ {"version":"0.9.33"}
package/lib/cli/doctor.js CHANGED
@@ -393,6 +393,24 @@ function checkTermuxExec(runtimeEnv, opts = {}) {
393
393
  };
394
394
  }
395
395
 
396
+ // «Server assente» e «non ho potuto guardare» sono due cose diverse, e vanno
397
+ // dette diverse. La prima e' uno stato normale — il server non c'e' ancora, la
398
+ // probe si rinvia al primo avvio. La seconda e' un'informazione che manca:
399
+ // collassarla nella prima significa dichiarare sano cio' che non si e'
400
+ // verificato, che e' esattamente il modo in cui un permesso sbagliato resta
401
+ // invisibile. E' la stessa distinzione gia' fatta fra «manca» e «non
402
+ // verificabile» altrove nel prodotto.
403
+ function classifyTmuxProbeCause(error) {
404
+ const code = String((error && error.code) || '');
405
+ const message = String((error && error.message) || error || '');
406
+ if (code === 'ENOENT'
407
+ || /no server running|no such file or directory|connection refused|no sessions/i.test(message)) {
408
+ return 'absent';
409
+ }
410
+ const cause = (code || message.trim() || 'errore non nominato');
411
+ return cause.length > 120 ? `${cause.slice(0, 117)}...` : cause;
412
+ }
413
+
396
414
  // Read-only probe of the long-lived tmux server cwd. A server that retained an
397
415
  // unlinked runtime directory keeps accepting clients but makes later children
398
416
  // fail getcwd(3). Never reports the path itself; only stable state.
@@ -403,8 +421,15 @@ function checkTmuxServerCwd(platform, execImpl, opts = {}) {
403
421
  let rawPid = '';
404
422
  try {
405
423
  rawPid = String(execImpl(opts.tmuxBin || 'tmux', ['display-message', '-p', '#{pid}'], { encoding: 'utf8' }) || '').trim();
406
- } catch (_) {
407
- return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
424
+ } catch (error) {
425
+ const cause = classifyTmuxProbeCause(error);
426
+ if (cause === 'absent') {
427
+ return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
428
+ }
429
+ return {
430
+ name: 'tmux server cwd', ok: false, warn: true,
431
+ detail: `probe non verificabile: ${cause}`,
432
+ };
408
433
  }
409
434
  if (!/^\d+$/.test(rawPid)) {
410
435
  return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non rilevato' };
@@ -620,6 +645,51 @@ function checkFleetDefinitions(home, fleetDefsPath, enabled = true) {
620
645
  };
621
646
  }
622
647
 
648
+ // Sezione «engine credentials»: per ogni engine che risolve una credenziale,
649
+ // DA DOVE viene. Prima non esisteva alcun controllo di questo tipo: l'unico
650
+ // blocco credenziali del doctor era quello dell'identita' (daemon/launcher), e
651
+ // la provenienza delle chiavi degli engine non era visibile da nessuna parte —
652
+ // una diagnosi sul campo e' arrivata a un confronto manuale di hash.
653
+ // Non si stampa MAI un valore: solo la sorgente, il path, l'mtime e l'impronta
654
+ // del valore troncata a 8. Un conflitto fra i file di chiavi su un engine che
655
+ // una cella USA e' un fallimento; su un engine che nessuna cella usa e' un
656
+ // avviso, perche' li' non c'e' ancora nulla che parta con la chiave sbagliata.
657
+ function checkEngineCredentials(home, fleetDefsPath, enabled = true) {
658
+ const name = 'engine credentials';
659
+ if (!enabled) return { name, ok: true, warn: true, detail: 'Fleet disabilitata' };
660
+ const target = fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
661
+ const defs = loadDefinitions(target);
662
+ if (!defs) return { name, ok: true, warn: true, detail: 'fleet.json non leggibile: provenienza non verificabile' };
663
+ const { describeManaged, extraModelsFrom } = require('../fleet/managed.js');
664
+ const inUso = new Set((defs.cells || []).map((c) => c && c.engine).filter(Boolean));
665
+ const extraModels = extraModelsFrom(defs);
666
+ const righe = [];
667
+ const conflitti = [];
668
+ for (const engine of defs.engines || []) {
669
+ if (!engine || !engine.managed) continue;
670
+ const info = describeManaged(engine.managed, { home, extraModels, engineId: engine.id });
671
+ const envKey = info.auth;
672
+ if (!envKey || envKey === 'login' || envKey === 'none') continue;
673
+ const dove = info.credentialPath
674
+ ? `${info.credentialSource} (${info.credentialPath}, mtime ${new Date(info.credentialMtime).toISOString()}, sha256 ${info.credentialHash8})`
675
+ : info.credentialSource;
676
+ righe.push(`${engine.id}: ${envKey} from ${dove}`);
677
+ if (info.credentialConflict) conflitti.push({ engine: engine.id, inUso: inUso.has(engine.id), c: info.credentialConflict });
678
+ }
679
+ if (!righe.length) return { name, ok: true, detail: 'nessun engine con credenziale risolta' };
680
+ for (const { engine, c } of conflitti) {
681
+ const altri = c.others
682
+ .map((o) => `${o.path} (mtime ${new Date(o.mtime).toISOString()}, sha256 ${o.hash8})`)
683
+ .join(', ');
684
+ righe.push(`${engine}: ${c.envKey} e' definita anche in ${altri} con un valore DIVERSO (vince ${c.winner.path})`);
685
+ }
686
+ const grave = conflitti.some((k) => k.inUso);
687
+ const detail = righe.join(' · ');
688
+ if (grave) return { name, ok: false, detail };
689
+ if (conflitti.length) return { name, ok: true, warn: true, detail };
690
+ return { name, ok: true, detail };
691
+ }
692
+
623
693
  // Read-only check: alternate-screen is applied per managed session, while the
624
694
  // history limit remains an operator-owned tmux setting. Do not write ~/.tmux.conf
625
695
  // or change a live server from doctor; only recommend a sufficient value.
@@ -682,6 +752,7 @@ function doctor(opts = {}) {
682
752
  checkTmuxSurvival(platform, execImpl),
683
753
  checkTokenPerms(tokenPath),
684
754
  checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
755
+ checkEngineCredentials(home, opts.fleetDefsPath, fleetEnabled),
685
756
  checkAlternateScreenHistory(opts.alternateScreen !== undefined
686
757
  ? opts.alternateScreen : loadConfig().alternateScreen, execImpl, opts.tmuxBin),
687
758
  checkTermuxExec(opts.env, { platform, home }),
@@ -715,4 +786,5 @@ module.exports = {
715
786
  checkSshClient, checkAutossh, checkSshPermitlisten,
716
787
  checkMcpIdentity, checkIdentityAuthorityMode, checkTermuxExec,
717
788
  checkTmuxServerCwd, checkTmuxServerTermuxPreload,
789
+ checkEngineCredentials,
718
790
  };
@@ -38,8 +38,10 @@ const {
38
38
  const {
39
39
  publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine,
40
40
  CATALOG, credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames, profileFor,
41
+ normalizeManagedSpec,
41
42
  } = require('./managed.js');
42
- const { probeModel } = require('./model-probe.js');
43
+ const { probeModel, probeCustomEndpoint } = require('./model-probe.js');
44
+ const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
43
45
  const { validEnvKey } = require('./env-key.js');
44
46
  const { setCredential, removeCredential } = require('./credentials.js');
45
47
  const { createLaunchBroker } = require('./launch-broker.js');
@@ -887,8 +889,44 @@ async function createBuiltinFleet(cfg = {}) {
887
889
  return !!(profile && profile.id === wantedEngine);
888
890
  })
889
891
  .map((e) => e.id);
890
- if (usato.length) {
891
- throw httpError(409, `modello in uso: ${usato.join(', ')}`);
892
+ // E una CELLA puo' sovrascrivere il modello del proprio engine: l'engine non
893
+ // lo usa, la cella si'. Guardare solo gli engine lasciava passare la
894
+ // rimozione e lasciava una cella che punta a un modello che non c'e' piu':
895
+ // il danno si manifestava al primo avvio, davanti a chi non sa cosa e'
896
+ // cambiato. Le chiavi sono le STESSE due della risoluzione — l'id
897
+ // dell'engine e quello del suo profilo — piu' l'override singolo `cell.model`,
898
+ // che vale per l'engine della cella.
899
+ // Il profilo che conta e' quello dell'engine DICHIARANTE: la seconda chiave
900
+ // con cui una cella riferisce un engine e' l'id del suo profilo, non quello
901
+ // di un altro.
902
+ const wantedDef = findEngine(defs, wantedEngine);
903
+ const wantedProfile = wantedDef && wantedDef.managed
904
+ ? profileFor(wantedDef.managed.client, wantedDef.managed.provider, wantedDef.managed.credentialProfile || '')
905
+ : null;
906
+ const wantedProfileId = wantedProfile && wantedProfile.id;
907
+ const celleUsano = (defs.cells || [])
908
+ .filter((c) => {
909
+ const perEngine = c.models || {};
910
+ if (perEngine[wantedEngine] === wantedId) return true;
911
+ if (wantedProfileId && perEngine[wantedProfileId] === wantedId) return true;
912
+ // `cell.model` vale per l'engine DELLA cella: e' un uso del modello
913
+ // chiesto solo se quella cella gira proprio sull'engine dichiarante.
914
+ if (c.model === wantedId) {
915
+ if (c.engine === wantedEngine) return true;
916
+ const cellEngine = findEngine(defs, c.engine);
917
+ const cellProfile = cellEngine && cellEngine.managed
918
+ ? profileFor(cellEngine.managed.client, cellEngine.managed.provider, cellEngine.managed.credentialProfile || '')
919
+ : null;
920
+ return !!(cellProfile && cellProfile.id === wantedEngine);
921
+ }
922
+ return false;
923
+ })
924
+ .map((c) => c.id);
925
+ if (usato.length || celleUsano.length) {
926
+ const parti = [];
927
+ if (usato.length) parti.push(`modello in uso: ${usato.join(', ')}`);
928
+ if (celleUsano.length) parti.push(`celle: ${celleUsano.join(', ')}`);
929
+ throw httpError(409, parti.join('; '));
892
930
  }
893
931
  await mutate(defs, (d) => {
894
932
  d.models = (d.models || []).filter((m) => !(m.id === wantedId && m.engine === wantedEngine));
@@ -897,6 +935,51 @@ async function createBuiltinFleet(cfg = {}) {
897
935
  return { ok: true, id: wantedId, engine: wantedEngine };
898
936
  }
899
937
 
938
+ // I campi non-identita' che `parseModel` ammette per una dichiarazione:
939
+ // id ed engine SONO l'identita' (il chiaro per cui esiste edit-model e' non
940
+ // doverla ritoccare), tutto il resto e' patchabile con la stessa validazione.
941
+ const EDIT_MODEL_FIELDS = new Set(['label', 'contextWindow', 'maxTokens', 'reasoning']);
942
+
943
+ async function editModel(id, engine, patch) {
944
+ if (readonly()) throw httpError(403, 'READONLY: edit-model bloccato');
945
+ const wantedId = String(id || '').trim();
946
+ const wantedEngine = String(engine || '').trim();
947
+ if (!wantedId || !wantedEngine) throw httpError(400, 'id e engine sono obbligatori');
948
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw httpError(400, 'patch mancante');
949
+ const keys = Object.keys(patch);
950
+ if (!keys.length) throw httpError(400, 'patch vuota: niente da modificare');
951
+ for (const key of keys) {
952
+ if (!EDIT_MODEL_FIELDS.has(key)) throw httpError(400, `campo non ammesso per edit-model: ${key}`);
953
+ }
954
+ if (patch.label !== undefined) {
955
+ if (typeof patch.label !== 'string' || !patch.label.trim() || patch.label.trim().length > 64) {
956
+ throw httpError(400, 'label non valida: stringa non vuota, massimo 64 caratteri');
957
+ }
958
+ }
959
+ for (const key of ['contextWindow', 'maxTokens']) {
960
+ if (patch[key] !== undefined && (!Number.isSafeInteger(patch[key]) || patch[key] < 1)) {
961
+ throw httpError(400, `${key} non valido: intero >= 1`);
962
+ }
963
+ }
964
+ if (patch.reasoning !== undefined && typeof patch.reasoning !== 'boolean') {
965
+ throw httpError(400, 'reasoning non valido: booleano');
966
+ }
967
+ const defs = reloadDefs();
968
+ if (!(defs.models || []).some((m) => m.id === wantedId && m.engine === wantedEngine)) {
969
+ throw httpError(404, `modello non dichiarato: ${wantedId} su ${wantedEngine}`);
970
+ }
971
+ // La scrittura passa da `mutate`, che rilegge e RIVALIDA l'intero file: la
972
+ // validazione completa resta quella del parser (contextWindow >= 1024 fino
973
+ // al cap, label entro il limite) e un valore fuori schema non entra. Gli
974
+ // engine che risolvono questo modello NON vengono toccati: la dichiarazione
975
+ // e' la stessa riga del file, l'engine non ha nulla da ricaricare.
976
+ await mutate(defs, (d) => {
977
+ const target = d.models.find((m) => m.id === wantedId && m.engine === wantedEngine);
978
+ Object.assign(target, patch);
979
+ });
980
+ return { ok: true, id: wantedId, engine: wantedEngine, edited: keys };
981
+ }
982
+
900
983
  async function editEngine(id, patch, envChanges) {
901
984
  if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
902
985
  if (!id) throw httpError(400, 'id engine mancante');
@@ -1273,7 +1356,10 @@ async function createBuiltinFleet(cfg = {}) {
1273
1356
  engines: defs.engines.map((e) => {
1274
1357
  const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
1275
1358
  delete out.env;
1276
- if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home, extraModels: extraModelsFrom(defs), engineId: e.id });
1359
+ if (e.managed) out.managedInfo = describeManaged(e.managed, {
1360
+ ...cfg, home, extraModels: extraModelsFrom(defs), engineId: e.id,
1361
+ endpointProbe: cfg.endpointProbe || defaultEndpointProbe,
1362
+ });
1277
1363
  return out;
1278
1364
  }),
1279
1365
  cells: defs.cells.map((c) => {
@@ -1449,7 +1535,29 @@ async function createBuiltinFleet(cfg = {}) {
1449
1535
  }
1450
1536
 
1451
1537
  function capabilities() {
1452
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test'];
1538
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test', 'edit-model'];
1539
+ }
1540
+
1541
+ // Un engine del documento con un `baseUrl` dichiarato a mano: indirizzo e
1542
+ // credenziale risolti per la prova, e niente altro. Il valore della chiave NON
1543
+ // entra nel ritorno di testModel ne' in un log.
1544
+ function customEndpointFor(engineId) {
1545
+ const defsNow = (typeof reloadDefs === 'function' ? reloadDefs() : defs) || defs;
1546
+ const eng = findEngine(defsNow, engineId);
1547
+ if (!eng || !eng.managed || !eng.managed.baseUrl) return null;
1548
+ const spec = normalizeManagedSpec(eng.managed, {
1549
+ extraModels: extraModelsFrom(defsNow), engineId: eng.id,
1550
+ });
1551
+ if (!spec) return null;
1552
+ const prof = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
1553
+ let credential = '';
1554
+ try {
1555
+ credential = (resolveProfileCredential(prof, {
1556
+ client: prof.client, provider: prof.provider,
1557
+ ...(spec.credentialProfile ? { credentialProfile: spec.credentialProfile } : {}),
1558
+ }, cfg, home) || {}).value || '';
1559
+ } catch (_) { credential = ''; }
1560
+ return { endpoint: spec.baseUrl || '', credential };
1453
1561
  }
1454
1562
 
1455
1563
  // «questo modello funziona con questa credenziale?», chiesto all'API invece
@@ -1457,14 +1565,27 @@ async function createBuiltinFleet(cfg = {}) {
1457
1565
  // la credenziale si risolve dove gia' si risolve per l'avvio: la chiave non
1458
1566
  // attraversa il router e non compare in nessuna risposta.
1459
1567
  async function testModel(engineId, model, opts = {}) {
1460
- const profile = CATALOG.find((p) => p.id === String(engineId || ''));
1461
- if (!profile) {
1462
- const e = new Error(`profilo sconosciuto "${engineId}"`); e.status = 404; throw e;
1463
- }
1568
+ const id = String(engineId || '');
1569
+ const profile = CATALOG.find((p) => p.id === id);
1464
1570
  const wanted = typeof model === 'string' ? model.trim() : '';
1465
1571
  if (!wanted || wanted.length > 128 || /[\x00-\x1f\x7f]/.test(wanted)) {
1466
1572
  const e = new Error('modello non valido'); e.status = 400; throw e;
1467
1573
  }
1574
+ if (!profile) {
1575
+ // Un engine DICHIARATO con un endpoint proprio non sta nel catalogo
1576
+ // pubblico: rispondere 404 significava «non provabile» proprio dove la
1577
+ // prova serve di piu' — un router interno che nessuno sorveglia. Si prova
1578
+ // sul suo indirizzo, con le credenziali gia' risolte per quell'engine.
1579
+ const custom = customEndpointFor(id);
1580
+ if (!custom) {
1581
+ const e = new Error(`profilo sconosciuto "${engineId}"`); e.status = 404; throw e;
1582
+ }
1583
+ const out = await probeCustomEndpoint({
1584
+ endpoint: custom.endpoint, credential: custom.credential, model: wanted,
1585
+ fetchImpl: opts.fetchImpl,
1586
+ });
1587
+ return { engine: id, model: wanted, ...out };
1588
+ }
1468
1589
  const spec = {
1469
1590
  client: profile.client,
1470
1591
  provider: profile.provider,
@@ -1490,7 +1611,7 @@ async function createBuiltinFleet(cfg = {}) {
1490
1611
  defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
1491
1612
  schema, definitions, capabilities,
1492
1613
  credentialStatus, setLocalCredential, removeLocalCredential, testModel,
1493
- defineModel, removeModel, close,
1614
+ defineModel, editModel, removeModel, close,
1494
1615
  // Fetta 2b: il lease Live esposto alle route /api/lease, che il bridge
1495
1616
  // MCP delle celle consuma per register/refresh/recovery. Assente se il
1496
1617
  // lease e' disattivato: le route rispondono 501, non 500.
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+ // lib/fleet/endpoint-probe.js — «l'endpoint risponde?», chiesto all'endpoint.
3
+ //
4
+ // Un engine managed con un `baseUrl` dichiarato a mano (un router llama.cpp, un
5
+ // server locale, un proxy) veniva dichiarato `ready` appena il BINARIO esisteva e
6
+ // la credenziale era a posto. Nessuno interrogava mai l'indirizzo: un router
7
+ // spento risultava pronto, e lo si scopriva facendo partire la cella. Qui la
8
+ // domanda si fa prima, con un GET sull'elenco dei modelli — l'unica chiamata che
9
+ // non consuma token e non genera testo.
10
+ //
11
+ // TRE DISCIPLINE, che valgono quanto la sonda:
12
+ // 1. NON BLOCCANTE OLTRE IL PROPRIO BUDGET. `refresh` e' l'unico punto che
13
+ // aspetta, e aspetta al massimo `timeoutMs`. Chi legge lo stato da un
14
+ // percorso sincrono usa `read`/`ensure`: ottiene il verdetto NOTO ADESSO e
15
+ // al massimo fa partire un rifornimento in sottofondo.
16
+ // 2. UNA PROBE OGNI `ttlMs` PER ENDPOINT. `nc_status` e la UI interrogano lo
17
+ // stato di continuo: senza cache si tradurrebbe in una tempesta di GET
18
+ // verso ogni router del parco.
19
+ // 3. GLI ENGINE DI CATALOGO NON SI SONDANO. La sonda si applica solo dove
20
+ // l'indirizzo e' dichiarato (provider pubblici: irraggiungibile da qui non
21
+ // significa rotto, e non e' compito di questo nodo misurarlo).
22
+
23
+ const DEFAULT_TIMEOUT_MS = 1500;
24
+ const DEFAULT_TTL_MS = 30000;
25
+
26
+ // L'elenco modelli sta sotto `/v1/models` per le API in stile OpenAI. Un
27
+ // `baseUrl` che dichiara gia' `/v1` non lo ripete: ripeterlo produce
28
+ // `/v1/v1/models`, che risponde 404 e verrebbe letto come «endpoint vivo».
29
+ function modelsProbeUrl(baseUrl) {
30
+ const b = String(baseUrl || '').trim().replace(/\/+$/, '');
31
+ if (!/^https?:\/\//i.test(b)) return null;
32
+ return /\/v1$/.test(b) ? `${b}/models` : `${b}/v1/models`;
33
+ }
34
+
35
+ // `host:porta` per il messaggio: e' cio' che serve a chi legge per capire QUALE
36
+ // indirizzo non risponde, senza riversare l'URL intero nel verdetto.
37
+ function endpointHost(baseUrl) {
38
+ try {
39
+ const u = new URL(String(baseUrl));
40
+ const port = u.port || (u.protocol === 'https:' ? '443' : '80');
41
+ return `${u.hostname}:${port}`;
42
+ } catch (_) { return String(baseUrl || ''); }
43
+ }
44
+
45
+ function createEndpointProbe({
46
+ fetchImpl = fetch,
47
+ timeoutMs = DEFAULT_TIMEOUT_MS,
48
+ ttlMs = DEFAULT_TTL_MS,
49
+ now = () => Date.now(),
50
+ setTimer = setTimeout,
51
+ clearTimer = clearTimeout,
52
+ log = () => {},
53
+ } = {}) {
54
+ const cache = new Map(); // baseUrl -> {state, reason, at}
55
+ const inFlight = new Map(); // baseUrl -> Promise
56
+
57
+ // Verdetto noto ADESSO, senza toccare la rete. `null` = mai sondato.
58
+ function read(baseUrl) {
59
+ if (!modelsProbeUrl(baseUrl)) return null;
60
+ const hit = cache.get(baseUrl);
61
+ if (!hit) return null;
62
+ return { state: hit.state, reason: hit.reason, at: hit.at, stale: (now() - hit.at) >= ttlMs };
63
+ }
64
+
65
+ async function refresh(baseUrl) {
66
+ const url = modelsProbeUrl(baseUrl);
67
+ if (!url) return null;
68
+ if (inFlight.has(baseUrl)) return inFlight.get(baseUrl);
69
+ const run = (async () => {
70
+ const controller = new AbortController();
71
+ const timer = setTimer(() => controller.abort(), timeoutMs);
72
+ // Un timer di sonda non tiene vivo il processo.
73
+ if (timer && typeof timer.unref === 'function') timer.unref();
74
+ let verdict;
75
+ try {
76
+ const res = await fetchImpl(url, { method: 'GET', signal: controller.signal });
77
+ const status = res && typeof res.status === 'number' ? res.status : 0;
78
+ if (status >= 200 && status < 300) {
79
+ verdict = { state: 'ready', reason: 'ready' };
80
+ } else if (status === 401 || status === 403) {
81
+ // L'endpoint ha risposto e sta chiedendo una credenziale: e' VIVO.
82
+ // Se la chiave sia quella giusta lo decide il client, non questa sonda.
83
+ verdict = { state: 'ready', reason: 'ready' };
84
+ } else if (status >= 500) {
85
+ verdict = { state: 'unreachable', reason: `endpoint unreachable: ${endpointHost(baseUrl)} (http ${status})` };
86
+ } else {
87
+ // Qualunque altra risposta (404 compreso) e' comunque una risposta:
88
+ // l'indirizzo c'e' ed e' in ascolto.
89
+ verdict = { state: 'ready', reason: 'ready' };
90
+ }
91
+ } catch (error) {
92
+ const aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR');
93
+ verdict = {
94
+ state: 'unreachable',
95
+ reason: `endpoint unreachable: ${endpointHost(baseUrl)} (${aborted ? `timeout (${timeoutMs}ms)` : 'connessione rifiutata'})`,
96
+ };
97
+ } finally {
98
+ try { clearTimer(timer); } catch (_) {}
99
+ }
100
+ cache.set(baseUrl, { ...verdict, at: now() });
101
+ try { log(`sonda endpoint ${endpointHost(baseUrl)}: ${verdict.state}`); } catch (_) {}
102
+ return verdict;
103
+ })();
104
+ inFlight.set(baseUrl, run);
105
+ try { return await run; } finally { inFlight.delete(baseUrl); }
106
+ }
107
+
108
+ // Percorso SINCRONO: ritorna il verdetto noto (o `null`) e, se manca o e'
109
+ // scaduto, avvia il rifornimento senza attendere. Il primo giro dopo un
110
+ // avvio puo' quindi non sapere ancora; dal secondo in poi dice la verita'
111
+ // per tutta la durata del TTL.
112
+ function ensure(baseUrl) {
113
+ if (!modelsProbeUrl(baseUrl)) return null;
114
+ const hit = read(baseUrl);
115
+ if (!hit || hit.stale) { refresh(baseUrl).catch(() => {}); }
116
+ return hit ? { state: hit.state, reason: hit.reason } : null;
117
+ }
118
+
119
+ // Percorso ASINCRONO: attende la sonda SOLO se il verdetto e' assente o
120
+ // scaduto. E' quello che usa lo status, dove un'attesa breve e' ammessa e
121
+ // un'informazione vecchia non serve a nessuno.
122
+ async function status(baseUrl) {
123
+ const hit = read(baseUrl);
124
+ if (hit && !hit.stale) return { state: hit.state, reason: hit.reason };
125
+ return refresh(baseUrl).catch(() => null);
126
+ }
127
+
128
+ return {
129
+ read, ensure, refresh, status,
130
+ size: () => cache.size,
131
+ clear: () => cache.clear(),
132
+ limits: { timeoutMs, ttlMs },
133
+ url: modelsProbeUrl,
134
+ host: endpointHost,
135
+ };
136
+ }
137
+
138
+ // Istanza di processo: la cache deve sopravvivere alla singola chiamata, ed e'
139
+ // esattamente cio' che evita la tempesta di probe.
140
+ const sharedProbe = createEndpointProbe();
141
+
142
+ module.exports = {
143
+ createEndpointProbe, sharedProbe, modelsProbeUrl, endpointHost,
144
+ ENDPOINT_PROBE_TIMEOUT_MS: DEFAULT_TIMEOUT_MS,
145
+ ENDPOINT_PROBE_TTL_MS: DEFAULT_TTL_MS,
146
+ };