@orkastery/cli 0.6.3 → 0.6.5

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.
@@ -388,6 +388,15 @@ function caiuDeVerdade(r) {
388
388
  return !r.ok && r.executado !== false && (r.causa === undefined || r.causa === 'exit') && Number.isInteger(r.code)
389
389
  && r.code >= 1 && r.code <= MAIOR_CODIGO_DA_LANE && !(r.relogioSobSteal?.length);
390
390
  }
391
+ /** O agregado so afirma contagens quando todas as lanes trouxeram JUnit valido. */
392
+ function junitDasLanes(lanes) {
393
+ if (!lanes.length || lanes.some((l) => l.resultado.junit?.estado !== 'lido'))
394
+ return undefined;
395
+ const relatorios = lanes.map((l) => l.resultado.junit);
396
+ const somar = (campo) => relatorios.reduce((total, r) => total + (r[campo] ?? 0), 0);
397
+ return { caminho: relatorios.map((r) => r.caminho).join(';'), estado: 'lido', testes: somar('testes'),
398
+ falhas: somar('falhas'), erros: somar('erros'), pulados: somar('pulados'), duracaoMs: somar('duracaoMs') };
399
+ }
391
400
  /**
392
401
  * O `test` das lanes (D4): ok so com todas ok; a evidencia de quem caiu; a parede e a da lane mais longa. A falha de
393
402
  * verdade de uma lane manda, como num comando so do Classic: o estouro ou o relogio sob steal de outra lane nao a esconde.
@@ -414,9 +423,22 @@ function testDasLanes(lanes) {
414
423
  const causas = lanes.map((l) => l.resultado.causa).filter((c) => !!c);
415
424
  const causa = deVerdade.length ? deVerdade[0].resultado.causa ?? 'exit' : PIOR_CAUSA.find((c) => causas.includes(c));
416
425
  const soRelogio = caidas.length > 0 && caidas.every((l) => (l.resultado.relogioSobSteal?.length ?? 0) > 0);
417
- const testes = juntar((r) => r.testeQueCaiu).slice(0, 10);
426
+ const nomesExtraidos = juntar((r) => r.testeQueCaiu);
427
+ const testes = lanes.some((l) => l.resultado.junit?.estado === 'lido') ? nomesExtraidos : nomesExtraidos.slice(0, 10);
418
428
  const falhas = juntar((r) => r.falhasDeTeste);
429
+ const parciais = lanes.filter((l) => !!l.resultado.parcial);
430
+ const filtrados = juntar((r) => r.parcial?.testes);
431
+ const junit = junitDasLanes(lanes);
419
432
  const relogio = soRelogio ? juntar((r) => r.relogioSobSteal) : [];
433
+ const retestes = lanes.map((l) => l.resultado.retesteDeRelogio).filter((r) => !!r);
434
+ const estadoDoReteste = ['reprovou', 'sem-veredito', 'sem-filtro', 'passou']
435
+ .find((estado) => retestes.some((r) => r.estado === estado));
436
+ const reteste = estadoDoReteste ? {
437
+ testes: [...new Set(retestes.flatMap((r) => r.testes))],
438
+ estado: estadoDoReteste,
439
+ ...(retestes.find((r) => r.estado === estadoDoReteste)?.code !== undefined
440
+ ? { code: retestes.find((r) => r.estado === estadoDoReteste).code } : {}),
441
+ } : undefined;
420
442
  const trechos = caidas.map((l) => l.resultado.trecho).filter((t) => !!t);
421
443
  return {
422
444
  nome: 'test',
@@ -430,12 +452,22 @@ function testDasLanes(lanes) {
430
452
  executado: !tempoComFalha && lanes.every((l) => l.resultado.executado !== false),
431
453
  ...(testes.length ? { testeQueCaiu: testes } : {}),
432
454
  ...(falhas.length ? { falhasDeTeste: falhas } : {}),
455
+ ...(junit ? { junit } : {}),
456
+ ...(parciais.length ? { parcial: { testes: [...new Set(filtrados)] } } : {}),
433
457
  ...(trechos.length ? { trecho: trechos.join('\n').slice(0, 1024) } : {}),
434
458
  ...(lanes.some((l) => l.resultado.stealPct !== undefined) ? { stealPct: lanes.find((l) => l.resultado.stealPct !== undefined).resultado.stealPct } : {}),
435
459
  ...(relogio.length ? { relogioSobSteal: relogio } : {}),
460
+ ...(reteste ? { retesteDeRelogio: reteste } : {}),
436
461
  lanes: lanes.map((l) => ({ lane: l.lane, comando: l.resultado.comando, ok: l.resultado.ok, code: l.resultado.code,
437
462
  ...(l.resultado.causa ? { causa: l.resultado.causa } : {}), duracaoMs: l.resultado.duracaoMs ?? 0,
438
- ...(l.resultado.testeQueCaiu?.length ? { testeQueCaiu: l.resultado.testeQueCaiu } : {}) })),
463
+ ...(l.resultado.testeQueCaiu?.length ? { testeQueCaiu: l.resultado.testeQueCaiu } : {}),
464
+ ...(l.resultado.junit ? { junit: l.resultado.junit } : {}),
465
+ ...(l.resultado.parcial ? { parcial: l.resultado.parcial } : {}),
466
+ ...(l.resultado.comandoExecutado ? { comandoExecutado: l.resultado.comandoExecutado } : {}),
467
+ ...(l.resultado.selecao ? { selecao: l.resultado.selecao } : {}),
468
+ ...(l.resultado.relogioSobSteal?.length ? { relogioSobSteal: l.resultado.relogioSobSteal } : {}),
469
+ ...(l.resultado.retesteDeRelogio ? { retesteDeRelogio: l.resultado.retesteDeRelogio } : {}),
470
+ ...(l.resultado.stealPct !== undefined ? { stealPct: l.resultado.stealPct } : {}) })),
439
471
  };
440
472
  }
441
473
  /**
@@ -466,7 +498,7 @@ function porQueDasLanes(c) {
466
498
  * Os comandos do manifesto no Agile (D4): `build` reaproveitado do preparo com a guarda valendo; o resto num grupo
467
499
  * paralelo, com as lanes no lugar do `verify.test`, so com a guarda valendo; sem ela, tudo em serie (o Classic).
468
500
  */
