@aksp/opencrew 1.6.3 → 1.7.1
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 +86 -0
- package/README.md +50 -5
- package/package.json +1 -1
- package/templates/AGENTS.md +20 -6
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/architect.agent.yaml +1 -1
- package/templates/_opencrew/core/escritorio/animacao.js +64 -0
- package/templates/_opencrew/core/escritorio/app.js +137 -0
- package/templates/_opencrew/core/escritorio/cena.js +132 -0
- package/templates/_opencrew/core/escritorio/demo.js +79 -0
- package/templates/_opencrew/core/escritorio/escala.js +27 -0
- package/templates/_opencrew/core/escritorio/index.html +166 -0
- package/templates/_opencrew/core/escritorio/modelo-agentes.js +93 -0
- package/templates/_opencrew/core/escritorio/modelo-estado.js +71 -0
- package/templates/_opencrew/core/escritorio/modelo-mesas.js +81 -0
- package/templates/_opencrew/core/escritorio/modelo-pagina.js +95 -0
- package/templates/_opencrew/core/escritorio/modelo-textos.js +65 -0
- package/templates/_opencrew/core/escritorio/modelo-visao.js +91 -0
- package/templates/_opencrew/core/escritorio/modelo.js +29 -0
- package/templates/_opencrew/core/escritorio/painel.js +120 -0
- package/templates/_opencrew/core/escritorio/quadro.js +106 -0
- package/templates/_opencrew/core/escritorio/rota.js +62 -0
- package/templates/_opencrew/core/escritorio/rotulos.js +78 -0
- package/templates/_opencrew/core/escritorio/sprites-mesa.js +122 -0
- package/templates/_opencrew/core/escritorio/sprites-sala.js +92 -0
- package/templates/_opencrew/core/escritorio/sprites.js +187 -0
- package/templates/_opencrew/core/prompts/build.prompt.md +3 -3
- package/templates/_opencrew/core/prompts/discovery.prompt.md +1 -1
- package/templates/_opencrew/core/prompts/repair.prompt.md +7 -12
- package/templates/_opencrew/core/runner.pipeline.md +124 -223
- package/templates/_opencrew/core/scripts/caminho/argumentos.mjs +49 -0
- package/templates/_opencrew/core/scripts/caminho/disco.mjs +36 -0
- package/templates/_opencrew/core/scripts/caminho/nucleo.mjs +61 -0
- package/templates/_opencrew/core/scripts/caminho.mjs +124 -0
- package/templates/_opencrew/core/scripts/escritorio/leitura.mjs +31 -0
- package/templates/_opencrew/core/scripts/escritorio/porta.mjs +98 -0
- package/templates/_opencrew/core/scripts/escritorio/projeto.mjs +29 -0
- package/templates/_opencrew/core/scripts/escritorio/servidor.mjs +78 -0
- package/templates/_opencrew/core/scripts/escritorio.mjs +117 -0
- package/templates/_opencrew/core/scripts/estado/argumentos.mjs +61 -0
- package/templates/_opencrew/core/scripts/estado/arquivo.mjs +53 -0
- package/templates/_opencrew/core/scripts/estado/decisao.mjs +56 -0
- package/templates/_opencrew/core/scripts/estado/elenco.mjs +58 -0
- package/templates/_opencrew/core/scripts/estado/nucleo.mjs +113 -0
- package/templates/_opencrew/core/scripts/estado/preferencia.mjs +24 -0
- package/templates/_opencrew/core/scripts/estado.mjs +96 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Núcleo do `caminho.mjs`: as regras de caminho de uma execução, em funções puras. Não toca em
|
|
2
|
+
// disco nem em processo (quem lê pastas e arquivos é `disco.mjs`).
|
|
3
|
+
// Spec: fase-r3-runner-em-uso-real.md, regras 2 a 5 (repositório do OpenCrew).
|
|
4
|
+
|
|
5
|
+
export const ACOES = ['pasta', 'saida', 'entrada', 'conferir'];
|
|
6
|
+
|
|
7
|
+
/** Os motivos de `CAMINHO:REPROVADO`, na ordem em que são conferidos (§6 da spec). */
|
|
8
|
+
export const MOTIVO = {
|
|
9
|
+
ausente: 'arquivo ausente ou vazio',
|
|
10
|
+
secoes: (achadas, minimo) => `${achadas} seções, mínimo ${minimo}`,
|
|
11
|
+
tldr: 'falta a seção TL;DR',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Caminho como o script o compara e devolve: com `/`, sem `./` na frente e sem barra dobrada. */
|
|
15
|
+
export const normalizar = (caminho) => String(caminho).replace(/\\/g, '/').replace(/^(?:\.\/+)+/, '').replace(/\/{2,}/g, '/');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Regra 2 — o caminho da execução. Caminho declarado que começa por `crews/<crew>/output/` ganha
|
|
19
|
+
* `<run>/` logo depois de `output/`.
|
|
20
|
+
* @returns {{ grupo: string, nome: string } | null} o grupo (a pasta do arquivo, já com o run) e
|
|
21
|
+
* o nome do arquivo; `null` quando o caminho não é de `output/` (volta como veio)
|
|
22
|
+
*/
|
|
23
|
+
export function naExecucao(declarado, crew, run) {
|
|
24
|
+
const saida = `crews/${crew}/output/`;
|
|
25
|
+
const caminho = normalizar(declarado);
|
|
26
|
+
if (!caminho.startsWith(saida)) return null;
|
|
27
|
+
const partes = caminho.slice(saida.length).split('/');
|
|
28
|
+
const nome = partes.pop();
|
|
29
|
+
return { grupo: [`${saida}${run}`, ...partes].join('/'), nome };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** O número de uma pasta de versão (`v` + número), ou `null` para qualquer outro nome. */
|
|
33
|
+
function numero(nome) {
|
|
34
|
+
const [, digitos] = /^v(\d{1,9})$/.exec(nome) ?? [];
|
|
35
|
+
return digitos === undefined ? null : Number(digitos);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** As pastas de versão, da mais nova para a mais antiga, em ordem numérica (`v10` antes de `v9`). */
|
|
39
|
+
export function daMaisNova(nomes) {
|
|
40
|
+
return nomes.filter((nome) => numero(nome) !== null).sort((a, b) => numero(b) - numero(a));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Regra 3 — a versão em que o passo grava: a maior `vN` do grupo mais 1; sem nenhuma, `v1`. */
|
|
44
|
+
export function proximaVersao(nomes) {
|
|
45
|
+
const [maior] = daMaisNova(nomes);
|
|
46
|
+
return `v${maior ? numero(maior) + 1 : 1}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Regra 5 — o que falta no texto de um arquivo gravado, pelo primeiro motivo da §6.
|
|
51
|
+
* @param {string} texto o conteúdo do arquivo (que já existe e não está vazio)
|
|
52
|
+
* @param {{ secoes?: number|null, tldr?: boolean }} pedido
|
|
53
|
+
* @returns {string|null} o motivo, ou `null` quando o arquivo passa
|
|
54
|
+
*/
|
|
55
|
+
export function motivoDeReprovacao(texto, { secoes = null, tldr = false } = {}) {
|
|
56
|
+
const linhas = (texto.charCodeAt(0) === 0xfeff ? texto.slice(1) : texto).split(/\r?\n/);
|
|
57
|
+
const achadas = linhas.filter((linha) => linha.startsWith('## ')).length;
|
|
58
|
+
if (secoes !== null && achadas < secoes) return MOTIVO.secoes(achadas, secoes);
|
|
59
|
+
if (tldr && !linhas.some((linha) => linha.startsWith('## TL;DR'))) return MOTIVO.tldr;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Caminho da execução de uma crew: diz onde cada passo grava, de onde lê e se o arquivo gravado
|
|
3
|
+
// está lá. Quem calcula é este script, igual em qualquer sistema — a IA não monta o caminho.
|
|
4
|
+
// Uso (na pasta do projeto): node _opencrew/core/scripts/caminho.mjs <crew> <ação> --run <id> [opções]
|
|
5
|
+
// pasta --run <id> cria crews/<crew>/output/<id>/
|
|
6
|
+
// saida --run <id> --arquivo <declarado> onde o passo grava (abre a pasta de versão seguinte)
|
|
7
|
+
// entrada --run <id> --arquivo <declarado> a saída mais nova desse arquivo
|
|
8
|
+
// conferir --arquivo <caminho já resolvido> [--secoes N] [--tldr]
|
|
9
|
+
// <crew> é o nome da pasta em `crews/`. <declarado> é o `inputFile` ou `outputFile` do passo.
|
|
10
|
+
// Só cria pastas, e só dentro de crews/<crew>/output/<id>/; nunca cria, altera nem apaga arquivo.
|
|
11
|
+
// Última linha da saída (o runner lê esta linha): CAMINHO:OK <caminho>, CAMINHO:FALTA <caminho>
|
|
12
|
+
// ou CAMINHO:REPROVADO <motivo>; o caminho sai relativo à pasta do projeto, com `/`.
|
|
13
|
+
// Código de saída: 0 sempre que a linha CAMINHO: sai · 1 = erro de uso (ação ou opção faltando,
|
|
14
|
+
// pasta sem `_opencrew/`, crew inexistente ou fora do projeto); com código 1 não há linha
|
|
15
|
+
// CAMINHO: e nada é criado.
|
|
16
|
+
// Spec: fase-r3-runner-em-uso-real.md (repositório do OpenCrew).
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { MSG, dentroDoProjeto, ehPrincipal, realDentroDe } from './comum.mjs';
|
|
19
|
+
import { USO, erroDeArgumentos, lerArgs, limpar } from './caminho/argumentos.mjs';
|
|
20
|
+
import { MOTIVO, daMaisNova, motivoDeReprovacao, naExecucao, normalizar, proximaVersao } from './caminho/nucleo.mjs';
|
|
21
|
+
import { criarPasta, ehPasta, lerTexto, pastasDe, temConteudo } from './caminho/disco.mjs';
|
|
22
|
+
|
|
23
|
+
const ok = (caminho) => `CAMINHO:OK ${caminho}`;
|
|
24
|
+
const falta = (caminho) => `CAMINHO:FALTA ${caminho}`;
|
|
25
|
+
const reprovado = (motivo) => `CAMINHO:REPROVADO ${motivo}`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* O arquivo declarado cabe na execução? Dentro de `output/`, depois de ganhar o run, ele tem de
|
|
29
|
+
* continuar dentro da pasta da execução — pelo texto e pelo lugar real.
|
|
30
|
+
* @returns {string|null} o erro de uso, ou `null`
|
|
31
|
+
*/
|
|
32
|
+
function erroDoArquivo(raiz, crew, { acao, run, arquivo }) {
|
|
33
|
+
if (!dentroDoProjeto(raiz, arquivo)) return MSG.foraDoProjeto(limpar(arquivo));
|
|
34
|
+
const local = acao === 'conferir' ? null : naExecucao(arquivo, crew, run);
|
|
35
|
+
if (!local) return null;
|
|
36
|
+
if (!local.nome) return `Falta o nome do arquivo em --arquivo: ${limpar(arquivo)}`;
|
|
37
|
+
const execucao = path.resolve(raiz, 'crews', crew, 'output', run);
|
|
38
|
+
const partes = normalizar(arquivo).split('/');
|
|
39
|
+
const sai = partes.includes('..') || partes.includes('.') || !realDentroDe(execucao, path.resolve(raiz, local.grupo));
|
|
40
|
+
return sai ? `Caminho fora da pasta da execução: ${limpar(arquivo)}` : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Onde fica a crew, ou o erro de uso. A crew é uma pasta direta de `crews/` (`crews/<nome>`
|
|
45
|
+
* também vale).
|
|
46
|
+
* @returns {{ erro: string } | { crew: string }}
|
|
47
|
+
*/
|
|
48
|
+
function localizar(raiz, args) {
|
|
49
|
+
const erro = erroDeArgumentos(args);
|
|
50
|
+
if (erro) return { erro };
|
|
51
|
+
if (!ehPasta(path.join(raiz, '_opencrew'))) return { erro: MSG.semRaiz };
|
|
52
|
+
const base = path.resolve(raiz, 'crews');
|
|
53
|
+
const nome = args.crew.replace(/^crews[\\/]+/, '');
|
|
54
|
+
if (!dentroDoProjeto(base, nome)) return { erro: MSG.foraDoProjeto(limpar(args.crew)) };
|
|
55
|
+
const pasta = path.resolve(base, nome);
|
|
56
|
+
if (path.dirname(pasta) !== base || !ehPasta(pasta)) return { erro: MSG.crewNaoEncontrada(limpar(args.crew)) };
|
|
57
|
+
const crew = path.basename(pasta);
|
|
58
|
+
const doArquivo = args.arquivo ? erroDoArquivo(raiz, crew, args) : null;
|
|
59
|
+
return doArquivo ? { erro: doArquivo } : { crew };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Regras 2 e 3: o caminho em que o passo grava; a pasta dele é criada. */
|
|
63
|
+
function saida(raiz, crew, { run, arquivo }) {
|
|
64
|
+
const local = naExecucao(arquivo, crew, run);
|
|
65
|
+
if (!local) return ok(normalizar(arquivo));
|
|
66
|
+
const pasta = `${local.grupo}/${proximaVersao(pastasDe(path.resolve(raiz, local.grupo)))}`;
|
|
67
|
+
criarPasta(path.resolve(raiz, pasta));
|
|
68
|
+
return ok(`${pasta}/${local.nome}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Regra 4: a versão mais nova que tem o arquivo; depois, o próprio grupo, sem pasta de versão. */
|
|
72
|
+
function entrada(raiz, crew, { run, arquivo }) {
|
|
73
|
+
const local = naExecucao(arquivo, crew, run);
|
|
74
|
+
const { grupo, nome } = local ?? { grupo: path.posix.dirname(normalizar(arquivo)), nome: path.posix.basename(normalizar(arquivo)) };
|
|
75
|
+
const versoes = local ? daMaisNova(pastasDe(path.resolve(raiz, grupo))) : [];
|
|
76
|
+
const candidatos = [...versoes.map((versao) => `${grupo}/${versao}/${nome}`), local ? `${grupo}/${nome}` : normalizar(arquivo)];
|
|
77
|
+
const achado = candidatos.find((caminho) => temConteudo(path.resolve(raiz, caminho)));
|
|
78
|
+
return achado ? ok(achado) : falta(candidatos.at(-1));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Regra 5: o arquivo gravado existe, não está vazio e tem o que o contrato do passo pede. */
|
|
82
|
+
function conferir(raiz, { arquivo, secoes, tldr }) {
|
|
83
|
+
const caminho = normalizar(arquivo);
|
|
84
|
+
const alvo = path.resolve(raiz, caminho);
|
|
85
|
+
if (!temConteudo(alvo)) return reprovado(MOTIVO.ausente);
|
|
86
|
+
const pedido = { secoes: secoes === undefined ? null : Number(secoes), tldr };
|
|
87
|
+
const motivo = pedido.secoes !== null || tldr ? motivoDeReprovacao(lerTexto(alvo), pedido) : null;
|
|
88
|
+
return motivo ? reprovado(motivo) : ok(caminho);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function responder(raiz, crew, args) {
|
|
92
|
+
if (args.acao === 'saida') return saida(raiz, crew, args);
|
|
93
|
+
if (args.acao === 'entrada') return entrada(raiz, crew, args);
|
|
94
|
+
if (args.acao === 'conferir') return conferir(raiz, args);
|
|
95
|
+
const pasta = `crews/${crew}/output/${args.run}`;
|
|
96
|
+
criarPasta(path.resolve(raiz, pasta));
|
|
97
|
+
return ok(pasta);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string[]} argv
|
|
102
|
+
* @param {object} [deps] `cwd` (a pasta do projeto) e `escrever`
|
|
103
|
+
* @returns {number} 0 = a linha `CAMINHO:` saiu · 1 = erro de uso, ou falha ao ler ou criar pasta
|
|
104
|
+
* (a linha de uso e o motivo, ou só o erro; sem linha `CAMINHO:`)
|
|
105
|
+
*/
|
|
106
|
+
export function main(argv, deps = {}) {
|
|
107
|
+
const { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = deps;
|
|
108
|
+
const args = lerArgs(argv);
|
|
109
|
+
const local = localizar(cwd, args);
|
|
110
|
+
if (local.erro) {
|
|
111
|
+
escrever(USO);
|
|
112
|
+
escrever(local.erro);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
escrever(responder(cwd, local.crew, args));
|
|
117
|
+
return 0;
|
|
118
|
+
} catch (erro) {
|
|
119
|
+
escrever(`Não consegui resolver o caminho: ${limpar(erro?.message ?? erro)}`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (ehPrincipal(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// O que o `/estado` do escritório devolve: o estado de cada crew, lido do disco a cada pedido.
|
|
2
|
+
// Só lê; quem grava `crews/<crew>/state.json` é o `estado.mjs`.
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const MARCA_DE_ORDEM = 0xfeff; // alguns editores e terminais gravam essa marca no início do arquivo
|
|
7
|
+
|
|
8
|
+
/** O estado de uma crew, ou null: arquivo ausente, pela metade, inválido ou sem `agents` em lista. */
|
|
9
|
+
async function lerCrew(raiz, crew) {
|
|
10
|
+
try {
|
|
11
|
+
const texto = await fs.readFile(path.join(raiz, 'crews', crew, 'state.json'), 'utf8');
|
|
12
|
+
const estado = JSON.parse(texto.charCodeAt(0) === MARCA_DE_ORDEM ? texto.slice(1) : texto);
|
|
13
|
+
return Array.isArray(estado?.agents) ? { crew, estado } : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `updatedAt` em milissegundos; ausente ou ilegível vale 0 (a crew vai para o fim). */
|
|
20
|
+
const atualizadaEm = ({ estado }) => Date.parse(estado.updatedAt) || 0;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* As crews de `crews/` que têm estado legível, a de `updatedAt` mais recente primeiro. Os nomes
|
|
24
|
+
* vêm da pasta, nunca do pedido. Sem a pasta `crews/`, ou sem nenhum estado: lista vazia.
|
|
25
|
+
* @returns {Promise<Array<{ crew: string, estado: object }>>}
|
|
26
|
+
*/
|
|
27
|
+
export async function lerCrews(raiz) {
|
|
28
|
+
const nomes = await fs.readdir(path.join(raiz, 'crews')).catch(() => []);
|
|
29
|
+
const lidas = await Promise.all(nomes.sort().map((nome) => lerCrew(raiz, nome)));
|
|
30
|
+
return lidas.filter(Boolean).sort((a, b) => atualizadaEm(b) - atualizadaEm(a));
|
|
31
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// A porta do escritório: abrir, perguntar quem está numa porta ocupada e procurar a que serve.
|
|
2
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regra 15 (repositório do OpenCrew).
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
|
|
6
|
+
const LOCAL = '127.0.0.1';
|
|
7
|
+
const ULTIMA_PORTA = 65535;
|
|
8
|
+
// Quem responde na porta pode ser qualquer serviço: a resposta não é guardada sem limite.
|
|
9
|
+
const TETO_DA_SONDA = 1_000_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Alguém aceita conexão em 127.0.0.1:porta? No Windows, o `listen` em 127.0.0.1 abre por cima de
|
|
13
|
+
* quem escuta a mesma porta em 0.0.0.0 ou `::` e toma o tráfego local dele: só a conexão mostra
|
|
14
|
+
* que a porta tem dono. Porta livre recusa na hora; sem resposta dentro da espera, conta como livre.
|
|
15
|
+
*/
|
|
16
|
+
function alguemEscuta(porta, esperaMs = 1000) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const conexao = net.connect({ host: LOCAL, port: porta });
|
|
19
|
+
const fim = (escuta) => { conexao.destroy(); resolve(escuta); };
|
|
20
|
+
conexao.setTimeout(esperaMs, () => fim(false));
|
|
21
|
+
conexao.once('connect', () => fim(true)).once('error', () => fim(false));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Abre o servidor na porta, preso em 127.0.0.1.
|
|
27
|
+
* @returns {Promise<boolean>} true se subiu; false se a porta não pôde ser aberta (ocupada, por
|
|
28
|
+
* quem quer que seja e em qualquer endereço, ou reservada pelo sistema). O mesmo servidor pode
|
|
29
|
+
* tentar outra porta em seguida.
|
|
30
|
+
*/
|
|
31
|
+
export async function abrir(servidor, porta) {
|
|
32
|
+
if (porta !== 0 && await alguemEscuta(porta)) return false;
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const subiu = () => { servidor.off('error', falhou); resolve(true); };
|
|
35
|
+
const falhou = () => { servidor.off('listening', subiu); resolve(false); };
|
|
36
|
+
servidor.once('listening', subiu).once('error', falhou).listen(porta, LOCAL);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** O `projeto` de uma resposta do `/estado`, ou null quando a resposta não é a de um escritório. */
|
|
41
|
+
function projetoDaResposta(corpo) {
|
|
42
|
+
try {
|
|
43
|
+
const { projeto } = JSON.parse(corpo) ?? {};
|
|
44
|
+
return typeof projeto === 'string' ? projeto : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Junta o corpo da resposta e o entrega inteiro; cortada no meio ou acima do teto, entrega null. */
|
|
51
|
+
function juntar(res, entregar) {
|
|
52
|
+
let corpo = '';
|
|
53
|
+
res.setEncoding('utf8');
|
|
54
|
+
res.on('data', (parte) => {
|
|
55
|
+
corpo += parte;
|
|
56
|
+
if (corpo.length > TETO_DA_SONDA) entregar(null);
|
|
57
|
+
});
|
|
58
|
+
res.on('end', () => entregar(corpo));
|
|
59
|
+
res.on('error', () => entregar(null));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pergunta quem está na porta: `GET /estado`, com espera de até 1 s.
|
|
64
|
+
* @returns {Promise<string|null>} o `projeto` do escritório que respondeu; null para qualquer
|
|
65
|
+
* outra resposta (outro serviço, erro, resposta grande demais) ou nenhuma dentro da espera
|
|
66
|
+
*/
|
|
67
|
+
export function sondar(porta, esperaMs = 1000) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const pedido = http.get({ host: LOCAL, port: porta, path: '/estado', agent: false }, (res) => juntar(res, fim));
|
|
70
|
+
const relogio = setTimeout(() => fim(null), esperaMs);
|
|
71
|
+
pedido.on('error', () => fim(null));
|
|
72
|
+
function fim(corpo) {
|
|
73
|
+
clearTimeout(relogio);
|
|
74
|
+
pedido.destroy();
|
|
75
|
+
resolve(corpo === null ? null : projetoDaResposta(corpo));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Procura a porta do escritório, de `inicial` em diante, em até `tentativas` portas. Porta que
|
|
82
|
+
* não abre é sondada: se quem responde é o escritório deste projeto, não sobe outro.
|
|
83
|
+
* @param {object} o
|
|
84
|
+
* @param {number} o.inicial primeira porta
|
|
85
|
+
* @param {string} o.projeto id deste projeto
|
|
86
|
+
* @param {(porta: number) => Promise<boolean>} o.abrir tenta abrir o servidor na porta
|
|
87
|
+
* @param {(porta: number) => Promise<string|null>} o.sondar o `projeto` de quem está na porta
|
|
88
|
+
* @returns {Promise<{ tipo: 'aberto'|'ja-aberto'|'sem-porta', porta?: number, ultima: number }>}
|
|
89
|
+
* `ultima` é a última porta do intervalo (a que entra na mensagem "sem porta")
|
|
90
|
+
*/
|
|
91
|
+
export async function procurar({ inicial, projeto, abrir: tentar, sondar: perguntar, tentativas = 10 }) {
|
|
92
|
+
const ultima = Math.min(inicial + tentativas - 1, ULTIMA_PORTA);
|
|
93
|
+
for (let porta = inicial; porta <= ultima; porta++) {
|
|
94
|
+
if (await tentar(porta)) return { tipo: 'aberto', porta, ultima };
|
|
95
|
+
if ((await perguntar(porta)) === projeto) return { tipo: 'ja-aberto', porta, ultima };
|
|
96
|
+
}
|
|
97
|
+
return { tipo: 'sem-porta', ultima };
|
|
98
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Identidade do projeto para o escritório: diz se o servidor que responde numa porta é o deste
|
|
2
|
+
// projeto, sem expor o caminho da pasta.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 12 caracteres hexadecimais do SHA-256 do caminho real da raiz. No Windows o caminho vai para
|
|
9
|
+
* minúsculas antes: lá `D:\Projeto` e `d:\projeto` são a mesma pasta. Função pura.
|
|
10
|
+
*/
|
|
11
|
+
export function idDoProjeto(caminhoReal, plataforma = process.platform) {
|
|
12
|
+
const texto = plataforma === 'win32' ? caminhoReal.toLowerCase() : caminhoReal;
|
|
13
|
+
return createHash('sha256').update(texto).digest('hex').slice(0, 12);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Caminho real: o mesmo projeto aberto por um link de pasta dá o mesmo resultado. Onde o sistema
|
|
18
|
+
* não resolve (alguns discos virtuais), vale o caminho absoluto.
|
|
19
|
+
*/
|
|
20
|
+
function caminhoReal(raiz) {
|
|
21
|
+
try {
|
|
22
|
+
return realpathSync.native(raiz);
|
|
23
|
+
} catch {
|
|
24
|
+
return path.resolve(raiz);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** O id do projeto que mora em `raiz`. */
|
|
29
|
+
export const projetoDe = (raiz) => idDoProjeto(caminhoReal(raiz));
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Servidor do escritório: só leitura, só neste computador, com rotas fechadas.
|
|
2
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 13 a 15 (repositório do OpenCrew).
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { lerCrews } from './leitura.mjs';
|
|
7
|
+
import { projetoDe } from './projeto.mjs';
|
|
8
|
+
|
|
9
|
+
// Vão em TODA resposta. Nenhuma resposta leva `Access-Control-*`: outra origem não lê o estado.
|
|
10
|
+
const CABECALHOS = {
|
|
11
|
+
'Cache-Control': 'no-store',
|
|
12
|
+
'X-Content-Type-Options': 'nosniff',
|
|
13
|
+
'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'",
|
|
14
|
+
};
|
|
15
|
+
const TEXTO = 'text/plain; charset=utf-8';
|
|
16
|
+
const JSON_UTF8 = 'application/json; charset=utf-8';
|
|
17
|
+
const TIPOS = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
|
|
18
|
+
const SEM_TIPO = 'application/octet-stream';
|
|
19
|
+
|
|
20
|
+
const MSG_HOST = (porta) => `Abra por http://127.0.0.1:${porta}`;
|
|
21
|
+
|
|
22
|
+
function responder(res, status, { corpo, tipo = TEXTO, extras = {} }) {
|
|
23
|
+
res.writeHead(status, { ...CABECALHOS, 'Content-Type': tipo, 'Content-Length': Buffer.byteLength(corpo), ...extras });
|
|
24
|
+
res.end(corpo);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* O pedido veio para este endereço? Uma página de fora que aponte um nome dela para 127.0.0.1
|
|
29
|
+
* chega com o nome dela no `Host`; só valem `127.0.0.1` e `localhost`, com a porta real.
|
|
30
|
+
*/
|
|
31
|
+
function hostAceito(req) {
|
|
32
|
+
const porta = req.socket.localPort;
|
|
33
|
+
const host = String(req.headers.host ?? '').toLowerCase();
|
|
34
|
+
return host === `127.0.0.1:${porta}` || host === `localhost:${porta}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Arquivo da página pelo nome da lista; null quando o caminho não é rota ou o arquivo falta. */
|
|
38
|
+
async function lerArquivo(ctx, caminho) {
|
|
39
|
+
const nome = ctx.rotas.get(caminho);
|
|
40
|
+
if (!nome) return null;
|
|
41
|
+
const corpo = await fs.readFile(path.join(ctx.pasta, nome)).catch(() => null);
|
|
42
|
+
return corpo && { corpo, tipo: TIPOS[path.extname(nome)] ?? SEM_TIPO };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function atender(req, res, ctx) {
|
|
46
|
+
if (!hostAceito(req)) return responder(res, 421, { corpo: MSG_HOST(req.socket.localPort) });
|
|
47
|
+
if (req.method !== 'GET') return responder(res, 405, { corpo: 'Método não permitido', extras: { Allow: 'GET' } });
|
|
48
|
+
const caminho = req.url.split('?')[0];
|
|
49
|
+
if (caminho === '/estado') {
|
|
50
|
+
const corpo = JSON.stringify({ projeto: ctx.projeto, crews: await lerCrews(ctx.raiz) });
|
|
51
|
+
return responder(res, 200, { corpo, tipo: JSON_UTF8 });
|
|
52
|
+
}
|
|
53
|
+
const arquivo = await lerArquivo(ctx, caminho);
|
|
54
|
+
return arquivo ? responder(res, 200, arquivo) : responder(res, 404, { corpo: 'Não encontrado' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function falhar(res) {
|
|
58
|
+
if (res.headersSent) res.destroy();
|
|
59
|
+
else responder(res, 500, { corpo: 'Erro interno' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cria o servidor (sem abrir a porta: quem abre é `abrir`, em `porta.mjs`).
|
|
64
|
+
* @param {object} o
|
|
65
|
+
* @param {string} o.raiz pasta do projeto (a que tem `_opencrew/` e `crews/`)
|
|
66
|
+
* @param {string} o.pasta pasta da página (`_opencrew/core/escritorio/`)
|
|
67
|
+
* @param {string[]} o.arquivos nomes servidos dessa pasta. É uma lista fixa: o caminho do pedido
|
|
68
|
+
* é comparado com ela e nunca é juntado a uma pasta, e a pasta nunca é lida para montar rota
|
|
69
|
+
* @returns {import('node:http').Server}
|
|
70
|
+
*/
|
|
71
|
+
export function criarServidor({ raiz, pasta, arquivos }) {
|
|
72
|
+
const rotas = new Map([['/', 'index.html'], ...arquivos.map((nome) => [`/${nome}`, nome])]);
|
|
73
|
+
const ctx = { raiz, pasta, rotas, projeto: projetoDe(raiz) };
|
|
74
|
+
// Sem `Host` o pedido também chega aqui e leva o 421 com os cabeçalhos, e não o 400 do Node.
|
|
75
|
+
return http.createServer({ requireHostHeader: false }, (req, res) => {
|
|
76
|
+
atender(req, res, ctx).catch(() => falhar(res));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Escritório do OpenCrew — servidor local da página que mostra a equipe trabalhando.
|
|
3
|
+
// Uso: node _opencrew/core/scripts/escritorio.mjs [--porta <1024 a 65535>]
|
|
4
|
+
// Rode na pasta do projeto (a que contém `_opencrew/`). Sem --porta, começa na 4747.
|
|
5
|
+
// Só leitura e só neste computador: escuta em 127.0.0.1, aceita só GET e não escreve em disco.
|
|
6
|
+
// Serve a página (`_opencrew/core/escritorio/`) e `GET /estado`, que lê `crews/*/state.json` a
|
|
7
|
+
// cada pedido.
|
|
8
|
+
// Porta ocupada: se quem responde nela é o escritório deste mesmo projeto, avisa e sai com 0, sem
|
|
9
|
+
// subir outro; senão tenta a seguinte, até 10 portas ao todo.
|
|
10
|
+
// Código de saída: 0 = escritório aberto (o processo segue rodando até Ctrl+C) ou já aberto ·
|
|
11
|
+
// 1 = erro de uso (porta inválida, pasta sem `_opencrew/`) ou nenhuma das 10 portas livre.
|
|
12
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 13 a 15 (repositório do OpenCrew).
|
|
13
|
+
import { existsSync, statSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { ehPrincipal } from './comum.mjs';
|
|
17
|
+
import { criarServidor } from './escritorio/servidor.mjs';
|
|
18
|
+
import { abrir, sondar, procurar } from './escritorio/porta.mjs';
|
|
19
|
+
import { projetoDe } from './escritorio/projeto.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Os arquivos da página, um a um: é a lista inteira do que o servidor entrega além de `/estado`.
|
|
23
|
+
* Arquivo novo em `_opencrew/core/escritorio/` só é servido depois de entrar aqui.
|
|
24
|
+
*/
|
|
25
|
+
export const ARQUIVOS = [
|
|
26
|
+
'index.html', 'app.js', 'painel.js', 'rotulos.js', 'cena.js',
|
|
27
|
+
'sprites.js', 'sprites-sala.js', 'sprites-mesa.js',
|
|
28
|
+
'modelo.js', 'modelo-mesas.js', 'modelo-textos.js', 'modelo-estado.js', 'modelo-agentes.js',
|
|
29
|
+
'modelo-visao.js', 'modelo-pagina.js', 'demo.js',
|
|
30
|
+
'escala.js', 'rota.js', 'animacao.js', 'quadro.js',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const PORTA_PADRAO = 4747;
|
|
34
|
+
const PORTA_MINIMA = 1024;
|
|
35
|
+
const PORTA_MAXIMA = 65535;
|
|
36
|
+
|
|
37
|
+
// Instalado, este script fica em `_opencrew/core/scripts/` e a página em `_opencrew/core/escritorio/`.
|
|
38
|
+
const PASTA_DA_PAGINA = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'escritorio');
|
|
39
|
+
|
|
40
|
+
const endereco = (porta) => `http://127.0.0.1:${porta}`;
|
|
41
|
+
|
|
42
|
+
const USO = `Uso: node _opencrew/core/scripts/escritorio.mjs [--porta <${PORTA_MINIMA} a ${PORTA_MAXIMA}>]`;
|
|
43
|
+
const MSG = {
|
|
44
|
+
aberto: (porta) => `Escritório aberto em ${endereco(porta)} — Ctrl+C para fechar`,
|
|
45
|
+
jaAberto: (porta) => `O escritório já está aberto em ${endereco(porta)}`,
|
|
46
|
+
semPorta: (a, b) => `Não foi possível abrir o escritório: as portas ${a} a ${b} estão ocupadas. Use --porta.`,
|
|
47
|
+
foraDaRaiz: 'Rode este comando na pasta do projeto (a que contém _opencrew/).',
|
|
48
|
+
portaInvalida: `Porta inválida: use --porta com um número de ${PORTA_MINIMA} a ${PORTA_MAXIMA}.`,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const ehPasta = (p) => existsSync(p) && statSync(p).isDirectory();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A porta pedida em `--porta N` ou `--porta=N` (a última vale); sem a opção, a padrão.
|
|
55
|
+
* @returns {number|null} null quando o valor não é um inteiro de 1024 a 65535
|
|
56
|
+
*/
|
|
57
|
+
function lerPorta(argv) {
|
|
58
|
+
let porta = PORTA_PADRAO;
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
const opcao = argv[i].match(/^--porta(?:=(.*))?$/s);
|
|
61
|
+
if (!opcao) continue;
|
|
62
|
+
const valor = opcao[1] ?? argv[++i] ?? '';
|
|
63
|
+
const numero = /^\d{1,5}$/.test(valor) ? Number(valor) : 0;
|
|
64
|
+
if (numero < PORTA_MINIMA || numero > PORTA_MAXIMA) return null;
|
|
65
|
+
porta = numero;
|
|
66
|
+
}
|
|
67
|
+
return porta;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Erro de uso, na ordem: porta inválida → pasta atual sem `_opencrew/`.
|
|
72
|
+
* @returns {string[]|null} as linhas em PT-BR, ou null quando está tudo certo
|
|
73
|
+
*/
|
|
74
|
+
function erroDeUso(porta, cwd) {
|
|
75
|
+
if (porta === null) return [MSG.portaInvalida, USO];
|
|
76
|
+
if (!ehPasta(path.join(cwd, '_opencrew'))) return [MSG.foraDaRaiz];
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Sobe o escritório do projeto que mora em `cwd`, ou reconhece o que já está aberto.
|
|
82
|
+
* @param {string[]} argv argumentos da linha de comando
|
|
83
|
+
* @param {object} [o]
|
|
84
|
+
* @param {string} [o.cwd] pasta do projeto
|
|
85
|
+
* @param {(linha: string) => void} [o.escrever] recebe cada linha da saída
|
|
86
|
+
* @param {string} [o.pasta] pasta da página
|
|
87
|
+
* @param {string[]} [o.arquivos] nomes servidos da pasta da página
|
|
88
|
+
* @param {(servidor: object, porta: number) => Promise<boolean>} [o.abrir] SÓ PARA TESTE: tenta
|
|
89
|
+
* abrir a porta (serve para simular portas ocupadas sem ocupá-las)
|
|
90
|
+
* @param {(porta: number) => Promise<string|null>} [o.sondar] SÓ PARA TESTE: o `projeto` de quem
|
|
91
|
+
* responde na porta
|
|
92
|
+
* @returns {Promise<{ code: number, porta?: number, servidor?: import('node:http').Server }>}
|
|
93
|
+
* `code` 0 com `porta` (escritório aberto ou já aberto) ou 1; `servidor` só quando este
|
|
94
|
+
* comando abriu um — é ele que mantém o processo vivo
|
|
95
|
+
*/
|
|
96
|
+
export async function main(argv, o = {}) {
|
|
97
|
+
const { cwd = process.cwd(), escrever = (linha) => process.stdout.write(`${linha}\n`), pasta = PASTA_DA_PAGINA, arquivos = ARQUIVOS } = o;
|
|
98
|
+
const inicial = lerPorta(argv);
|
|
99
|
+
const erro = erroDeUso(inicial, cwd);
|
|
100
|
+
if (erro) {
|
|
101
|
+
erro.forEach((linha) => escrever(linha));
|
|
102
|
+
return { code: 1 };
|
|
103
|
+
}
|
|
104
|
+
const servidor = criarServidor({ raiz: cwd, pasta, arquivos });
|
|
105
|
+
const tentar = (porta) => (o.abrir ?? abrir)(servidor, porta);
|
|
106
|
+
const r = await procurar({ inicial, projeto: projetoDe(cwd), abrir: tentar, sondar: o.sondar ?? sondar });
|
|
107
|
+
if (r.tipo === 'sem-porta') {
|
|
108
|
+
escrever(MSG.semPorta(inicial, r.ultima));
|
|
109
|
+
return { code: 1 };
|
|
110
|
+
}
|
|
111
|
+
escrever(r.tipo === 'aberto' ? MSG.aberto(r.porta) : MSG.jaAberto(r.porta));
|
|
112
|
+
return r.tipo === 'aberto' ? { code: 0, porta: r.porta, servidor } : { code: 0, porta: r.porta };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (ehPrincipal(import.meta.url)) {
|
|
116
|
+
main(process.argv.slice(2)).then(({ code }) => { process.exitCode = code; });
|
|
117
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Linha de comando do `estado.mjs`: a crew, o evento, as opções e a linha de uso.
|
|
2
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, §3 e regra 7 (repositório do OpenCrew).
|
|
3
|
+
|
|
4
|
+
export const USO = 'Uso: node _opencrew/core/scripts/estado.mjs <crew> <evento> [opções]';
|
|
5
|
+
|
|
6
|
+
/** Teto, em caracteres visíveis, de `--rotulo`, `--mensagem` e `--motivo`. */
|
|
7
|
+
export const TETO = 120;
|
|
8
|
+
|
|
9
|
+
const OPCAO = /^--(passos|n|agente|rotulo|mensagem|motivo)(?:=(.*))?$/s;
|
|
10
|
+
const QUEBRA = /[\r\n\v\f\u0085\u2028\u2029]+/g;
|
|
11
|
+
const segmentador = new Intl.Segmenter('pt', { granularity: 'grapheme' });
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Texto recebido é dado: fica numa linha (quebra de linha vira espaço) e com até 120 caracteres.
|
|
15
|
+
* O corte conta caracteres visíveis, por isso nunca parte um emoji ao meio. O laço para no 121º
|
|
16
|
+
* segmento: no Node 20 cada segmento guarda uma cópia da entrada, e guardar todos custaria caro.
|
|
17
|
+
*/
|
|
18
|
+
export function limparTexto(valor) {
|
|
19
|
+
const linha = String(valor).replace(QUEBRA, ' ').trim();
|
|
20
|
+
let fim = 0;
|
|
21
|
+
let visiveis = 0;
|
|
22
|
+
for (const { index, segment } of segmentador.segment(linha)) {
|
|
23
|
+
if (visiveis++ === TETO) break;
|
|
24
|
+
fim = index + segment.length;
|
|
25
|
+
}
|
|
26
|
+
return linha.slice(0, fim);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Inteiro a partir de 1; qualquer outra coisa é `null` (a opção não vale). */
|
|
30
|
+
const inteiro = (valor) => (/^\d{1,9}$/.test(valor ?? '') && Number(valor) >= 1 ? Number(valor) : null);
|
|
31
|
+
|
|
32
|
+
/** As opções como o núcleo as recebe: ausente ou inválida é `null` (número, agente) ou vazio (texto). */
|
|
33
|
+
function converter(cru) {
|
|
34
|
+
return {
|
|
35
|
+
passos: inteiro(cru.passos),
|
|
36
|
+
n: inteiro(cru.n),
|
|
37
|
+
agente: limparTexto(cru.agente ?? '') || null,
|
|
38
|
+
rotulo: limparTexto(cru.rotulo ?? ''),
|
|
39
|
+
mensagem: limparTexto(cru.mensagem ?? ''),
|
|
40
|
+
motivo: limparTexto(cru.motivo ?? ''),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `argv` → `{ crew, evento, opcoes: { passos, n, agente, rotulo, mensagem, motivo } }`. Os dois
|
|
46
|
+
* primeiros argumentos soltos são a crew e o evento (indefinidos, se faltam). Opção vale como
|
|
47
|
+
* `--nome valor` e `--nome=valor`; opção sem valor (a última, ou seguida de outra opção) não
|
|
48
|
+
* consome o argumento seguinte.
|
|
49
|
+
*/
|
|
50
|
+
export function lerArgs(argv) {
|
|
51
|
+
const soltos = [];
|
|
52
|
+
const cru = {};
|
|
53
|
+
for (let i = 0; i < argv.length; i++) {
|
|
54
|
+
const [, nome, colado] = argv[i].match(OPCAO) ?? [];
|
|
55
|
+
if (!nome) soltos.push(argv[i]);
|
|
56
|
+
else if (colado !== undefined) cru[nome] = colado;
|
|
57
|
+
else if (i + 1 < argv.length && !OPCAO.test(argv[i + 1])) cru[nome] = argv[++i];
|
|
58
|
+
}
|
|
59
|
+
const [crew, evento] = soltos.filter((s) => !s.startsWith('--'));
|
|
60
|
+
return { crew, evento, opcoes: converter(cru) };
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Leitura e gravação de `crews/<crew>/state.json`: inteira ou nenhuma.
|
|
2
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 3 e 6 (repositório do OpenCrew).
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/** O Windows recusa a troca de nome com um destes códigos enquanto outro processo lê o arquivo. */
|
|
6
|
+
const RECUSAS = ['EPERM', 'EBUSY', 'EACCES'];
|
|
7
|
+
/** Depois da primeira tentativa: até 3 repetições, uma a cada 100 ms (cerca de 300 ms ao todo). */
|
|
8
|
+
const REPETICOES = 3;
|
|
9
|
+
const INTERVALO_MS = 100;
|
|
10
|
+
|
|
11
|
+
const pausa = (ms) => new Promise((seguir) => { setTimeout(seguir, ms); });
|
|
12
|
+
|
|
13
|
+
/** O estado gravado, ou `null` quando o arquivo falta ou está ilegível (pela metade, sem `agents` em lista). */
|
|
14
|
+
export async function lerEstado(arquivo) {
|
|
15
|
+
try {
|
|
16
|
+
const estado = JSON.parse((await fs.readFile(arquivo, 'utf8')).replace(/^\uFEFF/, ''));
|
|
17
|
+
return Array.isArray(estado?.agents) ? estado : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function trocarNome(de, para, { renomear = fs.rename, esperar = pausa }) {
|
|
24
|
+
for (let repeticao = 0; ; repeticao++) {
|
|
25
|
+
try {
|
|
26
|
+
return await renomear(de, para);
|
|
27
|
+
} catch (erro) {
|
|
28
|
+
if (!RECUSAS.includes(erro?.code) || repeticao === REPETICOES) throw erro;
|
|
29
|
+
await esperar(INTERVALO_MS);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Grava o estado num temporário da mesma pasta (`state.json.<pid>.tmp`) e troca o nome: quem lê
|
|
36
|
+
* vê o arquivo antigo ou o novo, nunca um pela metade.
|
|
37
|
+
* @param {string} arquivo caminho do `state.json`
|
|
38
|
+
* @param {object} estado
|
|
39
|
+
* @param {{ renomear?: Function, esperar?: Function }} [deps] SÓ PARA TESTE: a troca de nome
|
|
40
|
+
* (`(de, para) => Promise`) e a espera entre as tentativas (`(ms) => Promise`)
|
|
41
|
+
* @returns {Promise<boolean>} `false` quando desistiu: o arquivo fica como estava e o temporário é apagado
|
|
42
|
+
*/
|
|
43
|
+
export async function gravarEstado(arquivo, estado, deps = {}) {
|
|
44
|
+
const temporario = `${arquivo}.${process.pid}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
await fs.writeFile(temporario, `${JSON.stringify(estado, null, 2)}\n`);
|
|
47
|
+
await trocarNome(temporario, arquivo, deps);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
await fs.rm(temporario, { force: true }).catch(() => {});
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|