suite-timesheet-mcp 1.0.0
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/CONTRACT.md +143 -0
- package/LICENSE +99 -0
- package/README.md +202 -0
- package/SECURITY.md +80 -0
- package/lib/autoarranque.js +90 -0
- package/lib/cliente.js +30 -0
- package/lib/contexto.js +14 -0
- package/lib/ferramentas.js +188 -0
- package/lib/fila.js +193 -0
- package/lib/rotas.js +148 -0
- package/lib/validar.js +57 -0
- package/mcp.js +45 -0
- package/package.json +47 -0
- package/serve.js +182 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Puro (cliente injetado). As seis tools do MCP e a sua execução.
|
|
2
|
+
|
|
3
|
+
import { validarProposta } from './validar.js';
|
|
4
|
+
|
|
5
|
+
const AVISO = 'Opera só no mês que a Folha de Horas mostra no Chrome. Nunca submete o mês, nunca muda o mês.';
|
|
6
|
+
const WAIT_MAX_S = 290;
|
|
7
|
+
// O cliente por defeito do SDK do MCP corta a chamada aos 60 s; 50 dá margem para a
|
|
8
|
+
// tool responder antes disso. timeout_s continua a poder ser pedido até WAIT_MAX_S (o
|
|
9
|
+
// Claude Code permite subir o timeout do cliente MCP quando isso for preciso).
|
|
10
|
+
const TIMEOUT_TOOL_S = 50;
|
|
11
|
+
// aplicar escreve linha a linha no Suite: pode demorar bem mais do que as outras tools.
|
|
12
|
+
const TIMEOUT_APLICAR_S = 120;
|
|
13
|
+
|
|
14
|
+
export const FERRAMENTAS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'estado',
|
|
17
|
+
description: `Estado da ponte com o Chrome: se a Folha de Horas está aberta, que mês mostra, quantos projetos tem e se há um lote a correr. ${AVISO}`,
|
|
18
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'projetos',
|
|
22
|
+
description: `Projetos do dropdown da Folha de Horas, com option_id, nome e se a linha está trancada (aprovada). ${AVISO}`,
|
|
23
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'ler_mes',
|
|
27
|
+
description: `Lê as horas já lançadas no mês visível: uma linha por projeto com os dias preenchidos, o status e os totais por dia. ${AVISO}`,
|
|
28
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'propor',
|
|
32
|
+
description: `Propõe lançamentos para o mês visível. A extensão calcula o plano (criar/atualizar/apagar), abre o painel com a pré-visualização e devolve-a aqui; nada é escrito até o utilizador clicar Aplicar no painel. Depois chama "resultado" com o id. Com espelho: true, as linhas do Suite que não vierem na proposta são apagadas. ${AVISO}`,
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: ['linhas'],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
linhas: {
|
|
39
|
+
type: 'array',
|
|
40
|
+
description: 'Uma entrada por projeto e dia. Várias entradas do mesmo projeto no mesmo dia são somadas.',
|
|
41
|
+
items: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
required: ['date', 'hours'],
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
properties: {
|
|
46
|
+
option_id: { type: 'integer', description: 'id do projeto no dropdown (usa "projetos"). Exclusivo com project.' },
|
|
47
|
+
project: { type: 'string', description: 'nome do projeto como aparece no dropdown. Exclusivo com option_id.' },
|
|
48
|
+
date: { type: 'string', description: 'YYYY-MM-DD, dentro do mês visível' },
|
|
49
|
+
hours: { type: 'number', description: 'de 0,5 a 23,5 em passo de 0,5' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
espelho: { type: 'boolean', description: 'apaga do Suite o que não vier nas linhas (defeito: false)' },
|
|
54
|
+
nome: { type: 'string', description: 'rótulo que aparece no painel (ex.: "sprint 12")' },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'aplicar',
|
|
60
|
+
description: `Escreve a proposta "id" no Suite através da extensão, sem esperar pelo clique em Aplicar no painel. Só chama esta tool depois de mostrares a pré-visualização ao utilizador e ele dar OK explícito no chat: essa aprovação, mais a aprovação desta tool no cliente MCP, é que substitui o clique no painel. Linhas trancadas e propostas bloqueadas são recusadas. ${AVISO}`,
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
required: ['id'],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
properties: {
|
|
66
|
+
id: { type: 'string', description: 'id devolvido por "propor"' },
|
|
67
|
+
timeout_s: {
|
|
68
|
+
type: 'integer',
|
|
69
|
+
minimum: 1,
|
|
70
|
+
maximum: WAIT_MAX_S,
|
|
71
|
+
description: `segundos a esperar (defeito ${TIMEOUT_APLICAR_S}, máximo ${WAIT_MAX_S}); a escrita é linha a linha e pode ser lenta`,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'resultado',
|
|
78
|
+
description: `Espera pela decisão do utilizador no painel para uma proposta: aplicado (com o relatório), cancelado (com o motivo), erro, ou pendente se o tempo esgotar (podes voltar a chamar). ${AVISO}`,
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
required: ['id'],
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
properties: {
|
|
84
|
+
id: { type: 'string', description: 'id devolvido por "propor"' },
|
|
85
|
+
timeout_s: { type: 'integer', minimum: 1, maximum: WAIT_MAX_S, description: `segundos a esperar (defeito ${TIMEOUT_TOOL_S}, máximo ${WAIT_MAX_S})` },
|
|
86
|
+
fase: {
|
|
87
|
+
type: 'string',
|
|
88
|
+
enum: ['previa', 'final'],
|
|
89
|
+
description: 'que fase esperar (defeito "final"). Usa "previa" para recuperar a pré-visualização de um "propor" que ficou pendente.',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const erro = (code, message) => Object.assign(new Error(message), { code });
|
|
97
|
+
|
|
98
|
+
// Um comando síncrono do ponto de vista da tool: ou há dados, ou é erro.
|
|
99
|
+
async function dadosDe(cliente, corpo) {
|
|
100
|
+
const r = await cliente.comando(corpo);
|
|
101
|
+
if (!r.resultado) throw erro('ERR_PENDENTE', `O Chrome não respondeu a tempo ao comando ${r.id}. Confirma que a Folha de Horas está aberta e tenta de novo.`);
|
|
102
|
+
if (!r.resultado.ok) throw erro(r.resultado.erro.code, r.resultado.erro.message);
|
|
103
|
+
return { id: r.id, dados: r.resultado.dados };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Forma de um resultado final (dados.fase === 'final'), partilhada por "propor" (quando a
|
|
107
|
+
// previa se perdeu e o que chega já é o final) e por "resultado".
|
|
108
|
+
const finalDe = (id, dados) => {
|
|
109
|
+
const { estado, report, motivo, erro: erroFinal } = dados;
|
|
110
|
+
if (estado === 'erro') return { id, estado: 'erro', erro: erroFinal };
|
|
111
|
+
return { id, estado, report, motivo };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export async function executar(nome, args = {}, cliente, { estadoServe } = {}) {
|
|
115
|
+
switch (nome) {
|
|
116
|
+
case 'estado': {
|
|
117
|
+
let e;
|
|
118
|
+
try {
|
|
119
|
+
e = await cliente.estado();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// O auto-arranque do mcp.js (ver lib/autoarranque.js) já tentou pôr o serve a
|
|
122
|
+
// responder antes disto. Se não conseguiu, por a porta estar ocupada por outro
|
|
123
|
+
// programa ou por o arranque ter falhado, esta tool não deve lançar: devolve o
|
|
124
|
+
// porquê para o Claude poder explicar ao utilizador em vez de um erro seco.
|
|
125
|
+
if (err.code === 'ERR_SERVE_EM_BAIXO' && (estadoServe?.estado === 'porta-ocupada' || estadoServe?.estado === 'falhou')) {
|
|
126
|
+
return { ponte: 'sem-serve', serve: estadoServe.estado, detalhe: estadoServe.detalhe };
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
const base = {
|
|
131
|
+
ponte: e.ponte,
|
|
132
|
+
ano: e.contexto?.ano ?? null,
|
|
133
|
+
mes: e.contexto?.mes ?? null,
|
|
134
|
+
projetos: e.contexto?.projetos?.length ?? 0,
|
|
135
|
+
ultimo_mcp: e.ultimoMcp ?? null,
|
|
136
|
+
};
|
|
137
|
+
if (e.ponte !== 'ligada') return { ...base, lote_em_curso: null };
|
|
138
|
+
if (!e.contexto) {
|
|
139
|
+
// O serve reiniciou depois de a página ter carregado: a ponte está viva (o worker
|
|
140
|
+
// continua a fazer long-poll) mas perdeu o contexto. Um comando 'estado' ia dar
|
|
141
|
+
// ERR_SEM_CONTEXTO — não vale a pena tentar, e esta tool nunca deve lançar por isto.
|
|
142
|
+
return { ...base, lote_em_curso: null, aviso: 'sem contexto: recarrega a Folha de Horas' };
|
|
143
|
+
}
|
|
144
|
+
const { dados } = await dadosDe(cliente, { tipo: 'estado', timeout_s: 10 });
|
|
145
|
+
return { ...base, lote_em_curso: dados.loteEmCurso ?? null };
|
|
146
|
+
}
|
|
147
|
+
case 'projetos': return (await dadosDe(cliente, { tipo: 'projetos', timeout_s: 30 })).dados;
|
|
148
|
+
case 'ler_mes': return (await dadosDe(cliente, { tipo: 'ler', timeout_s: TIMEOUT_TOOL_S })).dados;
|
|
149
|
+
case 'propor': {
|
|
150
|
+
const proposta = validarProposta(args); // erro imediato e legível; o serve valida com o mês
|
|
151
|
+
const r = await cliente.comando({ tipo: 'propor', ...proposta, timeout_s: TIMEOUT_TOOL_S });
|
|
152
|
+
if (!r.resultado) return { id: r.id, estado: 'pendente' };
|
|
153
|
+
if (!r.resultado.ok) throw erro(r.resultado.erro.code, r.resultado.erro.message);
|
|
154
|
+
const { dados } = r.resultado;
|
|
155
|
+
// Normalmente chega logo a previa; se a previa se perdeu (ex.: o worker publicou logo o
|
|
156
|
+
// final) dados.fase já vem 'final' — devolve o estado final em vez de fingir "previa".
|
|
157
|
+
if (dados.fase !== 'previa') return finalDe(r.id, dados);
|
|
158
|
+
const { previa } = dados;
|
|
159
|
+
return { id: r.id, estado: 'previa', previa, bloqueio: previa?.bloqueio ?? null };
|
|
160
|
+
}
|
|
161
|
+
case 'aplicar': {
|
|
162
|
+
const id = String(args.id);
|
|
163
|
+
const timeoutS = Math.min(Number(args.timeout_s) || TIMEOUT_APLICAR_S, WAIT_MAX_S);
|
|
164
|
+
const r = await cliente.comando({ tipo: 'aplicar', proposta: id, timeout_s: timeoutS });
|
|
165
|
+
// "id" devolvido é sempre o da proposta (args.id), não o do comando aplicar em si: quem
|
|
166
|
+
// chamou não conhece nem precisa de conhecer o id interno deste comando de escrita.
|
|
167
|
+
if (!r.resultado) return { id, estado: 'pendente' };
|
|
168
|
+
if (!r.resultado.ok) throw erro(r.resultado.erro.code, r.resultado.erro.message);
|
|
169
|
+
const { dados } = r.resultado;
|
|
170
|
+
if (dados.estado === 'erro') return { id, estado: 'erro', erro: dados.erro };
|
|
171
|
+
return { id, estado: dados.estado, report: dados.report };
|
|
172
|
+
}
|
|
173
|
+
case 'resultado': {
|
|
174
|
+
const waitS = Math.min(Number(args.timeout_s) || TIMEOUT_TOOL_S, WAIT_MAX_S);
|
|
175
|
+
const fase = args.fase === 'previa' ? 'previa' : 'final';
|
|
176
|
+
const r = await cliente.resultado(String(args.id), waitS, fase);
|
|
177
|
+
if (!r.resultado) return { id: r.id, estado: 'pendente' };
|
|
178
|
+
if (!r.resultado.ok) return { id: r.id, estado: 'erro', erro: r.resultado.erro };
|
|
179
|
+
const { dados } = r.resultado;
|
|
180
|
+
if (fase === 'previa' && dados.fase === 'previa') {
|
|
181
|
+
return { id: r.id, estado: 'previa', previa: dados.previa, bloqueio: dados.previa?.bloqueio ?? null };
|
|
182
|
+
}
|
|
183
|
+
return finalDe(r.id, dados);
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
throw erro('ERR_TOOL', `Tool desconhecida: ${nome}.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
package/lib/fila.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Puro (relógio e ids injetáveis). Fila de comandos entre o MCP e o worker da extensão.
|
|
2
|
+
// Um comando de cada vez chega ao worker; um propor só entra quando o anterior tem final.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const erro = (message, extra) => Object.assign(new Error(message), extra);
|
|
7
|
+
|
|
8
|
+
const TIMEOUT_ENTREGA_DEFEITO_MS = 30 * 1000; // sem timeout_s no pedido, ver CONTRACT.md
|
|
9
|
+
const PRAZO_PREVIA_MS = 120 * 1000; // entregue ao worker mas sem previa a tempo: dá-se como perdido
|
|
10
|
+
|
|
11
|
+
export function criarFila({
|
|
12
|
+
agora = () => Date.now(),
|
|
13
|
+
ttlMs = 60 * 60 * 1000,
|
|
14
|
+
ponteVivaMs = 40 * 1000,
|
|
15
|
+
novoId = randomUUID,
|
|
16
|
+
} = {}) {
|
|
17
|
+
const pendentes = []; // comandos ainda não entregues
|
|
18
|
+
const registos = new Map(); // id -> { cmd, criadoEm, entregueEm, previa, final, timeoutMs }
|
|
19
|
+
const esperasWorker = []; // { resolve, entregue, timer } à espera de comando
|
|
20
|
+
const esperasResultado = new Map(); // id -> [{ fase, resolve }]
|
|
21
|
+
let ultimoPoll = 0;
|
|
22
|
+
|
|
23
|
+
const proporAberto = () => {
|
|
24
|
+
for (const registo of registos.values()) {
|
|
25
|
+
if (registo.cmd.tipo === 'propor' && !registo.final) return registo.cmd.id;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const resolverEsperas = (id, fases, corpo) => {
|
|
31
|
+
const lista = esperasResultado.get(id) ?? [];
|
|
32
|
+
esperasResultado.set(id, lista.filter((espera) => {
|
|
33
|
+
if (!fases.includes(espera.fase)) return true;
|
|
34
|
+
espera.resolve(corpo);
|
|
35
|
+
return false;
|
|
36
|
+
}));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function expirar(id, registo, message) {
|
|
40
|
+
const final = { ok: false, erro: { code: 'ERR_EXPIRADO', message } };
|
|
41
|
+
registo.final = final;
|
|
42
|
+
if (!registo.previa) registo.previa = final;
|
|
43
|
+
resolverEsperas(id, ['previa', 'final'], final);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Comandos por atraso (nunca entregues, ou entregues mas sem previa) tornam-se finais aqui,
|
|
47
|
+
// o que também liberta o próximo `propor` (proporAberto só olha a registos sem final). Depois
|
|
48
|
+
// de expirar, tira o id de `pendentes`: um id evictado ou já expirado ali dentro faria
|
|
49
|
+
// `proximo()` rebentar com TypeError ao tentar atualizar um registo que já não existe (ou que
|
|
50
|
+
// já está fechado). Por fim, o TTL apaga o que sobrou de há muito e já está fechado.
|
|
51
|
+
function limpar() {
|
|
52
|
+
const agoraMs = agora();
|
|
53
|
+
|
|
54
|
+
for (const [id, registo] of registos) {
|
|
55
|
+
if (registo.final) continue;
|
|
56
|
+
if (registo.entregueEm === null) {
|
|
57
|
+
if (agoraMs - registo.criadoEm > registo.timeoutMs) {
|
|
58
|
+
expirar(id, registo, `O comando ${id} não foi entregue à extensão a tempo (a ponte pode estar em baixo).`);
|
|
59
|
+
}
|
|
60
|
+
} else if (registo.cmd.tipo === 'propor' && !registo.previa && agoraMs - registo.entregueEm > PRAZO_PREVIA_MS) {
|
|
61
|
+
expirar(id, registo, `A extensão recebeu o comando ${id} mas não devolveu a pré-visualização a tempo; a proposta foi perdida.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (let i = pendentes.length - 1; i >= 0; i--) {
|
|
66
|
+
const registo = registos.get(pendentes[i].id);
|
|
67
|
+
if (!registo || registo.final) pendentes.splice(i, 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const limite = agoraMs - ttlMs;
|
|
71
|
+
for (const [id, registo] of registos) {
|
|
72
|
+
const fechado = registo.final !== null || registo.cmd.tipo !== 'propor';
|
|
73
|
+
if (registo.criadoEm < limite && fechado) {
|
|
74
|
+
registos.delete(id);
|
|
75
|
+
esperasResultado.delete(id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function enfileirar(cmd, { timeoutMs = TIMEOUT_ENTREGA_DEFEITO_MS } = {}) {
|
|
81
|
+
limpar();
|
|
82
|
+
if (cmd.tipo === 'propor') {
|
|
83
|
+
const aberto = proporAberto();
|
|
84
|
+
if (aberto) {
|
|
85
|
+
throw erro(`Já há uma proposta à espera de confirmação no painel (${aberto}).`, { code: 'ERR_OCUPADO', id: aberto });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const id = cmd.id ?? novoId();
|
|
89
|
+
const completo = { ...cmd, id };
|
|
90
|
+
registos.set(id, { cmd: completo, criadoEm: agora(), entregueEm: null, previa: null, final: null, timeoutMs });
|
|
91
|
+
const espera = esperasWorker.shift();
|
|
92
|
+
if (espera) {
|
|
93
|
+
clearTimeout(espera.timer);
|
|
94
|
+
registos.get(id).entregueEm = agora();
|
|
95
|
+
espera.entregue = completo;
|
|
96
|
+
espera.resolve(completo);
|
|
97
|
+
} else {
|
|
98
|
+
pendentes.push(completo);
|
|
99
|
+
}
|
|
100
|
+
return { id };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Devolve { promessa, cancelar } em vez de só a promise: quem faz o long-poll (serve.js) tem
|
|
104
|
+
// de poder desistir se o socket do lado do worker fechar antes de a promise resolver. Se ainda
|
|
105
|
+
// não tinha comando, cancelar() só tira a espera da fila. Se já tinha (a promise resolveu mas o
|
|
106
|
+
// socket morreu antes de o serve conseguir escrever a resposta), cancelar() devolve o comando à
|
|
107
|
+
// cabeça de `pendentes` para não se perder — e por isso é preciso chamar cancelar() antes de
|
|
108
|
+
// outro `proximo()` reclamar o mesmo lugar, e só quando a resposta ainda não foi escrita.
|
|
109
|
+
function proximo(waitMs) {
|
|
110
|
+
limpar();
|
|
111
|
+
ultimoPoll = agora();
|
|
112
|
+
const espera = { resolve: null, entregue: null, timer: null };
|
|
113
|
+
const promessa = new Promise((resolve) => {
|
|
114
|
+
espera.resolve = resolve;
|
|
115
|
+
const cmd = pendentes.shift();
|
|
116
|
+
if (cmd) {
|
|
117
|
+
registos.get(cmd.id).entregueEm = agora();
|
|
118
|
+
espera.entregue = cmd;
|
|
119
|
+
resolve(cmd);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
esperasWorker.push(espera);
|
|
123
|
+
espera.timer = setTimeout(() => {
|
|
124
|
+
const i = esperasWorker.indexOf(espera);
|
|
125
|
+
if (i >= 0) {
|
|
126
|
+
esperasWorker.splice(i, 1);
|
|
127
|
+
resolve(null);
|
|
128
|
+
}
|
|
129
|
+
}, waitMs);
|
|
130
|
+
// Sem unref: no Node 20 o runner de testes termina com o event loop vazio e cancela
|
|
131
|
+
// promessas à espera de um timer unref'd. Os timers são curtos e limpos na resolução.
|
|
132
|
+
});
|
|
133
|
+
const cancelar = () => {
|
|
134
|
+
clearTimeout(espera.timer);
|
|
135
|
+
const i = esperasWorker.indexOf(espera);
|
|
136
|
+
if (i >= 0) {
|
|
137
|
+
esperasWorker.splice(i, 1);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (espera.entregue) {
|
|
141
|
+
const registo = registos.get(espera.entregue.id);
|
|
142
|
+
if (registo && !registo.final) {
|
|
143
|
+
registo.entregueEm = null;
|
|
144
|
+
pendentes.unshift(espera.entregue);
|
|
145
|
+
}
|
|
146
|
+
espera.entregue = null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
return { promessa, cancelar };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function publicar(id, corpo) {
|
|
153
|
+
const registo = registos.get(id);
|
|
154
|
+
if (!registo) return false;
|
|
155
|
+
const ehPrevia = corpo?.ok === true && corpo.dados?.fase === 'previa';
|
|
156
|
+
if (ehPrevia) {
|
|
157
|
+
registo.previa = corpo;
|
|
158
|
+
resolverEsperas(id, ['previa'], corpo);
|
|
159
|
+
} else {
|
|
160
|
+
registo.final = corpo;
|
|
161
|
+
// Um final sem previa (ex.: ERR_OCUPADO, ERR_SESSION) também liberta quem esperava pela previa.
|
|
162
|
+
if (!registo.previa) registo.previa = corpo;
|
|
163
|
+
resolverEsperas(id, ['previa', 'final'], corpo);
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resultado(id, waitMs, fase = 'final') {
|
|
169
|
+
limpar();
|
|
170
|
+
const registo = registos.get(id);
|
|
171
|
+
if (!registo) {
|
|
172
|
+
return Promise.reject(erro(`Comando ${id} desconhecido (o serve pode ter reiniciado).`, { code: 'ERR_COMANDO_DESCONHECIDO' }));
|
|
173
|
+
}
|
|
174
|
+
if (registo[fase]) return Promise.resolve(registo[fase]);
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
const espera = { fase, resolve };
|
|
177
|
+
esperasResultado.set(id, [...(esperasResultado.get(id) ?? []), espera]);
|
|
178
|
+
const timer = setTimeout(() => {
|
|
179
|
+
const lista = esperasResultado.get(id) ?? [];
|
|
180
|
+
if (lista.includes(espera)) {
|
|
181
|
+
esperasResultado.set(id, lista.filter((e) => e !== espera));
|
|
182
|
+
resolve(null);
|
|
183
|
+
}
|
|
184
|
+
}, waitMs);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ponteViva = () => agora() - ultimoPoll < ponteVivaMs;
|
|
189
|
+
|
|
190
|
+
const estado = () => ({ pendentes: pendentes.length, registos: registos.size, ultimoPoll, ponteViva: ponteViva() });
|
|
191
|
+
|
|
192
|
+
return { enfileirar, proximo, publicar, resultado, ponteViva, limpar, estado };
|
|
193
|
+
}
|
package/lib/rotas.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Puro (fila e contexto injetados). Um router sem http: recebe {metodo, caminho, query, corpo}
|
|
2
|
+
// e devolve {status, corpo}. serve.js é a cola HTTP à volta disto.
|
|
3
|
+
|
|
4
|
+
import { validarProposta } from './validar.js';
|
|
5
|
+
|
|
6
|
+
const TIPOS = ['estado', 'projetos', 'ler', 'propor', 'aplicar'];
|
|
7
|
+
// O Chrome mata um service worker MV3 cujo fetch demora mais de ~30 s: 25 é o wait que o
|
|
8
|
+
// CONTRACT.md promete (worker pede wait=25) e o máximo que este serve aceita.
|
|
9
|
+
const WAIT_BRIDGE_MAX_S = 25;
|
|
10
|
+
const WAIT_MCP_MAX_S = 290; // o fetch do Node corta cabeçalhos aos 300 s
|
|
11
|
+
const TIMEOUT_COMANDO_S = 30;
|
|
12
|
+
// aplicar escreve linha a linha no Suite: pode demorar bem mais do que um comando normal.
|
|
13
|
+
const TIMEOUT_APLICAR_DEFEITO_S = 120;
|
|
14
|
+
|
|
15
|
+
const erro = (status, code, message) => ({ status, corpo: { erro: { code, message } } });
|
|
16
|
+
const mesTexto = (ano, mes) => `${ano}-${String(mes).padStart(2, '0')}`;
|
|
17
|
+
const inteiro = (v, defeito) => (Number.isFinite(Number(v)) ? Number(v) : defeito);
|
|
18
|
+
const limitar = (v, max) => Math.max(0, Math.min(inteiro(v, max), max));
|
|
19
|
+
|
|
20
|
+
export function criarRotas({ fila, contexto, log = () => {}, versao, agora = Date.now }) {
|
|
21
|
+
// Timestamp ISO do último pedido a qualquer rota /mcp/*, ou null se ainda não houve nenhum
|
|
22
|
+
// desde que este `serve` arrancou. É o que /health e /mcp/estado mostram como "ultimoMcp",
|
|
23
|
+
// para a extensão (painel) saber se o Claude ainda está a falar com o serviço.
|
|
24
|
+
let ultimoMcp = null;
|
|
25
|
+
const marcarMcp = () => { ultimoMcp = new Date(agora()).toISOString(); };
|
|
26
|
+
|
|
27
|
+
async function comando(corpo) {
|
|
28
|
+
if (!corpo || !TIPOS.includes(corpo.tipo)) return erro(400, 'ERR_COMANDO', `tipo tem de ser um de ${TIPOS.join(', ')}.`);
|
|
29
|
+
if (!fila.ponteViva()) {
|
|
30
|
+
return erro(409, 'ERR_SEM_CHROME', 'Abre a Folha de Horas do Suite no Chrome e espera uns segundos.');
|
|
31
|
+
}
|
|
32
|
+
const visivel = contexto.mesVisivel();
|
|
33
|
+
if (!visivel) {
|
|
34
|
+
// A ponte está viva (o worker está a fazer long-poll), mas o serve reiniciou depois de a
|
|
35
|
+
// página ter carregado: perdeu o contexto e ainda não houve recarregamento para o repor.
|
|
36
|
+
return erro(409, 'ERR_SEM_CONTEXTO', 'A ponte está ligada mas ainda não recebi o mês da página. Recarrega a Folha de Horas no Chrome.');
|
|
37
|
+
}
|
|
38
|
+
const ano = inteiro(corpo.ano, visivel.ano);
|
|
39
|
+
const mes = inteiro(corpo.mes, visivel.mes);
|
|
40
|
+
if (ano !== visivel.ano || mes !== visivel.mes) {
|
|
41
|
+
return erro(409, 'ERR_MES_DIFERENTE',
|
|
42
|
+
`A página mostra ${mesTexto(visivel.ano, visivel.mes)} e pediste ${mesTexto(ano, mes)}. Muda o mês na página do Suite.`);
|
|
43
|
+
}
|
|
44
|
+
let cmd = { tipo: corpo.tipo, ano, mes };
|
|
45
|
+
let proposta = null;
|
|
46
|
+
if (corpo.tipo === 'propor') {
|
|
47
|
+
try {
|
|
48
|
+
proposta = validarProposta(corpo, visivel);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return erro(400, e.code ?? 'ERR_PROPOSTA', e.message);
|
|
51
|
+
}
|
|
52
|
+
cmd = { ...cmd, ...proposta };
|
|
53
|
+
}
|
|
54
|
+
if (corpo.tipo === 'aplicar') {
|
|
55
|
+
// aplicar resolve um propor já enfileirado (pelo id da proposta), não cria um novo à
|
|
56
|
+
// espera de confirmação — por isso não passa por validarProposta nem por proporAberto.
|
|
57
|
+
if (typeof corpo.proposta !== 'string' || corpo.proposta.trim() === '') {
|
|
58
|
+
return erro(400, 'ERR_COMANDO', 'proposta tem de ser o id de uma proposta pendente (devolvido por "propor").');
|
|
59
|
+
}
|
|
60
|
+
cmd = { ...cmd, proposta: corpo.proposta };
|
|
61
|
+
}
|
|
62
|
+
// Quanto tempo este pedido HTTP espera sincronamente pelo resultado (0 é válido: "não
|
|
63
|
+
// bloqueies, dá-me pendente"). É distinto do prazo de entrega guardado na fila: um
|
|
64
|
+
// timeout_s pequeno (ou 0) não pode fazer o comando expirar antes de chegar a um worker —
|
|
65
|
+
// por isso o prazo de entrega tem sempre um mínimo de TIMEOUT_COMANDO_S. aplicar escreve
|
|
66
|
+
// linha a linha, por isso o seu defeito (sem timeout_s no pedido) é maior que o dos outros.
|
|
67
|
+
const defeitoTimeoutS = corpo.tipo === 'aplicar' ? TIMEOUT_APLICAR_DEFEITO_S : TIMEOUT_COMANDO_S;
|
|
68
|
+
const waitResultadoMs = limitar(corpo.timeout_s ?? defeitoTimeoutS, WAIT_MCP_MAX_S) * 1000;
|
|
69
|
+
const timeoutEntregaMs = Math.max(waitResultadoMs, TIMEOUT_COMANDO_S * 1000);
|
|
70
|
+
let id;
|
|
71
|
+
try {
|
|
72
|
+
({ id } = fila.enfileirar(cmd, { timeoutMs: timeoutEntregaMs }));
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return erro(409, e.code ?? 'ERR_OCUPADO', e.message);
|
|
75
|
+
}
|
|
76
|
+
// Só grava a proposta depois do enfileirar aceitar: se der ERR_OCUPADO, a proposta
|
|
77
|
+
// rejeitada não pode substituir a que já estava guardada para o /timesheet.
|
|
78
|
+
if (proposta) contexto.guardarProposta(ano, mes, proposta.linhas);
|
|
79
|
+
log(`comando ${id} ${cmd.tipo} ${mesTexto(ano, mes)}`);
|
|
80
|
+
const fase = cmd.tipo === 'propor' ? 'previa' : 'final';
|
|
81
|
+
const resultado = await fila.resultado(id, waitResultadoMs, fase);
|
|
82
|
+
return { status: 200, corpo: { id, estado: resultado ? 'concluido' : 'pendente', resultado } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return async function despachar({ metodo, caminho, query = {}, corpo = null, aoFechar = () => {} }) {
|
|
86
|
+
if (metodo === 'GET' && caminho === '/health') {
|
|
87
|
+
// JSON (não texto) desde sempre: a extensão usa "nome" para distinguir este serve de
|
|
88
|
+
// qualquer outro programa na mesma porta — um corpo de texto passa a contar como isso.
|
|
89
|
+
return {
|
|
90
|
+
status: 200,
|
|
91
|
+
corpo: { nome: 'suite-timesheet-serve', versao, ultimoMcp, ponte: fila.ponteViva() ? 'ligada' : 'sem-chrome' },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (metodo === 'GET' && caminho === '/timesheet') {
|
|
96
|
+
const year = inteiro(query.year, 0);
|
|
97
|
+
const month = inteiro(query.month, 0);
|
|
98
|
+
return { status: 200, corpo: { year, month, generated_at: new Date().toISOString(), rows: contexto.proposta(year, month) } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (metodo === 'POST' && caminho === '/bridge/contexto') {
|
|
102
|
+
if (!corpo || !Number.isInteger(corpo.ano) || !Number.isInteger(corpo.mes)) return erro(400, 'ERR_COMANDO', 'contexto sem ano/mes.');
|
|
103
|
+
contexto.guardar(corpo);
|
|
104
|
+
return { status: 204 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (metodo === 'GET' && caminho === '/bridge/next') {
|
|
108
|
+
const { promessa, cancelar } = fila.proximo(limitar(query.wait ?? WAIT_BRIDGE_MAX_S, WAIT_BRIDGE_MAX_S) * 1000);
|
|
109
|
+
// Dá ao serve.js uma forma de desistir se o socket do worker morrer antes desta promise
|
|
110
|
+
// resolver, para o comando (se já lhe tiver sido atribuído) não se perder (ver fila.js).
|
|
111
|
+
aoFechar(cancelar);
|
|
112
|
+
const cmd = await promessa;
|
|
113
|
+
return cmd ? { status: 200, corpo: cmd } : { status: 204 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const resultadoDe = /^\/bridge\/result\/([^/]+)$/.exec(caminho);
|
|
117
|
+
if (metodo === 'POST' && resultadoDe) {
|
|
118
|
+
const id = decodeURIComponent(resultadoDe[1]);
|
|
119
|
+
const aceite = fila.publicar(id, corpo);
|
|
120
|
+
log(`resultado ${id} ${corpo?.ok ? 'ok' : corpo?.erro?.code ?? '?'}${corpo?.dados?.fase ? ` ${corpo.dados.fase}` : ''}`);
|
|
121
|
+
return aceite ? { status: 204 } : erro(404, 'ERR_COMANDO_DESCONHECIDO', `Comando ${id} desconhecido.`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (metodo === 'GET' && caminho === '/mcp/estado') {
|
|
125
|
+
marcarMcp();
|
|
126
|
+
return { status: 200, corpo: { ponte: fila.ponteViva() ? 'ligada' : 'sem-chrome', contexto: contexto.atual(), ultimoMcp } };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (metodo === 'POST' && caminho === '/mcp/comando') {
|
|
130
|
+
marcarMcp();
|
|
131
|
+
return comando(corpo);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const comandoDe = /^\/mcp\/comando\/([^/]+)$/.exec(caminho);
|
|
135
|
+
if (metodo === 'GET' && comandoDe) {
|
|
136
|
+
marcarMcp();
|
|
137
|
+
const id = decodeURIComponent(comandoDe[1]);
|
|
138
|
+
try {
|
|
139
|
+
const resultado = await fila.resultado(id, limitar(query.wait ?? 0, WAIT_MCP_MAX_S) * 1000, query.fase === 'previa' ? 'previa' : 'final');
|
|
140
|
+
return { status: 200, corpo: { id, estado: resultado ? 'concluido' : 'pendente', resultado } };
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return erro(404, e.code ?? 'ERR_COMANDO_DESCONHECIDO', e.message);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return erro(404, 'ERR_ROTA', `Não existe ${metodo} ${caminho}.`);
|
|
147
|
+
};
|
|
148
|
+
}
|
package/lib/validar.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Puro. Primeira validação de uma proposta, para o Claude ter um erro imediato e legível.
|
|
2
|
+
// A extensão volta a validar (normalize/match): esta é a rede, aquela é a verdade.
|
|
3
|
+
|
|
4
|
+
const DATA_ISO = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
5
|
+
|
|
6
|
+
const erroProposta = (problemas) => Object.assign(
|
|
7
|
+
new Error(`Proposta inválida:\n${problemas.join('\n')}`),
|
|
8
|
+
{ code: 'ERR_PROPOSTA', problemas },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
function dataValida(texto) {
|
|
12
|
+
const m = DATA_ISO.exec(String(texto ?? ''));
|
|
13
|
+
if (!m) return null;
|
|
14
|
+
const [ano, mes, dia] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
15
|
+
const data = new Date(Date.UTC(ano, mes - 1, dia));
|
|
16
|
+
if (data.getUTCFullYear() !== ano || data.getUTCMonth() !== mes - 1 || data.getUTCDate() !== dia) return null;
|
|
17
|
+
return { ano, mes, dia };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validarProposta({ linhas, espelho = false, nome } = {}, mesVisivel = null) {
|
|
21
|
+
const problemas = [];
|
|
22
|
+
const lista = Array.isArray(linhas) ? linhas : [];
|
|
23
|
+
if (!Array.isArray(linhas)) problemas.push('"linhas" tem de ser um array.');
|
|
24
|
+
else if (linhas.length === 0 && espelho !== true) problemas.push('Sem linhas. Para limpar o mês usa espelho: true.');
|
|
25
|
+
|
|
26
|
+
const normalizadas = lista.map((linha, i) => {
|
|
27
|
+
const n = i + 1;
|
|
28
|
+
const temId = linha?.option_id !== undefined && linha.option_id !== null && linha.option_id !== '';
|
|
29
|
+
const temNome = typeof linha?.project === 'string' && linha.project.trim() !== '';
|
|
30
|
+
if (temId === temNome) problemas.push(`linha ${n}: indica exatamente um de option_id ou project.`);
|
|
31
|
+
if (temId && !(Number.isInteger(Number(linha.option_id)) && Number(linha.option_id) > 0)) {
|
|
32
|
+
problemas.push(`linha ${n}: option_id tem de ser um inteiro positivo.`);
|
|
33
|
+
}
|
|
34
|
+
const data = dataValida(linha?.date);
|
|
35
|
+
if (!data) problemas.push(`linha ${n}: date tem de ser YYYY-MM-DD e uma data real.`);
|
|
36
|
+
else if (mesVisivel && (data.ano !== mesVisivel.ano || data.mes !== mesVisivel.mes)) {
|
|
37
|
+
problemas.push(`linha ${n}: ${linha.date} está fora do mês visível ${mesVisivel.ano}-${String(mesVisivel.mes).padStart(2, '0')}.`);
|
|
38
|
+
}
|
|
39
|
+
const horas = Number(linha?.hours);
|
|
40
|
+
if (!Number.isFinite(horas) || horas <= 0 || horas > 23.5 || Math.round(horas * 2) !== horas * 2) {
|
|
41
|
+
problemas.push(`linha ${n}: hours tem de estar entre 0,5 e 23,5 em passo de 0,5.`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
option_id: temId ? Number(linha.option_id) : '',
|
|
45
|
+
project: temNome ? linha.project.trim() : '',
|
|
46
|
+
date: String(linha?.date ?? ''),
|
|
47
|
+
hours: horas,
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (problemas.length > 0) throw erroProposta(problemas);
|
|
52
|
+
return {
|
|
53
|
+
linhas: normalizadas,
|
|
54
|
+
espelho: espelho === true,
|
|
55
|
+
nome: typeof nome === 'string' && nome.trim() ? nome.trim() : 'proposta do Claude',
|
|
56
|
+
};
|
|
57
|
+
}
|
package/mcp.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Servidor MCP por stdio: cliente fino do serve. Nunca fala com o Suite.
|
|
3
|
+
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { FERRAMENTAS, executar } from './lib/ferramentas.js';
|
|
9
|
+
import { criarCliente } from './lib/cliente.js';
|
|
10
|
+
import { garantirServe } from './lib/autoarranque.js';
|
|
11
|
+
|
|
12
|
+
const base = process.env.SUITE_TIMESHEET_BASE ?? 'http://127.0.0.1:18765';
|
|
13
|
+
// Caminho absoluto do serve.js (irmão deste ficheiro): a mensagem de ERR_SERVE_EM_BAIXO diz
|
|
14
|
+
// exatamente o que correr, sem o utilizador ter de adivinhar onde este pacote está instalado.
|
|
15
|
+
const comandoArranque = `node ${fileURLToPath(new URL('./serve.js', import.meta.url))}`;
|
|
16
|
+
const cliente = criarCliente({ base, comandoArranque });
|
|
17
|
+
|
|
18
|
+
// Arranca o serve sozinho, salvo pedido em contrário (SUITE_TIMESHEET_SEM_AUTOARRANQUE=1):
|
|
19
|
+
// no máximo ~3 s (garantirServe faz 15 tentativas de 200 ms) antes da ligação por stdio, para
|
|
20
|
+
// nunca deixar o handshake do MCP pendurado à espera disto. Guarda-se o resultado para a tool
|
|
21
|
+
// "estado" poder explicar um "porta-ocupada"/"falhou" em vez de só lançar ERR_SERVE_EM_BAIXO
|
|
22
|
+
// (ver lib/ferramentas.js).
|
|
23
|
+
let estadoServe;
|
|
24
|
+
if (process.env.SUITE_TIMESHEET_SEM_AUTOARRANQUE !== '1') {
|
|
25
|
+
estadoServe = await garantirServe({ base });
|
|
26
|
+
console.error(`[suite-timesheet] serve: ${estadoServe.estado} em ${base}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const server = new Server(
|
|
30
|
+
{ name: 'suite-timesheet', version: '1.0.0' },
|
|
31
|
+
{ capabilities: { tools: {} } },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: FERRAMENTAS }));
|
|
35
|
+
|
|
36
|
+
server.setRequestHandler(CallToolRequestSchema, async (pedido) => {
|
|
37
|
+
try {
|
|
38
|
+
const r = await executar(pedido.params.name, pedido.params.arguments ?? {}, cliente, { estadoServe });
|
|
39
|
+
return { content: [{ type: 'text', text: JSON.stringify(r, null, 2) }] };
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return { isError: true, content: [{ type: 'text', text: `${e.code ?? 'ERR'}: ${e.message}` }] };
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await server.connect(new StdioServerTransport());
|