469
- function comandosNoAgile(manifesto, cwd, lista, executor, grupo, guarda, preparo) {
501
+ function comandosNoAgile(manifesto, cwd, lista, executor, grupo, guarda, preparo, selecao = { comandos: {}, lanes: {} }, passagemParcial, selecaoPorMudanca = {}) {
470
502
  const pecas = [];
471
503
  const passosDoPreparo = manifesto.verify.preparo ? passosDoComando(manifesto.verify.preparo) : [];
472
504
  const lanesDeclaradas = Object.entries(manifesto.check?.agile?.test ?? {});
@@ -480,7 +512,12 @@ function comandosNoAgile(manifesto, cwd, lista, executor, grupo, guarda, preparo
480
512
  pecas.push({ peca: 'grupo', estado: 'fallback', motivo, fallback: 'comandos em serie' });
481
513
  if (lanesDeclaradas.length > 0)
482
514
  pecas.push({ peca: 'lanes', estado: 'fallback', motivo, fallback: 'verify.test' });
483
- return { comandos: lista.map((c) => executor(c.nome, c.comando, cwd)), pecas, paralelo: false, lanes: [] };
515
+ const executar = (c) => {
516
+ const parcial = c.filtro && selecao.comandos[c.nome]
517
+ ? passagemParcial?.(c.nome, c.comando, cwd, c.filtro, selecao.comandos[c.nome]) : null;
518
+ return parcial && !parcial.ok ? parcial : executor(c.nome, c.comando, cwd);
519
+ };
520
+ return { comandos: lista.map(executar), pecas, paralelo: false, lanes: [] };
484
521
  }
485
522
  // Com a guarda: o build primeiro (do preparo, quando e so passos dele), e o resto junto.
486
523
  const build = lista.find((c) => c.nome === 'build');
@@ -499,17 +536,55 @@ function comandosNoAgile(manifesto, cwd, lista, executor, grupo, guarda, preparo
499
536
  for (const c of resto) {
500
537
  if (c.nome === 'test' && lanesDeclaradas.length > 0) {
501
538
  for (const [lane, comando] of lanesDeclaradas) {
539
+ const junit = manifesto.check?.agile?.junit?.[lane];
540
+ const filtro = manifesto.check?.agile?.filtro?.[lane];
541
+ const recursos = manifesto.check?.agile?.recursos?.[lane];
542
+ const testesFiltrados = selecao.lanes[lane];
543
+ const porMudanca = selecaoPorMudanca[lane];
544
+ const efetivo = porMudanca?.comando ?? comando;
545
+ const dadosDaSelecao = porMudanca ? { comandoIntegral: comando, selecaoPorMudanca: porMudanca } : {};
502
546
  if (depoisDaVarredura.has(lane))
503
- encadeados.push({ nome: `test:${lane}`, comando, apos: ['security_scan'], papelCpu: 'integracao' });
547
+ encadeados.push({ nome: `test:${lane}`, comando: efetivo, ...dadosDaSelecao,
548
+ ...(junit ? { junit } : {}), ...(filtro ? { filtro } : {}), ...(testesFiltrados ? { testesFiltrados } : {}),
549
+ ...(recursos?.length ? { recursos } : {}),
550
+ apos: ['security_scan'], papelCpu: 'integracao' });
504
551
  else
505
- itens.push({ nome: `test:${lane}`, comando, papelCpu: lane === 'hermetica' ? 'hermetica' : 'outro' });
552
+ itens.push({ nome: `test:${lane}`, comando: efetivo, ...dadosDaSelecao, ...(junit ? { junit } : {}),
553
+ ...(filtro ? { filtro } : {}), ...(testesFiltrados ? { testesFiltrados } : {}),
554
+ ...(recursos?.length ? { recursos } : {}),
555
+ papelCpu: lane === 'hermetica' ? 'hermetica' : 'outro' });
506
556
  }
507
557
  }
508
558
  else
509
- itens.push({ nome: c.nome, comando: c.comando, papelCpu: c.nome === 'security_scan' ? 'varredura' : 'outro' });
559
+ itens.push({ nome: c.nome, comando: c.comando, ...(c.junit ? { junit: c.junit } : {}),
560
+ ...(c.filtro ? { filtro: c.filtro } : {}), ...(selecao.comandos[c.nome] ? { testesFiltrados: selecao.comandos[c.nome] } : {}),
561
+ papelCpu: c.nome === 'security_scan' ? 'varredura' : 'outro' });
510
562
  }
511
563
  itens.push(...encadeados);
512
- const resultados = itens.length > 1 ? grupo(itens, cwd) : itens.map((i) => executor(i.nome, i.comando, cwd));
564
+ const parciaisVermelhas = new Map();
565
+ for (const item of itens) {
566
+ if (!item.filtro || !item.testesFiltrados?.length || !passagemParcial)
567
+ continue;
568
+ const parcial = passagemParcial(item.nome, item.comando, cwd, item.filtro, item.testesFiltrados);
569
+ if (parcial && !parcial.ok)
570
+ parciaisVermelhas.set(item.nome, parcial);
571
+ }
572
+ const inteiros = itens.filter((i) => !parciaisVermelhas.has(i.nome));
573
+ const resultadosInteiros = inteiros.length > 1 ? grupo(inteiros, cwd) : inteiros.map((i) => executor(i.nome, i.comando, cwd));
574
+ const porItemInteiro = new Map(inteiros.map((item, i) => [item.nome, resultadosInteiros[i]]));
575
+ const resultados = itens.map((item) => {
576
+ const parcial = parciaisVermelhas.get(item.nome);
577
+ if (parcial)
578
+ return parcial;
579
+ const resultado = porItemInteiro.get(item.nome);
580
+ if (!item.selecaoPorMudanca || !item.comandoIntegral)
581
+ return resultado;
582
+ const { comando: _comando, ...registro } = item.selecaoPorMudanca;
583
+ return item.selecaoPorMudanca.modo === 'selecionada'
584
+ ? { ...resultado, comando: item.comandoIntegral, comandoExecutado: item.comando,
585
+ parcial: { testes: [] }, selecao: registro }
586
+ : { ...resultado, comando: item.comandoIntegral, selecao: registro };
587
+ });
513
588
  pecas.push(...(grupo.pecas ?? []));
514
589
  const lanes = [];
515
590
  itens.forEach((item, i) => {
@@ -590,7 +665,8 @@ exports.RelatorioDoCheck = RelatorioDoCheck;
590
665
  */
591
666
  function pecasDoRelatorio(linhas) {
592
667
  const vistas = new Set();
593
- return linhas.filter((l) => l.tipo === 'peca' && typeof l.peca === 'string' && (l.estado === 'agile' || l.estado === 'fallback'))
668
+ return linhas.filter((l) => l.tipo === 'peca' && typeof l.peca === 'string'
669
+ && (l.estado === 'agile' || l.estado === 'degradado' || l.estado === 'fallback'))
594
670
  .map((l) => ({ peca: l.peca, estado: l.estado,
595
671
  ...(typeof l.motivo === 'string' ? { motivo: l.motivo } : {}), ...(typeof l.fallback === 'string' ? { fallback: l.fallback } : {}) }))
596
672
  .filter((p) => { const chave = JSON.stringify(p); if (vistas.has(chave))
@@ -627,8 +703,8 @@ function resultadosDasLanes(linhas) {
627
703
  /** Hash do ambiente funcional; transporte do CHECK, npm e bookkeeping do shell nao mudam o teste. */
628
704
  function assinaturaDoAmbiente(env) {
629
705
  const ignorada = (nome) => nome.startsWith('npm_') || nome.startsWith('NPM_')
630
- || ['ORK_CHECK_RELATORIO', 'ORK_CHECK_CPUS_DA_LANE', 'ORK_CHECK_CPUS_DA_LANE_APOS_RESERVA', 'ORK_CHECK_RESERVA_DEVOLVIDA',
631
- 'ORK_CHECK_CPUS_DOCKER',
706
+ || ['ORK_CHECK_RELATORIO', 'ORK_CHECK_CPUS_DA_LANE', 'ORK_CHECK_CPUS_DA_LANE_APOS_VARREDURA',
707
+ 'ORK_CHECK_CPUS_DA_LANE_APOS_RESERVA', 'ORK_CHECK_RESERVA_DEVOLVIDA', 'ORK_CHECK_CONTAINERS_DA_LANE', 'ORK_CHECK_CPUS_DOCKER',
632
708
  'NODE_TEST_CONTEXT', 'NODE_COMPILE_CACHE', 'PWD', 'OLDPWD', 'SHLVL', '_', 'INIT_CWD'].includes(nome);
633
709
  const pares = Object.entries(env).filter(([nome, valor]) => valor !== undefined && !ignorada(nome))
634
710
  .sort(([a], [b]) => a.localeCompare(b));
@@ -34,6 +34,10 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.argvDoItem = argvDoItem;
37
+ exports.workersDaLane = workersDaLane;
38
+ exports.chaveDoPerfilIntegracao = chaveDoPerfilIntegracao;
39
+ exports.picoDaArvoreParaPerfil = picoDaArvoreParaPerfil;
40
+ exports.picoCpuPorFonte = picoCpuPorFonte;
37
41
  exports.esperaDoGrupo = esperaDoGrupo;
38
42
  exports.rodarEmParalelo = rodarEmParalelo;
39
43
  /**
@@ -48,12 +52,17 @@ exports.rodarEmParalelo = rodarEmParalelo;
48
52
  * de integracao depois da varredura), e `maxParalelo` limita quantos rodam ao mesmo tempo.
49
53
  */
50
54
  const node_child_process_1 = require("node:child_process");
55
+ const node_crypto_1 = require("node:crypto");
51
56
  const fs = __importStar(require("node:fs"));
52
57
  const os = __importStar(require("node:os"));
53
58
  const path = __importStar(require("node:path"));
54
59
  const agendador_cpu_1 = require("./agendador-cpu");
60
+ const integracoes_locais_1 = require("./integracoes-locais");
55
61
  const TIMEOUT_BIN = '/usr/bin/timeout';
56
62
  const NICE_BIN = '/usr/bin/nice';
63
+ const TIME_BIN = '/usr/bin/time';
64
+ const CONTRATO_PERFIL_INTEGRACAO = 'ork.check.cpu-integracao/v2';
65
+ const INTERVALO_CPU_MS = 100;
57
66
  /** O argv de um item: `nice` (se pedido e existir), `timeout(1)` (se existir) e o `argv` do item ou `bash -c`. */
58
67
  function argvDoItem(item, comGrupo = fs.existsSync(TIMEOUT_BIN), comNice = fs.existsSync(NICE_BIN)) {
59
68
  const programa = item.argv?.length ? item.argv : ['bash', '-c', item.comando];
@@ -77,6 +86,299 @@ function vagas(max) {
77
86
  livres++; },
78
87
  };
79
88
  }
89
+ /**
90
+ * Recursos exclusivos sem dependencia artificial. Cada pedido adquire o conjunto inteiro ou espera, o que evita
91
+ * deadlock entre listas cruzadas; pedidos disjuntos continuam livres para avancar.
92
+ */
93
+ function recursosExclusivos() {
94
+ const ocupados = new Set();
95
+ const fila = [];
96
+ const despachar = () => {
97
+ for (let i = 0; i < fila.length;) {
98
+ const pedido = fila[i];
99
+ if (pedido.nomes.some((nome) => ocupados.has(nome))) {
100
+ i++;
101
+ continue;
102
+ }
103
+ fila.splice(i, 1);
104
+ pedido.nomes.forEach((nome) => ocupados.add(nome));
105
+ pedido.pronto(() => {
106
+ pedido.nomes.forEach((nome) => ocupados.delete(nome));
107
+ despachar();
108
+ });
109
+ }
110
+ };
111
+ return {
112
+ tomar: (nomes) => new Promise((pronto) => {
113
+ fila.push({ nomes: [...new Set(nomes)], pronto });
114
+ despachar();
115
+ }),
116
+ };
117
+ }
118
+ /** A reserva que vira ORK_WORKERS quando a lane e efetivamente lancada. */
119
+ function workersDaLane(item, plano, cpus, totalDeLanes) {
120
+ if (!item.nome.startsWith('test:'))
121
+ return null;
122
+ const capacidade = (0, agendador_cpu_1.workersDaReserva)(cpus);
123
+ if (!plano)
124
+ return Math.max(1, Math.floor(capacidade / Math.max(1, Math.min(capacidade, totalDeLanes))));
125
+ if (item.papelCpu === 'hermetica')
126
+ return (0, agendador_cpu_1.workersDaReserva)(plano.cpusHermetica);
127
+ if (item.papelCpu === 'integracao')
128
+ return (0, agendador_cpu_1.workersDaReserva)(plano.cpusIntegracao);
129
+ // Outros fecham a cadeia depois que as reservas da varredura e da integracao voltaram.
130
+ return capacidade;
131
+ }
132
+ function arquivoDoPerfilIntegracao() {
133
+ const base = process.env.XDG_CACHE_HOME && path.isAbsolute(process.env.XDG_CACHE_HOME)
134
+ ? process.env.XDG_CACHE_HOME : path.join(os.homedir(), '.cache');
135
+ return path.join(base, 'orkastery', 'check', 'cpu-integracao.json');
136
+ }
137
+ /** Os arquivos da lane de integracao, pela mesma regra do `test-lanes.cjs`: a lista do ambiente ou a do nucleo. */
138
+ function arquivosDaIntegracao(env = process.env) {
139
+ const lista = env.ORK_LANES_INTEGRACAO !== undefined
140
+ ? env.ORK_LANES_INTEGRACAO.split(',').map((n) => n.trim()).filter(Boolean) : [...integracoes_locais_1.TESTES_DE_INTEGRACAO_LOCAL];
141
+ return [...new Set(lista)].sort();
142
+ }
143
+ /**
144
+ * A medida so atravessa rodadas quando comando, capacidade e entradas que definem a lane continuam iguais: o script das
145
+ * lanes, a lista de integracao e o conteudo compilado de cada teste dela. Teste novo, mudado ou ausente troca a chave.
146
+ */
147
+ function chaveDoPerfilIntegracao(itens, cpus, arquivos = arquivosDaIntegracao()) {
148
+ const integracao = itens.filter((i) => i.papelCpu === 'integracao');
149
+ if (!integracao.length)
150
+ return null;
151
+ const h = (0, node_crypto_1.createHash)('sha256').update(JSON.stringify({ cpus, node: process.version,
152
+ comandos: integracao.map((i) => i.comando), arquivos }));
153
+ const cwd = integracao[0].cwd;
154
+ const relativos = ['core/dist/integracoes-locais.js', 'core/scripts/test-lanes.cjs',
155
+ ...arquivos.map((nome) => path.join('core', 'dist-test', 'test', nome))];
156
+ for (const relativo of relativos) {
157
+ try {
158
+ h.update(relativo).update(fs.readFileSync(path.join(cwd, relativo)));
159
+ }
160
+ catch {
161
+ h.update(`${relativo}:ausente`);
162
+ }
163
+ }
164
+ return h.digest('hex');
165
+ }
166
+ function lerPerfilIntegracao(chave, cpus) {
167
+ if (!chave)
168
+ return null;
169
+ try {
170
+ const p = JSON.parse(fs.readFileSync(arquivoDoPerfilIntegracao(), 'utf8'));
171
+ return p?.contrato === CONTRATO_PERFIL_INTEGRACAO && p.chave === chave && p.cpus === cpus && p.completa
172
+ && Number.isFinite(p.picoCpusDaArvore) && p.picoCpusDaArvore > 0 && p.picoCpusDaArvore <= cpus ? p : null;
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }
178
+ /** Somente uma arvore completa e fisicamente possivel pode ensinar a reserva; lacunas de conteiner ficam na evidencia. */
179
+ function picoDaArvoreParaPerfil(medicao, cpus) {
180
+ const pico = medicao.picoCpusDaArvore;
181
+ return medicao.arvoreCompleta && !medicao.anomaliaDaArvore && pico !== null && pico > 0 && pico <= cpus ? pico : null;
182
+ }
183
+ function gravarPerfilIntegracao(chave, cpus, medicao) {
184
+ const picoCpusDaArvore = picoDaArvoreParaPerfil(medicao, cpus);
185
+ if (!chave || picoCpusDaArvore === null)
186
+ return;
187
+ const arquivo = arquivoDoPerfilIntegracao();
188
+ try {
189
+ fs.mkdirSync(path.dirname(arquivo), { recursive: true });
190
+ const temporario = `${arquivo}.${process.pid}.tmp`;
191
+ const perfil = { contrato: CONTRATO_PERFIL_INTEGRACAO, chave, cpus,
192
+ picoCpusDaArvore, completa: true, medidoEm: new Date().toISOString() };
193
+ fs.writeFileSync(temporario, JSON.stringify(perfil) + '\n', { mode: 0o600 });
194
+ fs.renameSync(temporario, arquivo);
195
+ }
196
+ catch { /* cache de desempenho ausente conserva a reserva; nunca derruba o CHECK */ }
197
+ }
198
+ const CLK_TCK = (() => {
199
+ const r = (0, node_child_process_1.spawnSync)('getconf', ['CLK_TCK'], { encoding: 'utf8', timeout: 1000 });
200
+ const n = Number(r.stdout?.trim());
201
+ return Number.isFinite(n) && n > 0 ? n : 100;
202
+ })();
203
+ function processosCpu() {
204
+ const tabela = new Map();
205
+ let nomes = [];
206
+ try {
207
+ nomes = fs.readdirSync('/proc');
208
+ }
209
+ catch {
210
+ return tabela;
211
+ }
212
+ for (const nome of nomes) {
213
+ if (!/^\d+$/.test(nome))
214
+ continue;
215
+ try {
216
+ const stat = fs.readFileSync(`/proc/${nome}/stat`, 'utf8');
217
+ const depois = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/);
218
+ tabela.set(Number(nome), { ppid: Number(depois[1]), cpuMs: (Number(depois[11]) + Number(depois[12])) * 1000 / CLK_TCK,
219
+ inicio: depois[19] });
220
+ }
221
+ catch { /* acabou entre a lista e a leitura */ }
222
+ }
223
+ return tabela;
224
+ }
225
+ function arvoreDeProcessos(raiz, tabela) {
226
+ const filhos = new Map();
227
+ for (const [pid, p] of tabela) {
228
+ const lista = filhos.get(p.ppid) ?? [];
229
+ lista.push(pid);
230
+ filhos.set(p.ppid, lista);
231
+ }
232
+ const vistos = new Set(), pilha = [raiz];
233
+ while (pilha.length) {
234
+ const pid = pilha.pop();
235
+ if (vistos.has(pid))
236
+ continue;
237
+ vistos.add(pid);
238
+ pilha.push(...(filhos.get(pid) ?? []));
239
+ }
240
+ return [...vistos];
241
+ }
242
+ function nomesDosContainers(arquivo) {
243
+ try {
244
+ return [...new Set(fs.readFileSync(arquivo, 'utf8').split('\n').filter(Boolean).flatMap((linha) => {
245
+ try {
246
+ const r = JSON.parse(linha);
247
+ return typeof r.nome === 'string' ? [r.nome] : [];
248
+ }
249
+ catch {
250
+ return [];
251
+ }
252
+ }))];
253
+ }
254
+ catch {
255
+ return [];
256
+ }
257
+ }
258
+ function arquivoCpuDoContainer(nome) {
259
+ const r = (0, node_child_process_1.spawnSync)('docker', ['inspect', '--format', '{{.Id}} {{.State.Pid}}', nome], { encoding: 'utf8', timeout: 1000 });
260
+ const [id = '', pidTexto = ''] = r.stdout?.trim().split(/\s+/) ?? [];
261
+ const pid = Number(pidTexto);
262
+ if (r.status !== 0)
263
+ return { fonte: null, motivo: 'docker inspect nao encontrou o conteiner' };
264
+ if (!/^[a-f0-9]{12,64}$/.test(id) || !Number.isInteger(pid) || pid <= 0) {
265
+ return { fonte: null, motivo: 'PID ou identificador do conteiner indisponivel' };
266
+ }
267
+ let linhas = '';
268
+ try {
269
+ linhas = fs.readFileSync(`/proc/${pid}/cgroup`, 'utf8');
270
+ }
271
+ catch {
272
+ return { fonte: null, motivo: 'cgroup do PID do conteiner indisponivel' };
273
+ }
274
+ const unificado = /^0::(.+)$/m.exec(linhas)?.[1];
275
+ const candidatos = [
276
+ ...(unificado ? [{ arquivo: path.join('/sys/fs/cgroup', unificado, 'cpu.stat'), divisor: 1000 }] : []),
277
+ { arquivo: path.join('/sys/fs/cgroup/system.slice', `docker-${id}.scope`, 'cpu.stat'), divisor: 1000 },
278
+ { arquivo: path.join('/sys/fs/cgroup/docker', id, 'cpu.stat'), divisor: 1000 },
279
+ ];
280
+ const legado = /^(?:\d+):[^\n]*(?:cpuacct|cpu)[^\n]*:(.+)$/m.exec(linhas)?.[1];
281
+ if (legado)
282
+ candidatos.push({ arquivo: path.join('/sys/fs/cgroup/cpuacct', legado, 'cpuacct.usage'), divisor: 1_000_000 });
283
+ const fonte = candidatos.find((x) => fs.existsSync(x.arquivo)) ?? null;
284
+ return fonte ? { fonte, motivo: null }
285
+ : { fonte: null, motivo: 'cpu.stat ausente no cgroup v2, no escopo do systemd e no caminho legado' };
286
+ }
287
+ function lerCpuDoCgroup(fonte) {
288
+ try {
289
+ const texto = fs.readFileSync(fonte.arquivo, 'utf8');
290
+ const bruto = fonte.arquivo.endsWith('cpu.stat') ? Number(/^usage_usec\s+(\d+)$/m.exec(texto)?.[1]) : Number(texto.trim());
291
+ return Number.isFinite(bruto) && bruto >= 0 ? bruto / fonte.divisor : null;
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ }
297
+ /**
298
+ * Calcula o pico por fonte. A primeira leitura vira baseline; uma fonte que nasce depois dela contribui com o acumulado
299
+ * desde seu nascimento, sem despejar na janela o tempo anterior de processos ou conteineres que ja existiam.
300
+ */
301
+ function picoCpuPorFonte(amostras, cpus) {
302
+ let pico = 0;
303
+ for (let i = 1; i < amostras.length; i++) {
304
+ const anterior = amostras[i - 1], atual = amostras[i];
305
+ if (!(atual.em > anterior.em))
306
+ continue;
307
+ let delta = 0;
308
+ for (const [fonte, acumulado] of Object.entries(atual.fontes)) {
309
+ const base = anterior.fontes[fonte];
310
+ delta += Math.max(0, acumulado - (base ?? 0));
311
+ }
312
+ pico = Math.max(pico, delta / (atual.em - anterior.em));
313
+ }
314
+ if (pico > cpus + 1e-9) {
315
+ return { picoCpus: null, anomalia: `pico medido de ${pico.toFixed(2)} CPU excede a capacidade de ${cpus} CPU` };
316
+ }
317
+ return { picoCpus: pico > 0 ? Math.ceil(pico * 100) / 100 : null, anomalia: null };
318
+ }
319
+ /** CPU acumulada e pico por janela da integracao; nomes registrados mas nunca observados tornam a medida incompleta. */
320
+ function sondarCpuDaIntegracao(pid, registro, cpus) {
321
+ const fontes = new Map();
322
+ const motivos = new Map();
323
+ const amostras = [];
324
+ const amostrasDaArvore = [];
325
+ const primeiraCpuDosContainers = new Map();
326
+ const ultimaCpuDosContainers = new Map();
327
+ const amostrar = () => {
328
+ const tabela = processosCpu();
329
+ const arvore = {};
330
+ for (const p of arvoreDeProcessos(pid, tabela)) {
331
+ const x = tabela.get(p);
332
+ if (x)
333
+ arvore[`processo:${p}:${x.inicio}`] = x.cpuMs;
334
+ }
335
+ const atuais = { ...arvore };
336
+ for (const nome of nomesDosContainers(registro)) {
337
+ let fonte = fontes.get(nome);
338
+ if (!fonte) {
339
+ const busca = arquivoCpuDoContainer(nome);
340
+ if (busca.fonte) {
341
+ fonte = busca.fonte;
342
+ fontes.set(nome, fonte);
343
+ motivos.delete(nome);
344
+ }
345
+ else if (busca.motivo)
346
+ motivos.set(nome, busca.motivo);
347
+ }
348
+ const cpu = fonte ? lerCpuDoCgroup(fonte) : null;
349
+ if (cpu !== null) {
350
+ atuais[`conteiner:${nome}`] = cpu;
351
+ if (!primeiraCpuDosContainers.has(nome))
352
+ primeiraCpuDosContainers.set(nome, amostras.length === 0 ? cpu : 0);
353
+ ultimaCpuDosContainers.set(nome, cpu);
354
+ motivos.delete(nome);
355
+ }
356
+ else if (fonte)
357
+ motivos.set(nome, 'contador de CPU do cgroup ilegivel');
358
+ }
359
+ const em = Date.now();
360
+ amostrasDaArvore.push({ em, fontes: arvore });
361
+ amostras.push({ em, fontes: atuais });
362
+ };
363
+ return {
364
+ amostrar,
365
+ fechar: (arvoreMs, paredeMs) => {
366
+ amostrar();
367
+ const nomes = nomesDosContainers(registro);
368
+ const containersMs = nomes.reduce((total, nome) => total + Math.max(0, (ultimaCpuDosContainers.get(nome) ?? 0) - (primeiraCpuDosContainers.get(nome) ?? 0)), 0);
369
+ const ausentes = nomes.filter((nome) => !ultimaCpuDosContainers.has(nome));
370
+ const pico = picoCpuPorFonte(amostras, cpus);
371
+ const picoDaArvore = picoCpuPorFonte(amostrasDaArvore, cpus);
372
+ const arvoreCompleta = arvoreMs > 0 && picoDaArvore.picoCpus !== null && !picoDaArvore.anomalia;
373
+ return { arvoreMs, containersMs, totalMs: arvoreMs + containersMs, paredeMs,
374
+ picoCpus: pico.picoCpus, picoCpusDaArvore: picoDaArvore.picoCpus,
375
+ completa: arvoreCompleta && ausentes.length === 0 && !pico.anomalia, arvoreCompleta,
376
+ containers: nomes, ausentes, motivosDosAusentes: ausentes.map((container) => ({ container,
377
+ motivo: motivos.get(container) ?? 'conteiner terminou antes de uma amostra completa' })),
378
+ anomalia: pico.anomalia, anomaliaDaArvore: picoDaArvore.anomalia };
379
+ },
380
+ };
381
+ }
80
382
  /**
81
383
  * O lado do auxiliar: lanca cada item quando os de `apos` terminam e ha vaga, espera todos e grava `resultado.json` ao
82
384
  * lado das saidas.
@@ -84,10 +386,21 @@ function vagas(max) {
84
386
  async function rodarEspecificacao(esp) {
85
387
  const comGrupo = fs.existsSync(TIMEOUT_BIN);
86
388
  const usaAgendador = esp.itens.some((i) => i.papelCpu === 'hermetica');
87
- const plano = usaAgendador ? (0, agendador_cpu_1.planejarCpuDoGrupo)(esp.cpus ?? os.availableParallelism(), esp.medicaoCpu ?? await (0, agendador_cpu_1.medirCpuDoGrupo)()) : undefined;
389
+ const cpus = esp.cpus ?? os.availableParallelism();
390
+ const chaveDoPerfil = usaAgendador ? chaveDoPerfilIntegracao(esp.itens, cpus) : null;
391
+ const perfil = esp.medicaoCpu?.cpusIntegracao === undefined ? lerPerfilIntegracao(chaveDoPerfil, cpus) : null;
392
+ const medicao = esp.medicaoCpu ?? await (0, agendador_cpu_1.medirCpuDoGrupo)();
393
+ if (perfil)
394
+ Object.assign(medicao, { cpusIntegracao: perfil.picoCpusDaArvore, fonteCpusIntegracao: 'medida' });
395
+ else if (medicao.cpusIntegracao === undefined)
396
+ Object.assign(medicao, { cpusIntegracao: null, fonteCpusIntegracao: 'conservadora' });
397
+ const plano = usaAgendador ? (0, agendador_cpu_1.planejarCpuDoGrupo)(cpus, medicao) : undefined;
88
398
  const sinalDaReserva = plano && !plano.saturado ? path.join(esp.dir, 'reserva-devolvida.json') : undefined;
89
399
  const itens = plano ? aplicarPlano(esp.itens, plano, sinalDaReserva) : esp.itens;
90
- const limite = vagas(esp.maxParalelo && esp.maxParalelo > 0 ? esp.maxParalelo : itens.length);
400
+ const tetoInformado = esp.maxParalelo && esp.maxParalelo > 0 ? esp.maxParalelo : itens.length;
401
+ const limite = vagas(Math.max(1, Math.min(tetoInformado, (0, agendador_cpu_1.workersDaReserva)(cpus))));
402
+ const exclusivos = recursosExclusivos();
403
+ const totalDeLanes = itens.filter((item) => item.nome.startsWith('test:')).length;
91
404
  const porNome = new Map();
92
405
  const indice = new Map(itens.map((item, i) => [item.nome, i]));
93
406
  const vezDoItem = (item, i) => {
@@ -102,12 +415,14 @@ async function rodarEspecificacao(esp) {
102
415
  if (j !== undefined)
103
416
  await vezDoItem(itens[j], j);
104
417
  }
418
+ const soltarRecursos = await exclusivos.tomar(item.recursos ?? []);
105
419
  await limite.tomar();
106
420
  try {
107
421
  return await lancar(item, i);
108
422
  }
109
423
  finally {
110
424
  limite.soltar();
425
+ soltarRecursos();
111
426
  }
112
427
  });
113
428
  porNome.set(item.nome, vez);
@@ -119,29 +434,85 @@ async function rodarEspecificacao(esp) {
119
434
  if (!sinalDaReserva || !reservados.some((item) => item.papelCpu === 'varredura')
120
435
  || !reservados.some((item) => item.papelCpu === 'integracao'))
121
436
  return;
122
- await Promise.all(reservados.map((item) => vezDoItem(item, indice.get(item.nome))));
123
- fs.writeFileSync(sinalDaReserva, JSON.stringify({ em: new Date().toISOString(), vagas: plano.cpusHermeticaAposReserva }), { mode: 0o600 });
437
+ const varreduras = reservados.filter((item) => item.papelCpu === 'varredura');
438
+ const integracoes = reservados.filter((item) => item.papelCpu === 'integracao');
439
+ const degrau = async (origem, pecas, vagas, antes) => {
440
+ await Promise.all(pecas.map((item) => vezDoItem(item, indice.get(item.nome))));
441
+ fs.appendFileSync(sinalDaReserva, JSON.stringify({ contrato: 'ork.check.reserva-devolvida/v2', origem,
442
+ em: new Date().toISOString(), vagasGanhas: vagas - antes, vagas }) + '\n', { mode: 0o600 });
443
+ };
444
+ if (!plano.integracaoEmParalelo) {
445
+ await degrau('varredura', varreduras, plano.cpusHermeticaAposVarredura, plano.cpusHermetica);
446
+ await degrau('integracao', integracoes, plano.cpusHermeticaAposReserva, plano.cpusHermeticaAposVarredura);
447
+ }
448
+ else {
449
+ // As promessas escrevem em série no event loop; o primeiro término recebe somente a reserva da própria peça.
450
+ let vagas = plano.cpusHermetica;
451
+ const aoTerminar = async (origem, pecas, libera) => {
452
+ await Promise.all(pecas.map((item) => vezDoItem(item, indice.get(item.nome))));
453
+ const antes = vagas;
454
+ vagas = Math.min(plano.cpus, vagas + libera);
455
+ fs.appendFileSync(sinalDaReserva, JSON.stringify({ contrato: 'ork.check.reserva-devolvida/v2', origem,
456
+ em: new Date().toISOString(), vagasGanhas: vagas - antes, vagas }) + '\n', { mode: 0o600 });
457
+ };
458
+ await Promise.all([aoTerminar('varredura', varreduras, plano.cpusDaFatia),
459
+ aoTerminar('integracao', integracoes, plano.cpusIntegracao)]);
460
+ }
124
461
  };
125
462
  const [, terminados] = await Promise.all([devolverReserva(), todos]);
463
+ const medidaDaIntegracao = terminados.find((t) => t.medicaoCpu)?.medicaoCpu;
464
+ if (plano && medidaDaIntegracao) {
465
+ plano.medicaoCpuIntegracao = medidaDaIntegracao;
466
+ gravarPerfilIntegracao(chaveDoPerfil, cpus, medidaDaIntegracao);
467
+ }
126
468
  fs.writeFileSync(path.join(esp.dir, 'resultado.json'), JSON.stringify({ terminados, ...(plano ? { agendamentoCpu: plano } : {}) }), { mode: 0o600 });
127
469
  function lancar(item, i) {
128
470
  return new Promise((resolve) => {
129
471
  const saida = fs.openSync(path.join(esp.dir, `${i}.out`), 'w', 0o600);
130
472
  const erro = fs.openSync(path.join(esp.dir, `${i}.err`), 'w', 0o600);
131
473
  const inicio = Date.now();
132
- const [bin, ...args] = argvDoItem(item, comGrupo);
474
+ const medirIntegracao = item.papelCpu === 'integracao';
475
+ const registro = path.join(esp.dir, `${i}.containers.jsonl`);
476
+ const tempo = path.join(esp.dir, `${i}.tempo`);
477
+ if (medirIntegracao)
478
+ fs.writeFileSync(registro, '', { mode: 0o600 });
479
+ const base = argvDoItem(item, comGrupo);
480
+ const medido = medirIntegracao && fs.existsSync(TIME_BIN)
481
+ ? [TIME_BIN, '-f', '%U %S', '-o', tempo, ...base] : base;
482
+ const [bin, ...args] = medido;
133
483
  let fim = null;
484
+ let sonda = null;
485
+ let relogio = null;
134
486
  const encerrar = (t) => {
135
487
  if (fim)
136
488
  return;
137
- fim = t;
489
+ if (relogio)
490
+ clearInterval(relogio);
491
+ let arvoreMs = 0;
492
+ if (medirIntegracao) {
493
+ try {
494
+ const [user, sys] = fs.readFileSync(tempo, 'utf8').trim().split(/\s+/).map(Number);
495
+ arvoreMs = (user + sys) * 1000;
496
+ }
497
+ catch { /* medida incompleta abaixo */ }
498
+ }
499
+ const medicaoCpu = medirIntegracao && sonda ? sonda.fechar(arvoreMs, t.duracaoMs) : undefined;
500
+ fim = { ...t, ...(medicaoCpu ? { medicaoCpu } : {}) };
138
501
  fs.closeSync(saida);
139
502
  fs.closeSync(erro);
140
- resolve(t);
503
+ resolve(fim);
141
504
  };
142
505
  // Sem o `timeout(1)`, o proprio auxiliar mata pelo prazo, como o `spawnSync` do `exec` faria.
143
- const filho = (0, node_child_process_1.spawn)(bin, args, { cwd: item.cwd, env: { ...process.env, ...item.ambiente }, stdio: ['ignore', saida, erro],
506
+ const workers = workersDaLane(item, plano, cpus, totalDeLanes);
507
+ const ambiente = { ...process.env, ...item.ambiente, ...(workers === null ? {} : { [agendador_cpu_1.ENV_WORKERS]: String(workers) }),
508
+ ...(medirIntegracao ? { [agendador_cpu_1.ENV_CONTAINERS_DA_LANE]: registro } : {}) };
509
+ const filho = (0, node_child_process_1.spawn)(bin, args, { cwd: item.cwd, env: ambiente, stdio: ['ignore', saida, erro],
144
510
  ...(comGrupo ? {} : { timeout: item.prazoMs, killSignal: 'SIGTERM' }) });
511
+ if (medirIntegracao) {
512
+ sonda = sondarCpuDaIntegracao(filho.pid, registro, cpus);
513
+ sonda.amostrar();
514
+ relogio = setInterval(sonda.amostrar, INTERVALO_CPU_MS);
515
+ }
145
516
  filho.on('error', (e) => encerrar({ nome: item.nome, code: null, signal: null,
146
517
  error: e.code ?? e.message, duracaoMs: Date.now() - inicio, comGrupo }));
147
518
  filho.on('close', (code, signal) => encerrar({ nome: item.nome, code, signal,
@@ -170,24 +541,25 @@ function aplicarPlano(originais, plano, sinalDaReserva) {
170
541
  const cpusContainer = String(plano.cpusPorContainer);
171
542
  ambiente(hermetica, { [agendador_cpu_1.ENV_CPUS_DA_LANE]: String(plano.cpusHermetica), [agendador_cpu_1.ENV_CPUS_DOCKER]: cpusContainer,
172
543
  ...(sinalDaReserva && varredura && integracoes.length > 0 ? {
544
+ [agendador_cpu_1.ENV_CPUS_DA_LANE_APOS_VARREDURA]: String(plano.cpusHermeticaAposVarredura),
173
545
  [agendador_cpu_1.ENV_CPUS_DA_LANE_APOS_RESERVA]: String(plano.cpusHermeticaAposReserva), [agendador_cpu_1.ENV_SINAL_DA_RESERVA]: sinalDaReserva,
174
546
  } : {}) });
175
547
  ambiente(varredura, {
176
548
  ORK_CHECK_VARREDURA_PARALELA: String(plano.ferramentasDaVarredura),
177
549
  [agendador_cpu_1.ENV_CPUS_DOCKER]: cpusContainer,
178
550
  });
179
- let anterior = varredura?.nome;
551
+ let anteriorIntegracao;
180
552
  for (const item of integracoes) {
181
553
  ambiente(item, { [agendador_cpu_1.ENV_CPUS_DA_LANE]: String(plano.cpusIntegracao), [agendador_cpu_1.ENV_CPUS_DOCKER]: cpusContainer });
182
- depender(item, [anterior]);
183
- anterior = item.nome;
554
+ depender(item, [anteriorIntegracao ?? (plano.integracaoEmParalelo ? undefined : varredura?.nome)]);
555
+ anteriorIntegracao = item.nome;
184
556
  }
185
557
  if (plano.saturado)
186
558
  depender(varredura, [hermetica?.nome]);
187
559
  // Comandos sem papel conhecido nao disputam CPU com as tres pecas: fecham a cadeia, um por vez.
188
560
  for (const item of outros) {
189
- depender(item, [hermetica?.nome, anterior]);
190
- anterior = item.nome;
561
+ depender(item, [hermetica?.nome, varredura?.nome, anteriorIntegracao]);
562
+ anteriorIntegracao = item.nome;
191
563
  }
192
564
  return itens;
193
565
  }