@mmmbuto/nexuscrew 0.9.30 → 0.9.32
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.
- package/CHANGELOG.md +21 -0
- package/docs/CONFIGURATION.md +39 -0
- package/frontend/dist/assets/index-CH2iAu6y.js +167 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/doctor.js +105 -4
- package/lib/decks/routes.js +11 -1
- package/lib/fleet/builtin.js +96 -11
- package/lib/fleet/definitions.js +11 -3
- package/lib/fleet/endpoint-probe.js +146 -0
- package/lib/fleet/managed.js +175 -11
- package/lib/fleet/model-probe.js +110 -6
- package/lib/fleet/runtime.js +30 -3
- package/lib/notify/ask-answer-service.js +10 -4
- package/lib/notify/asks.js +67 -7
- package/lib/notify/closure-retry.js +201 -0
- package/lib/notify/event-feed-asks-routes.js +4 -0
- package/lib/notify/event-feed-routes.js +8 -0
- package/lib/notify/routes.js +274 -12
- package/lib/proxy/federation.js +16 -3
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +132 -14
- package/package.json +1 -1
- package/frontend/dist/assets/index-BbcjoZS0.js +0 -167
package/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-CH2iAu6y.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.
|
|
1
|
+
{"version":"0.9.32"}
|
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
|
-
|
|
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' };
|
|
@@ -435,8 +460,37 @@ function checkTmuxServerTermuxPreload(runtimeEnv, execImpl, opts = {}) {
|
|
|
435
460
|
let raw = '';
|
|
436
461
|
try {
|
|
437
462
|
raw = String(execImpl(opts.tmuxBin || 'tmux', ['show-environment', '-g', 'LD_PRELOAD'], { encoding: 'utf8' }) || '').trim();
|
|
438
|
-
} catch (
|
|
439
|
-
|
|
463
|
+
} catch (err) {
|
|
464
|
+
// Con il server ATTIVO `tmux show-environment -g <var>` esce 1 scrivendo
|
|
465
|
+
// "unknown variable" su stderr: execFileSync lo rigetta come errore, come
|
|
466
|
+
// fa per un server assente ("no server running"), ma i due esiti non sono
|
|
467
|
+
// la stessa cosa. L'assenza della variabile merita un WARN col rimedio,
|
|
468
|
+
// non l'esito «server non attivo».
|
|
469
|
+
const msg = String((err && (err.stderr || err.message)) || '');
|
|
470
|
+
if (/unknown variable/i.test(msg)) {
|
|
471
|
+
return {
|
|
472
|
+
name: 'tmux server termux-exec', ok: true, warn: true,
|
|
473
|
+
detail: 'server tmux attivo ma LD_PRELOAD non impostato (tmux: unknown variable); al primo avvio verra\' impostata, oppure: tmux set-environment -g LD_PRELOAD <libtermux-exec-ld-preload.so>',
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
// Classificazione dell'errore tmux: solo il «no server running» autentico
|
|
477
|
+
// dice che il server non c'è. Un «error connecting ... (Permission denied)»
|
|
478
|
+
// o qualunque altro fallimento lasciano la domanda aperta — il socket
|
|
479
|
+
// esiste ma questo utente non può leggerlo — e diventano «probe non
|
|
480
|
+
// verificabile» con lo stderr nel dettaglio, non un falso «server assente».
|
|
481
|
+
if (/permission denied/i.test(msg)) {
|
|
482
|
+
return {
|
|
483
|
+
name: 'tmux server termux-exec', ok: true, warn: true,
|
|
484
|
+
detail: `probe non verificabile: il socket tmux risponde "${msg}"; il server potrebbe essere attivo ma non leggibile con questo utente`,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
if (/no server running|error connecting/i.test(msg)) {
|
|
488
|
+
return { name: 'tmux server termux-exec', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
name: 'tmux server termux-exec', ok: true, warn: true,
|
|
492
|
+
detail: `probe non verificabile: tmux ha risposto "${msg}"; stato del server non determinabile da qui`,
|
|
493
|
+
};
|
|
440
494
|
}
|
|
441
495
|
const match = raw.match(/^LD_PRELOAD=(.+)$/);
|
|
442
496
|
const trusted = match && trustedTermuxPreload({ ...env, LD_PRELOAD: match[1] }, { platform, home: opts.home });
|
|
@@ -591,6 +645,51 @@ function checkFleetDefinitions(home, fleetDefsPath, enabled = true) {
|
|
|
591
645
|
};
|
|
592
646
|
}
|
|
593
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
|
+
|
|
594
693
|
// Read-only check: alternate-screen is applied per managed session, while the
|
|
595
694
|
// history limit remains an operator-owned tmux setting. Do not write ~/.tmux.conf
|
|
596
695
|
// or change a live server from doctor; only recommend a sufficient value.
|
|
@@ -653,6 +752,7 @@ function doctor(opts = {}) {
|
|
|
653
752
|
checkTmuxSurvival(platform, execImpl),
|
|
654
753
|
checkTokenPerms(tokenPath),
|
|
655
754
|
checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
|
|
755
|
+
checkEngineCredentials(home, opts.fleetDefsPath, fleetEnabled),
|
|
656
756
|
checkAlternateScreenHistory(opts.alternateScreen !== undefined
|
|
657
757
|
? opts.alternateScreen : loadConfig().alternateScreen, execImpl, opts.tmuxBin),
|
|
658
758
|
checkTermuxExec(opts.env, { platform, home }),
|
|
@@ -686,4 +786,5 @@ module.exports = {
|
|
|
686
786
|
checkSshClient, checkAutossh, checkSshPermitlisten,
|
|
687
787
|
checkMcpIdentity, checkIdentityAuthorityMode, checkTermuxExec,
|
|
688
788
|
checkTmuxServerCwd, checkTmuxServerTermuxPreload,
|
|
789
|
+
checkEngineCredentials,
|
|
689
790
|
};
|
package/lib/decks/routes.js
CHANGED
|
@@ -29,7 +29,17 @@ function decksRoutes({ cfg = {}, decksPath }) {
|
|
|
29
29
|
};
|
|
30
30
|
const find = (st, name) => st.decks.find((d) => d.name === name) || null;
|
|
31
31
|
const checkRevision = (deck, rev) => {
|
|
32
|
-
if (deck.revision !== rev)
|
|
32
|
+
if (deck.revision !== rev) {
|
|
33
|
+
// Traccia il conflitto di revisione con i campi che servono a
|
|
34
|
+
// capire chi lo ha causato (nessun merge lato server in questo ciclo).
|
|
35
|
+
console.log(JSON.stringify({
|
|
36
|
+
event: 'deck-revision-conflict',
|
|
37
|
+
name: deck.name,
|
|
38
|
+
expectedRevision: rev,
|
|
39
|
+
currentRevision: deck.revision,
|
|
40
|
+
}));
|
|
41
|
+
throw httpError(409, 'deck modificato da un’altra finestra', { current: deck });
|
|
42
|
+
}
|
|
33
43
|
};
|
|
34
44
|
|
|
35
45
|
r.get('/', (_req, res) => {
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -37,9 +37,11 @@ const {
|
|
|
37
37
|
} = require('./definitions.js');
|
|
38
38
|
const {
|
|
39
39
|
publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine,
|
|
40
|
-
CATALOG, credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames,
|
|
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');
|
|
@@ -874,12 +876,57 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
874
876
|
}
|
|
875
877
|
// Se un engine sta USANDO questo modello, toglierlo lo renderebbe invalido
|
|
876
878
|
// al prossimo avvio — e il messaggio arriverebbe a chi non sa cosa e'
|
|
877
|
-
// cambiato. Meglio rifiutare adesso, dicendo chi lo usa.
|
|
879
|
+
// cambiato. Meglio rifiutare adesso, dicendo chi lo usa. Un engine risolve
|
|
880
|
+
// il modello dalla dichiarazione fatta sul SUO id o sul suo profilo
|
|
881
|
+
// (declaredFor cerca in [engineId, profileId]): il ritiro di
|
|
882
|
+
// {wantedId, wantedEngine} lo rompe solo se wantedEngine e' una di quelle
|
|
883
|
+
// chiavi — un altro engine con la propria dichiarazione non c'entra.
|
|
878
884
|
const usato = (defs.engines || [])
|
|
879
|
-
.filter((e) =>
|
|
885
|
+
.filter((e) => {
|
|
886
|
+
if (!e.managed || e.managed.model !== wantedId) return false;
|
|
887
|
+
if (e.id === wantedEngine) return true;
|
|
888
|
+
const profile = profileFor(e.managed.client, e.managed.provider, e.managed.credentialProfile || '');
|
|
889
|
+
return !!(profile && profile.id === wantedEngine);
|
|
890
|
+
})
|
|
880
891
|
.map((e) => e.id);
|
|
881
|
-
|
|
882
|
-
|
|
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('; '));
|
|
883
930
|
}
|
|
884
931
|
await mutate(defs, (d) => {
|
|
885
932
|
d.models = (d.models || []).filter((m) => !(m.id === wantedId && m.engine === wantedEngine));
|
|
@@ -1264,7 +1311,10 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1264
1311
|
engines: defs.engines.map((e) => {
|
|
1265
1312
|
const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
|
|
1266
1313
|
delete out.env;
|
|
1267
|
-
if (e.managed) out.managedInfo = describeManaged(e.managed, {
|
|
1314
|
+
if (e.managed) out.managedInfo = describeManaged(e.managed, {
|
|
1315
|
+
...cfg, home, extraModels: extraModelsFrom(defs), engineId: e.id,
|
|
1316
|
+
endpointProbe: cfg.endpointProbe || defaultEndpointProbe,
|
|
1317
|
+
});
|
|
1268
1318
|
return out;
|
|
1269
1319
|
}),
|
|
1270
1320
|
cells: defs.cells.map((c) => {
|
|
@@ -1443,19 +1493,54 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1443
1493
|
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test'];
|
|
1444
1494
|
}
|
|
1445
1495
|
|
|
1496
|
+
// Un engine del documento con un `baseUrl` dichiarato a mano: indirizzo e
|
|
1497
|
+
// credenziale risolti per la prova, e niente altro. Il valore della chiave NON
|
|
1498
|
+
// entra nel ritorno di testModel ne' in un log.
|
|
1499
|
+
function customEndpointFor(engineId) {
|
|
1500
|
+
const defsNow = (typeof reloadDefs === 'function' ? reloadDefs() : defs) || defs;
|
|
1501
|
+
const eng = findEngine(defsNow, engineId);
|
|
1502
|
+
if (!eng || !eng.managed || !eng.managed.baseUrl) return null;
|
|
1503
|
+
const spec = normalizeManagedSpec(eng.managed, {
|
|
1504
|
+
extraModels: extraModelsFrom(defsNow), engineId: eng.id,
|
|
1505
|
+
});
|
|
1506
|
+
if (!spec) return null;
|
|
1507
|
+
const prof = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
|
|
1508
|
+
let credential = '';
|
|
1509
|
+
try {
|
|
1510
|
+
credential = (resolveProfileCredential(prof, {
|
|
1511
|
+
client: prof.client, provider: prof.provider,
|
|
1512
|
+
...(spec.credentialProfile ? { credentialProfile: spec.credentialProfile } : {}),
|
|
1513
|
+
}, cfg, home) || {}).value || '';
|
|
1514
|
+
} catch (_) { credential = ''; }
|
|
1515
|
+
return { endpoint: spec.baseUrl || '', credential };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1446
1518
|
// «questo modello funziona con questa credenziale?», chiesto all'API invece
|
|
1447
1519
|
// che scoperto quando la cella non parte. Vive qui e non nelle route perche'
|
|
1448
1520
|
// la credenziale si risolve dove gia' si risolve per l'avvio: la chiave non
|
|
1449
1521
|
// attraversa il router e non compare in nessuna risposta.
|
|
1450
1522
|
async function testModel(engineId, model, opts = {}) {
|
|
1451
|
-
const
|
|
1452
|
-
|
|
1453
|
-
const e = new Error(`profilo sconosciuto "${engineId}"`); e.status = 404; throw e;
|
|
1454
|
-
}
|
|
1523
|
+
const id = String(engineId || '');
|
|
1524
|
+
const profile = CATALOG.find((p) => p.id === id);
|
|
1455
1525
|
const wanted = typeof model === 'string' ? model.trim() : '';
|
|
1456
1526
|
if (!wanted || wanted.length > 128 || /[\x00-\x1f\x7f]/.test(wanted)) {
|
|
1457
1527
|
const e = new Error('modello non valido'); e.status = 400; throw e;
|
|
1458
1528
|
}
|
|
1529
|
+
if (!profile) {
|
|
1530
|
+
// Un engine DICHIARATO con un endpoint proprio non sta nel catalogo
|
|
1531
|
+
// pubblico: rispondere 404 significava «non provabile» proprio dove la
|
|
1532
|
+
// prova serve di piu' — un router interno che nessuno sorveglia. Si prova
|
|
1533
|
+
// sul suo indirizzo, con le credenziali gia' risolte per quell'engine.
|
|
1534
|
+
const custom = customEndpointFor(id);
|
|
1535
|
+
if (!custom) {
|
|
1536
|
+
const e = new Error(`profilo sconosciuto "${engineId}"`); e.status = 404; throw e;
|
|
1537
|
+
}
|
|
1538
|
+
const out = await probeCustomEndpoint({
|
|
1539
|
+
endpoint: custom.endpoint, credential: custom.credential, model: wanted,
|
|
1540
|
+
fetchImpl: opts.fetchImpl,
|
|
1541
|
+
});
|
|
1542
|
+
return { engine: id, model: wanted, ...out };
|
|
1543
|
+
}
|
|
1459
1544
|
const spec = {
|
|
1460
1545
|
client: profile.client,
|
|
1461
1546
|
provider: profile.provider,
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -170,7 +170,7 @@ function cellIdFromTmuxSession(session) {
|
|
|
170
170
|
//
|
|
171
171
|
// Un modello dichiarato NON aggira il controllo: lo estende in modo esplicito.
|
|
172
172
|
// Un id mai dichiarato resta rifiutato.
|
|
173
|
-
const MAX_MODELS =
|
|
173
|
+
const MAX_MODELS = 128;
|
|
174
174
|
const MODEL_ID_MAX = 128;
|
|
175
175
|
|
|
176
176
|
function parseModel(m) {
|
|
@@ -245,7 +245,15 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
|
|
|
245
245
|
const models = [];
|
|
246
246
|
const extraModels = new Map();
|
|
247
247
|
if (d.models !== undefined) {
|
|
248
|
-
if (!Array.isArray(d.models)
|
|
248
|
+
if (!Array.isArray(d.models)) return null;
|
|
249
|
+
if (d.models.length > MAX_MODELS) {
|
|
250
|
+
// Come per gli engine: il rifiuto NOMINA il limite, altrimenti chi
|
|
251
|
+
// dichiara si ritrova un 400 generico senza sapere che il cap era
|
|
252
|
+
// quello. `onIssue` porta campo/valore/regola al messaggio del comando
|
|
253
|
+
// (400 di una scrittura), `reject` alla lettura del file.
|
|
254
|
+
if (typeof onIssue === 'function') onIssue({ field: 'models', value: d.models.length, rule: `troppi modelli dichiarati, cap ${MAX_MODELS}` });
|
|
255
|
+
return reject(`fleet.json rifiutato: ${d.models.length} modelli dichiarati, cap ${MAX_MODELS}; riduci i modelli a ${MAX_MODELS} o meno`);
|
|
256
|
+
}
|
|
249
257
|
const seen = new Set();
|
|
250
258
|
for (const m of d.models) {
|
|
251
259
|
const model = parseModel(m);
|
|
@@ -1081,7 +1089,7 @@ function atomicWrite(p, data) {
|
|
|
1081
1089
|
}
|
|
1082
1090
|
|
|
1083
1091
|
const CAPS = {
|
|
1084
|
-
SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
|
|
1092
|
+
SCHEMA_VERSION, MAX_ENGINES, MAX_MODELS, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
|
|
1085
1093
|
MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_LABEL_LEN,
|
|
1086
1094
|
MAX_COMMAND_LEN, MAX_CWD_LEN, MAX_MODEL_FLAG_LEN, MAX_MODEL_VAL_LEN,
|
|
1087
1095
|
MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_CELL_COMMAND_LEN, MAX_TMUXSESSION_LEN,
|
|
@@ -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
|
+
};
|