@mmmbuto/nexuscrew 0.9.32 → 0.9.34
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 +13 -0
- package/frontend/dist/assets/{index-CH2iAu6y.js → index-5eCvm3HF.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/builtin.js +107 -3
- package/lib/fleet/causes.js +3 -0
- package/lib/fleet/cell-exec.js +12 -12
- package/lib/fleet/cell-lease-server.js +4 -4
- package/lib/fleet/definitions.js +109 -6
- package/lib/fleet/managed.js +257 -3
- package/lib/fleet/routes.js +5 -0
- package/lib/fleet/runtime.js +7 -1
- package/lib/mcp/cells.js +21 -1
- package/lib/proxy/federation.js +2 -2
- package/lib/proxy/resource-acl.js +1 -1
- package/package.json +1 -1
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-5eCvm3HF.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.34"}
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -34,11 +34,12 @@ const {
|
|
|
34
34
|
aggiornaDefinizioni,
|
|
35
35
|
cellIdFromTmuxSession,
|
|
36
36
|
resolveCwd, normalizeCwdRel, deriveCwdRel,
|
|
37
|
+
effectiveCapabilities,
|
|
37
38
|
} = require('./definitions.js');
|
|
38
39
|
const {
|
|
39
40
|
publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine,
|
|
40
41
|
CATALOG, credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames, profileFor,
|
|
41
|
-
normalizeManagedSpec,
|
|
42
|
+
normalizeManagedSpec, codexSurfaceNames,
|
|
42
43
|
} = require('./managed.js');
|
|
43
44
|
const { probeModel, probeCustomEndpoint } = require('./model-probe.js');
|
|
44
45
|
const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
|
|
@@ -935,6 +936,51 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
935
936
|
return { ok: true, id: wantedId, engine: wantedEngine };
|
|
936
937
|
}
|
|
937
938
|
|
|
939
|
+
// I campi non-identita' che `parseModel` ammette per una dichiarazione:
|
|
940
|
+
// id ed engine SONO l'identita' (il chiaro per cui esiste edit-model e' non
|
|
941
|
+
// doverla ritoccare), tutto il resto e' patchabile con la stessa validazione.
|
|
942
|
+
const EDIT_MODEL_FIELDS = new Set(['label', 'contextWindow', 'maxTokens', 'reasoning']);
|
|
943
|
+
|
|
944
|
+
async function editModel(id, engine, patch) {
|
|
945
|
+
if (readonly()) throw httpError(403, 'READONLY: edit-model bloccato');
|
|
946
|
+
const wantedId = String(id || '').trim();
|
|
947
|
+
const wantedEngine = String(engine || '').trim();
|
|
948
|
+
if (!wantedId || !wantedEngine) throw httpError(400, 'id e engine sono obbligatori');
|
|
949
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw httpError(400, 'patch mancante');
|
|
950
|
+
const keys = Object.keys(patch);
|
|
951
|
+
if (!keys.length) throw httpError(400, 'patch vuota: niente da modificare');
|
|
952
|
+
for (const key of keys) {
|
|
953
|
+
if (!EDIT_MODEL_FIELDS.has(key)) throw httpError(400, `campo non ammesso per edit-model: ${key}`);
|
|
954
|
+
}
|
|
955
|
+
if (patch.label !== undefined) {
|
|
956
|
+
if (typeof patch.label !== 'string' || !patch.label.trim() || patch.label.trim().length > 64) {
|
|
957
|
+
throw httpError(400, 'label non valida: stringa non vuota, massimo 64 caratteri');
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
for (const key of ['contextWindow', 'maxTokens']) {
|
|
961
|
+
if (patch[key] !== undefined && (!Number.isSafeInteger(patch[key]) || patch[key] < 1)) {
|
|
962
|
+
throw httpError(400, `${key} non valido: intero >= 1`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (patch.reasoning !== undefined && typeof patch.reasoning !== 'boolean') {
|
|
966
|
+
throw httpError(400, 'reasoning non valido: booleano');
|
|
967
|
+
}
|
|
968
|
+
const defs = reloadDefs();
|
|
969
|
+
if (!(defs.models || []).some((m) => m.id === wantedId && m.engine === wantedEngine)) {
|
|
970
|
+
throw httpError(404, `modello non dichiarato: ${wantedId} su ${wantedEngine}`);
|
|
971
|
+
}
|
|
972
|
+
// La scrittura passa da `mutate`, che rilegge e RIVALIDA l'intero file: la
|
|
973
|
+
// validazione completa resta quella del parser (contextWindow >= 1024 fino
|
|
974
|
+
// al cap, label entro il limite) e un valore fuori schema non entra. Gli
|
|
975
|
+
// engine che risolvono questo modello NON vengono toccati: la dichiarazione
|
|
976
|
+
// e' la stessa riga del file, l'engine non ha nulla da ricaricare.
|
|
977
|
+
await mutate(defs, (d) => {
|
|
978
|
+
const target = d.models.find((m) => m.id === wantedId && m.engine === wantedEngine);
|
|
979
|
+
Object.assign(target, patch);
|
|
980
|
+
});
|
|
981
|
+
return { ok: true, id: wantedId, engine: wantedEngine, edited: keys };
|
|
982
|
+
}
|
|
983
|
+
|
|
938
984
|
async function editEngine(id, patch, envChanges) {
|
|
939
985
|
if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
|
|
940
986
|
if (!id) throw httpError(400, 'id engine mancante');
|
|
@@ -1324,6 +1370,43 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1324
1370
|
...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
|
|
1325
1371
|
...(c.commands ? { commands: { ...c.commands } } : {}),
|
|
1326
1372
|
};
|
|
1373
|
+
// Superficie dichiarata (solo vista): una cella LEGACY (niente
|
|
1374
|
+
// capabilities, niente profilo) e' la cella di sempre — esce con
|
|
1375
|
+
// `legacy: true` e nessun campo capabilities; una cella dichiarata
|
|
1376
|
+
// esce con la fusione profilo+override (sostitutivo per chiave) e con
|
|
1377
|
+
// le chiavi che il materializzatore NON copre per il suo engine
|
|
1378
|
+
// segnate come `unsupported` (motivo leggibile, MAI un errore). Il
|
|
1379
|
+
// materializzatore per engine arriva per fasi: qui la dichiarazione
|
|
1380
|
+
// onesta di cosa viene davvero materializzato.
|
|
1381
|
+
const eff = effectiveCapabilities(c, defs);
|
|
1382
|
+
out.legacy = eff.legacy;
|
|
1383
|
+
if (!eff.legacy) {
|
|
1384
|
+
const client = (defs.engines.find((e) => e.id === c.engine) || {}).managed?.client || null;
|
|
1385
|
+
const materializzaMcp = client === 'claude' || client === 'codex' || client === 'codex-vl';
|
|
1386
|
+
const materializzaOndemand = client === 'codex' || client === 'codex-vl';
|
|
1387
|
+
const nonMaterializzate = {
|
|
1388
|
+
mcp: `nessun materializzatore MCP per questo engine in questa fase (client: ${client || 'sconosciuto'})`,
|
|
1389
|
+
skills: 'le skill non sono ancora materializzate per cella (fase successiva)',
|
|
1390
|
+
ondemand: 'la materializzazione on-demand arriva in una fase successiva',
|
|
1391
|
+
};
|
|
1392
|
+
const unsupported = Object.keys(eff.declared)
|
|
1393
|
+
.filter((key) => !(key === 'mcp' && materializzaMcp) && !(key === 'ondemand' && materializzaOndemand))
|
|
1394
|
+
.map((key) => ({ key, reason: nonMaterializzate[key] }));
|
|
1395
|
+
// Per codex/codex-vl un nome ondemand che NON e' nella config utente
|
|
1396
|
+
// del nodo viene ignorato dal profilo (TOML non scrive tabelle per
|
|
1397
|
+
// server sconosciuti): la vista lo dice al punto, invece di farlo
|
|
1398
|
+
// scoprire all'operatore quando la capability non risponde.
|
|
1399
|
+
if ((client === 'codex' || client === 'codex-vl') && eff.declared.ondemand?.length) {
|
|
1400
|
+
try {
|
|
1401
|
+
const toml = fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8');
|
|
1402
|
+
const noti = new Set(codexSurfaceNames(toml).servers);
|
|
1403
|
+
for (const nome of eff.declared.ondemand.filter((n) => !noti.has(n))) {
|
|
1404
|
+
unsupported.push({ key: 'ondemand', name: nome, reason: `il server on-demand "${nome}" non e' dichiarato nella config utente del nodo: ignorato dal profilo` });
|
|
1405
|
+
}
|
|
1406
|
+
} catch (_) { /* config utente non leggibile: nessuna nota, non e' un errore di vista */ }
|
|
1407
|
+
}
|
|
1408
|
+
out.capabilities = { declared: eff.declared, ...(unsupported.length ? { unsupported } : {}) };
|
|
1409
|
+
}
|
|
1327
1410
|
// cwdRel portatile derivato (solo vista): per celle valide (cwd reale
|
|
1328
1411
|
// sotto home) espone il rel canonico; per celle persistite ma non
|
|
1329
1412
|
// valide/fuori home espone needsRepair:true. NESSUNA mutazione di
|
|
@@ -1485,12 +1568,33 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1485
1568
|
// Nome leggibile, distinto dall'id: quest'ultimo resta la chiave di
|
|
1486
1569
|
// indirizzamento e l'unica origine della sessione tmux.
|
|
1487
1570
|
label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN },
|
|
1571
|
+
// Superficie operativa dichiarata della cella. Assenti entrambi = cella
|
|
1572
|
+
// legacy (comportamento di sempre). Il profilo alimenta le chiavi che
|
|
1573
|
+
// la cella non copre; l'override della cella sostituisce PER CHIAVE.
|
|
1574
|
+
capabilities: {
|
|
1575
|
+
type: 'object', required: false,
|
|
1576
|
+
mcp: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },
|
|
1577
|
+
skills: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
|
|
1578
|
+
ondemand: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
|
|
1579
|
+
},
|
|
1580
|
+
capabilityProfile: { type: 'string', required: false, ref: 'capabilityProfiles.id', max: 32 },
|
|
1581
|
+
},
|
|
1582
|
+
// Profili di ruolo condivisi del documento (top-level, accanto a
|
|
1583
|
+
// engines/cells): stessa forma del set di cella, referenziabili per nome.
|
|
1584
|
+
capabilityProfiles: {
|
|
1585
|
+
type: 'object', required: false, maxKeys: 32,
|
|
1586
|
+
keyPattern: '^[A-Za-z0-9._-]{1,32}$',
|
|
1587
|
+
valueShape: {
|
|
1588
|
+
mcp: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },
|
|
1589
|
+
skills: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
|
|
1590
|
+
ondemand: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
|
|
1591
|
+
},
|
|
1488
1592
|
},
|
|
1489
1593
|
};
|
|
1490
1594
|
}
|
|
1491
1595
|
|
|
1492
1596
|
function capabilities() {
|
|
1493
|
-
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test'];
|
|
1597
|
+
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test', 'edit-model'];
|
|
1494
1598
|
}
|
|
1495
1599
|
|
|
1496
1600
|
// Un engine del documento con un `baseUrl` dichiarato a mano: indirizzo e
|
|
@@ -1566,7 +1670,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1566
1670
|
defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
|
|
1567
1671
|
schema, definitions, capabilities,
|
|
1568
1672
|
credentialStatus, setLocalCredential, removeLocalCredential, testModel,
|
|
1569
|
-
defineModel, removeModel, close,
|
|
1673
|
+
defineModel, editModel, removeModel, close,
|
|
1570
1674
|
// Fetta 2b: il lease Live esposto alle route /api/lease, che il bridge
|
|
1571
1675
|
// MCP delle celle consuma per register/refresh/recovery. Assente se il
|
|
1572
1676
|
// lease e' disattivato: le route rispondono 501, non 500.
|
package/lib/fleet/causes.js
CHANGED
package/lib/fleet/cell-exec.js
CHANGED
|
@@ -632,10 +632,10 @@ async function main(argv = process.argv.slice(2), seams = {}) {
|
|
|
632
632
|
? ['inherit', 'inherit', 'inherit', 'pipe', 'pipe']
|
|
633
633
|
: ['inherit', 'inherit', 'inherit'],
|
|
634
634
|
});
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
635
|
+
// exit/error del figlio contano SUBITO, prima di qualunque await — se
|
|
636
|
+
// il figlio esce durante l'handshake di generazione, l'evento non deve
|
|
637
|
+
// andare perso (prima i listener arrivavano solo con waitChild, e il
|
|
638
|
+
// supervisore restava appeso).
|
|
639
639
|
let childSettled = false;
|
|
640
640
|
const childExit = new Promise((resolve) => {
|
|
641
641
|
current.once('exit', (code, signal) => {
|
|
@@ -649,16 +649,16 @@ async function main(argv = process.argv.slice(2), seams = {}) {
|
|
|
649
649
|
});
|
|
650
650
|
// Transizione di generazione comunicata al lease PRIMA di aprire il
|
|
651
651
|
// canale identita' della generazione nuova. L'exit del figlio ha la
|
|
652
|
-
// precedenza sull'annuncio;
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
652
|
+
// precedenza sull'annuncio; fail-closed: solo un annuncio RISOLTO con
|
|
653
|
+
// ok === true E generazione corrispondente apre il canale — ok:false
|
|
654
|
+
// (lease-down, identity-unverified), rifiuto, timeout ed exit restano
|
|
655
|
+
// tutti negativi, con sola diagnostica: il figlio parte comunque,
|
|
656
|
+
// senza identita'.
|
|
657
657
|
if (leaseCtl && generation > 0) {
|
|
658
658
|
const raced = await Promise.race([
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
659
|
+
// Il valore RISOLTO va ispezionato: .then(() => 'ok', ...) scartava
|
|
660
|
+
// il valore e trattava un annuncio risolto {ok:false} come successo,
|
|
661
|
+
// aprendo il canale.
|
|
662
662
|
leaseCtl.announceGeneration(generation).then(
|
|
663
663
|
(announced) => (announced && announced.ok === true && announced.generation === generation
|
|
664
664
|
? 'ok'
|
|
@@ -426,10 +426,10 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
426
426
|
writeSafe(socket, { type: 'generationDeny', generation: msg.generation });
|
|
427
427
|
return;
|
|
428
428
|
}
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
429
|
+
// L'avanzamento PRIMA dell'ACK e' voluto (at-least-once): se l'ACK si
|
|
430
|
+
// perde, il client ritenta con la STESSA generazione e qui e' idempotente
|
|
431
|
+
// (== corrente -> ACK). L'ordine inverso lascerebbe il server stantio con
|
|
432
|
+
// un client che crede di aver transizionato.
|
|
433
433
|
entry.lease.generation = msg.generation;
|
|
434
434
|
writeSafe(socket, { type: 'generationAck', generation: msg.generation });
|
|
435
435
|
}
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -39,6 +39,18 @@ const MCP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
|
39
39
|
const MAX_CELL_COMMAND_LEN = 4096;
|
|
40
40
|
const MAX_TMUXSESSION_LEN = 64;
|
|
41
41
|
|
|
42
|
+
// Capacita' dichiarate di una cella, per NOME (come `mcp`, che ne e' il caso
|
|
43
|
+
// particolare e l'alias legacy). Tre chiavi: `mcp` (server MCP), `skills`,
|
|
44
|
+
// `ondemand`. Profili di ruolo condivisi in `capabilityProfiles` (top-level del
|
|
45
|
+
// documento); la cella riferisce un profilo e puo' sostituire SINGOLE CHIAVI
|
|
46
|
+
// con il proprio set: l'override e' sostitutivo, non additivo, perche' unire
|
|
47
|
+
// non saprebbe togliere.
|
|
48
|
+
const MAX_CAPABILITIES = 64; // nomi per singola chiave
|
|
49
|
+
const MAX_CAPABILITY_PROFILES = 32; // profili dichiarabili nel documento
|
|
50
|
+
const CAPABILITY_KEYS = ['mcp', 'skills', 'ondemand'];
|
|
51
|
+
const CAPABILITY_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/; // skill/ondemand: slug
|
|
52
|
+
const CAPABILITY_KEY_RES = { mcp: MCP_NAME_RE, skills: CAPABILITY_NAME_RE, ondemand: CAPABILITY_NAME_RE };
|
|
53
|
+
|
|
42
54
|
const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
|
|
43
55
|
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/; // cell id: ammette maiuscole (il punto e' un id umano valido)
|
|
44
56
|
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // identificatore env POSIX-like
|
|
@@ -279,6 +291,30 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
|
|
|
279
291
|
engines.push(eng);
|
|
280
292
|
}
|
|
281
293
|
|
|
294
|
+
// I profili di capability si leggono PRIMA delle celle: una cella riferisce
|
|
295
|
+
// un profilo per NOME, e leggerli dopo la farebbe rifiutare per un dato che
|
|
296
|
+
// in realta' c'e'. `capabilityProfiles` e' opzionale: senza, il documento
|
|
297
|
+
// resta com'era (chiave assente, non vuota).
|
|
298
|
+
let capabilityProfiles;
|
|
299
|
+
if (d.capabilityProfiles !== undefined) {
|
|
300
|
+
if (!d.capabilityProfiles || typeof d.capabilityProfiles !== 'object' || Array.isArray(d.capabilityProfiles)) return null;
|
|
301
|
+
const voci = Object.entries(d.capabilityProfiles);
|
|
302
|
+
if (voci.length > MAX_CAPABILITY_PROFILES) {
|
|
303
|
+
if (typeof onIssue === 'function') onIssue({ field: 'capabilityProfiles', value: voci.length, rule: `troppi profili di capability dichiarati, cap ${MAX_CAPABILITY_PROFILES}` });
|
|
304
|
+
return reject(`fleet.json rifiutato: ${voci.length} profili di capability dichiarati, cap ${MAX_CAPABILITY_PROFILES}; riduci i profili a ${MAX_CAPABILITY_PROFILES} o meno`);
|
|
305
|
+
}
|
|
306
|
+
capabilityProfiles = {};
|
|
307
|
+
for (const [nome, profilo] of voci) {
|
|
308
|
+
// Il nome e' un identificatore (finira' in riferimento per cella e, poi,
|
|
309
|
+
// in un nome di file generato): stessa forma chiusa dell'id cella.
|
|
310
|
+
if (typeof nome !== 'string' || !CELL_ID_RE.test(nome) || capabilityProfiles[nome] !== undefined) return null;
|
|
311
|
+
const set = parseCapabilitySet(profilo, onIssue);
|
|
312
|
+
if (!set) return null;
|
|
313
|
+
capabilityProfiles[nome] = set;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const capabilityProfileNames = capabilityProfiles ? new Set(Object.keys(capabilityProfiles)) : null;
|
|
317
|
+
|
|
282
318
|
const tmuxSeen = new Set();
|
|
283
319
|
const legacyTmuxSeen = new Set();
|
|
284
320
|
const legacyTmuxSessions = new Map();
|
|
@@ -286,7 +322,7 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
|
|
|
286
322
|
const cells = [];
|
|
287
323
|
for (const c of d.cells) {
|
|
288
324
|
const cell = parseCell(c, engineIds, engineMap, {
|
|
289
|
-
allowLegacyTmuxNames, extraModels, onReject: reject, onIssue,
|
|
325
|
+
allowLegacyTmuxNames, extraModels, onReject: reject, onIssue, capabilityProfileNames,
|
|
290
326
|
});
|
|
291
327
|
if (!cell) return null;
|
|
292
328
|
if (cellIds.has(cell.id)) return null; // id cell univoco
|
|
@@ -301,11 +337,12 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
|
|
|
301
337
|
cells.push(cell);
|
|
302
338
|
}
|
|
303
339
|
|
|
304
|
-
// `models`
|
|
305
|
-
// resta byte-identica a prima, e l'aggiornamento non
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
340
|
+
// `models` e `capabilityProfiles` compaiono solo se dichiarati: una
|
|
341
|
+
// configurazione senza resta byte-identica a prima, e l'aggiornamento non
|
|
342
|
+
// riscrive nulla.
|
|
343
|
+
const parsed = { schemaVersion: SCHEMA_VERSION, engines, cells };
|
|
344
|
+
if (models.length) parsed.models = models;
|
|
345
|
+
if (capabilityProfiles) parsed.capabilityProfiles = capabilityProfiles;
|
|
309
346
|
// Metadato solo in memoria: serve al bootstrap per rinominare una sessione
|
|
310
347
|
// legacy PRIMA di persistere il nome safe. Non entra in JSON, draft o API.
|
|
311
348
|
Object.defineProperty(parsed, 'legacyTmuxSessions', {
|
|
@@ -462,8 +499,50 @@ function cellModelAllowed(engineId, model, engineMap, extraModels, onIssue) {
|
|
|
462
499
|
return !!normalizeManagedSpec({ ...engine.managed, model }, { extraModels, engineId, onIssue });
|
|
463
500
|
}
|
|
464
501
|
|
|
502
|
+
// Un set di capability (di profilo o di cella): oggetto le cui chiavi ammesse
|
|
503
|
+
// sono CAPABILITY_KEYS, ciascuna un array di nomi VALIDATI PER CHIAVE, senza
|
|
504
|
+
// duplicati, sotto cap. Chiave non ammessa -> rifiuto dell'intero set: come
|
|
505
|
+
// per panelUrl, due esiti opposti che si assomigliano non collassano in uno.
|
|
506
|
+
function parseCapabilitySet(v, onIssue = null) {
|
|
507
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
508
|
+
const out = {};
|
|
509
|
+
for (const key of Object.keys(v)) {
|
|
510
|
+
if (!CAPABILITY_KEYS.includes(key)) return null;
|
|
511
|
+
const arr = v[key];
|
|
512
|
+
if (!Array.isArray(arr) || arr.length > MAX_CAPABILITIES) {
|
|
513
|
+
if (typeof onIssue === 'function') onIssue({ field: `capabilities.${key}`, value: Array.isArray(arr) ? arr.length : -1, rule: `troppi nomi, cap ${MAX_CAPABILITIES}` });
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
const visti = new Set();
|
|
517
|
+
for (const nome of arr) {
|
|
518
|
+
if (typeof nome !== 'string' || !CAPABILITY_KEY_RES[key].test(nome) || visti.has(nome)) return null;
|
|
519
|
+
visti.add(nome);
|
|
520
|
+
}
|
|
521
|
+
out[key] = [...visti];
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// La superficie effettiva di una cella: il profilo alimenta le chiavi che la
|
|
527
|
+
// cella non copre, e l'override della cella SOSTITUISCE per chiave (unire non
|
|
528
|
+
// saprebbe togliere). LEGACY = nessun campo: la cella di sempre, comportamento
|
|
529
|
+
// invariato. Pura: la vista e i materializzatori la usano senza toccare nulla.
|
|
530
|
+
function effectiveCapabilities(cell, defs) {
|
|
531
|
+
const profili = (defs && defs.capabilityProfiles) || {};
|
|
532
|
+
const profilo = cell && cell.capabilityProfile !== undefined ? profili[cell.capabilityProfile] : undefined;
|
|
533
|
+
const declared = {};
|
|
534
|
+
let any = false;
|
|
535
|
+
for (const key of CAPABILITY_KEYS) {
|
|
536
|
+
const source = (cell && cell.capabilities && cell.capabilities[key] !== undefined) ? cell.capabilities[key]
|
|
537
|
+
: (profilo && profilo[key] !== undefined) ? profilo[key] : undefined;
|
|
538
|
+
if (source !== undefined) { declared[key] = [...source]; any = true; }
|
|
539
|
+
}
|
|
540
|
+
return { legacy: cell?.capabilities === undefined && cell?.capabilityProfile === undefined, declared };
|
|
541
|
+
}
|
|
542
|
+
|
|
465
543
|
function parseCell(c, engineIds, engineMap = new Map(), {
|
|
466
544
|
allowLegacyTmuxNames = true, extraModels = null, onReject = null, onIssue = null,
|
|
545
|
+
capabilityProfileNames = null,
|
|
467
546
|
} = {}) {
|
|
468
547
|
const reject = (message) => {
|
|
469
548
|
if (typeof onReject === 'function') onReject(message);
|
|
@@ -609,6 +688,26 @@ function parseCell(c, engineIds, engineMap = new Map(), {
|
|
|
609
688
|
mcp = [...visti];
|
|
610
689
|
}
|
|
611
690
|
|
|
691
|
+
// capabilities / capabilityProfile: la superficie operativa DICHIARATA della
|
|
692
|
+
// cella. `capabilities` e' un set per chiave (`mcp`/`skills`/`ondemand`,
|
|
693
|
+
// parseCapabilitySet sopra); `capabilityProfile` riferisce un profilo
|
|
694
|
+
// top-level del documento — DEVE esistere: un profilo dangling rifiuta la
|
|
695
|
+
// cella, non collassa in "niente profilo" (stesso motivo di panelUrl).
|
|
696
|
+
// Assenti entrambi = cella legacy: comportamento invariato (effectiveCapabilities).
|
|
697
|
+
let capabilities;
|
|
698
|
+
if (c.capabilities !== undefined) {
|
|
699
|
+
capabilities = parseCapabilitySet(c.capabilities, onIssue);
|
|
700
|
+
if (!capabilities) return null;
|
|
701
|
+
}
|
|
702
|
+
let capabilityProfile;
|
|
703
|
+
if (c.capabilityProfile !== undefined) {
|
|
704
|
+
if (typeof c.capabilityProfile !== 'string' || !CELL_ID_RE.test(c.capabilityProfile)) return null;
|
|
705
|
+
if (!capabilityProfileNames || !capabilityProfileNames.has(c.capabilityProfile)) {
|
|
706
|
+
return reject(`fleet.json rifiutato: la cella ${c.id} riferisce il profilo di capability "${c.capabilityProfile}" che non e' dichiarato in capabilityProfiles`);
|
|
707
|
+
}
|
|
708
|
+
capabilityProfile = c.capabilityProfile;
|
|
709
|
+
}
|
|
710
|
+
|
|
612
711
|
// label (opzionale): nome LEGGIBILE della cella, distinto dall'id.
|
|
613
712
|
// L'id resta la chiave stabile con cui si indirizza una cella e con cui si
|
|
614
713
|
// deriva la sessione tmux; la label e' solo cio' che un umano legge. Senza
|
|
@@ -670,6 +769,8 @@ function parseCell(c, engineIds, engineMap = new Map(), {
|
|
|
670
769
|
if (prompt !== undefined) out.prompt = prompt;
|
|
671
770
|
if (panelUrl !== undefined) out.panelUrl = panelUrl;
|
|
672
771
|
if (mcp !== undefined) out.mcp = mcp;
|
|
772
|
+
if (capabilities !== undefined) out.capabilities = capabilities;
|
|
773
|
+
if (capabilityProfile !== undefined) out.capabilityProfile = capabilityProfile;
|
|
673
774
|
if (label !== undefined) out.label = label;
|
|
674
775
|
if (legacyTmuxSession) {
|
|
675
776
|
Object.defineProperty(out, 'legacyTmuxSession', {
|
|
@@ -1098,6 +1199,8 @@ const CAPS = {
|
|
|
1098
1199
|
|
|
1099
1200
|
module.exports = {
|
|
1100
1201
|
parseDefinitions,
|
|
1202
|
+
effectiveCapabilities,
|
|
1203
|
+
parseCapabilitySet,
|
|
1101
1204
|
validateCommandTrust,
|
|
1102
1205
|
aggiornaDefinizioni,
|
|
1103
1206
|
proprietarioVivo,
|