hobots 4.5.0 → 5.1.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/docs/runs.md CHANGED
@@ -29,9 +29,11 @@ Dentro do handler — e em **qualquer módulo** chamado por ele — o objeto glo
29
29
  ```js
30
30
  // notas.js — não recebe ctx nenhum
31
31
  export async function baixar(nota) {
32
- const step = hobots.transaction.start('http.client', `GET /notas/${nota}`);
33
- const xml = await portal.baixar(nota);
34
- step.setData('bytes', xml.length).succeeded();
32
+ const xml = await hobots.step(`GET /notas/${nota}`, async etapa => {
33
+ const xml = await portal.baixar(nota); // lançou? o SDK tenta de novo uma vez
34
+ etapa.data('bytes', xml.length);
35
+ return xml;
36
+ });
35
37
  hobots.items.succeeded({ numero_nota: nota }, { id: nota });
36
38
  }
37
39
  ```
@@ -43,7 +45,7 @@ Isso funciona com `concurrency > 1` porque cada execução roda no seu contexto
43
45
  | `hobots.log.<debug\|info\|warn\|error\|success>(msg, ...rest)` | Enviado incrementalmente (PATCHes com throttle) e ecoado no terminal (`[hobots] INFO …`). Nunca lança. `hobots.image(...)` no último argumento = print da linha. |
44
46
  | `hobots.items` | Ver [Itens](#itens-de-execução-hobotsitems) |
45
47
  | `hobots.progress` | Ver [Progresso](#progresso-hobotsprogress) |
46
- | `hobots.transaction` | Transaction da execução (`run`), já correlacionada e finalizada pelo SDK. `start(op, descrição)` = uma step no waterfall. |
48
+ | `hobots.step(nome, fn, opts?)` | Ver [Etapas](#etapas-hobotsstep) |
47
49
  | `hobots.image(imagem, opts?)` | Ver [Imagens](#imagens-screenshots) |
48
50
  | `hobots.captureException(erro, hint?)` | Issue no módulo Issues; `hint.attachment` anexa um print à linha de log |
49
51
  | `hobots.retry(fn, opts?)` | Ver [Retry](#retry) |
@@ -52,6 +54,7 @@ Isso funciona com `concurrency > 1` porque cada execução roda no seu contexto
52
54
 
53
55
  - **Retorno** do handler: **ignorado** (resultados podem conter dados sensíveis). Reporte por logs e itens.
54
56
  - **Erro lançado**: nova tentativa (ver Retry) e, esgotadas, `failed` com tipo, mensagem (cap 10k) e stack (cap 20k), passando pelo scrub de segredos.
57
+ - Para rodar o handler na sua máquina sem a Central, ver [Modo local](#modo-local-teste-sem-a-central).
55
58
 
56
59
  > **Dados sensíveis:** logs, itens, erros e prints são enviados ao servidor e ficam visíveis no app.
57
60
 
@@ -79,7 +82,7 @@ O contrato da plataforma — **todo item previsto termina com um desfecho regist
79
82
  const resultado = await hobots.items.each(
80
83
  contas,
81
84
  async (conta, item) => {
82
- await login(conta); // lançou → item `failed` com a mensagem, step em erro, segue
85
+ await login(conta); // lançou → item `failed` com a mensagem, etapa em erro, segue
83
86
  const fatura = await baixar(conta);
84
87
  if (!fatura) return item.occurrence('sem fatura no mês'); // ocorrência de negócio
85
88
  item.succeeded({ fatura: fatura.numero, valor: fatura.valor }); // mescla com o payload base
@@ -90,8 +93,8 @@ const resultado = await hobots.items.each(
90
93
  // resultado = { total, succeeded, failed, occurrence }
91
94
  ```
92
95
 
93
- - Cada elemento vira uma **step** no waterfall (`op` 'item', descrição = `id`; `item.step.start(...)` mede sub-etapas) e um **item** com o `id` e o payload base.
94
- - **Exceção no handler** = item `failed` com a mensagem do erro, `step.failed(erro)`, linha de erro no log da run — e o loop **segue para o próximo**. Nada do que o handler já registrou (`item.failed/occurrence`) é sobrescrito.
96
+ - Cada elemento vira uma **etapa** no waterfall (`op` 'item', descrição = `id`; `hobots.step(...)` dentro do handler vira sub-etapa dela, e `item.step` é o handle `warning`/`image`/`data`) e um **item** com o `id` e o payload base.
97
+ - **Exceção no handler** = item `failed` com a mensagem do erro, etapa em `error`, linha de erro no log da run — e o loop **segue para o próximo**. Nada do que o handler já registrou (`item.failed/occurrence`) é sobrescrito. O `each` não repete o item: para tentar de novo um trecho, use `hobots.step` dentro do handler.
95
98
  - **Progresso automático**: declara o total pelo tamanho da lista (se o bot ainda não declarou um) e avança a cada elemento, inclusive nos que falharam. `progress: false` deixa o progresso por conta do bot.
96
99
  - Só **cancelamento, timeout e shutdown** interrompem o loop: o `RunCanceled` sobe, o item em curso não é registrado e o progresso não avança.
97
100
  - Aceita `Iterable` ou `AsyncIterable` (sem `length`, nenhum total é declarado).
@@ -112,11 +115,41 @@ hobots.register(handler, {
112
115
  - A declaração vai na presença da instância (`instance.items` no poll / abertura de run). No app, **Configurações → agente → itens da execução** pode personalizar as colunas (e restaurar as do código).
113
116
  - Sem declaração nem personalização, o app mostra as chaves encontradas nos próprios payloads.
114
117
 
118
+ ## Etapas (`hobots.step`)
119
+
120
+ Uma **etapa** é um trecho da execução com nome de negócio, medido no waterfall da aba de performance. Uma chamada só: o SDK abre a etapa, roda a função, **tenta de novo** se ela lançar e fecha.
121
+
122
+ ```js
123
+ // retries = 1 e retryDelayMs = 5_000 por padrão
124
+ const pedido = await hobots.step('Buscar pedido', () => portal.buscar(numero));
125
+
126
+ // etapa não idempotente (enviar, pagar, emitir): sem repetir
127
+ await hobots.step(`Emitir nota ${numero}`, () => portal.emitir(numero), { retries: 0 });
128
+
129
+ // mais tentativas, espera própria (número ou função da tentativa, 1-based)
130
+ await hobots.step('Entrar no portal', login, { retries: 3, retryDelayMs: attempt => attempt * 2_000 });
131
+
132
+ // a função recebe o handle `etapa` para o que o SDK não vê sozinho
133
+ await hobots.step('Conferir anexos', async etapa => {
134
+ const anexos = await portal.anexos(numero);
135
+ etapa.data('total', anexos.length);
136
+ if (anexos.length === 0) etapa.warning('pedido sem anexos', { image: await page.screenshot() });
137
+ });
138
+ ```
139
+
140
+ - **Uma etapa só na Central**, cobrindo todas as tentativas: `data.attempts` quando houve mais de uma; status `warning` com o motivo (`data.message = "tentativa 1/2 falhou: Tipo: mensagem"`) quando a nova tentativa deu certo — a menos que a função já tenha marcado `warning`; `error` com `data.error = { type, message }` quando esgotou (a exceção original sobe); `cancelled` em cancelamento, timeout ou shutdown (nunca repetem).
141
+ - Cada tentativa falhada vai para o **log da run**: `etapa "Buscar pedido": tentativa 1/2 falhou: Error: portal caiu — nova tentativa em 5000ms`. A espera é cancelável: uma execução cancelada no app não espera a próxima tentativa.
142
+ - **Aninhamento automático**: `hobots.step` dentro da função vira sub-etapa da etapa corrente — inclusive dentro do handler de `items.each`, onde aninha na etapa do item.
143
+ - O handle `etapa` (`StepHandle`): `warning(motivo, { image }?)` (uma etapa com erro não volta para aviso), `image(png)` (até 4 por etapa; vai em `data.attachments`), `data(chave, valor)` (`attachments` é reservada). Nada de abrir, fechar ou mudar status — isso é do SDK.
144
+ - Abrir a etapa é um **ponto de parada** (`RunCanceled` se a execução foi cancelada). O nome é recortado em 2048 caracteres no cliente.
145
+ - Fora de uma execução (aviso único), `hobots.step` roda a função com o mesmo retry, sem registrar nada. No [modo local](#modo-local-teste-sem-a-central) a etapa fica só na memória e o FINISH ecoa no terminal: `[hobots:step] FINISH Buscar pedido (812ms): tentativa 1/2 falhou: …`.
146
+ - `hobots.retry(fn)` continua existindo para repetir **sem etapa**; `retries` de `register()`/`start()` repete o **trabalho inteiro**.
147
+
115
148
  ## Entregar enquanto processa (`hobots.flush()`)
116
149
 
117
150
  A transaction da execução só é enviada quando a run termina — num agente sempre ligado (`start()`) isso é nunca. `hobots.flush()` deixa o **bot** decidir quando entregar o que já fez:
118
151
 
119
- - cada step **raiz** já finalizada (com todas as filhas; as filhas ainda abertas são fechadas) vai numa transaction parcial da mesma run (`op` `hobots.run.flush`, `event_id` novo, até 200 steps cada) e sai da memória do SDK; steps raiz ainda abertas ficam para o próximo flush;
152
+ - cada etapa **raiz** já finalizada (com todas as sub-etapas; as ainda abertas são fechadas) vai numa transaction parcial da mesma run (`op` `hobots.run.flush`, `event_id` novo, até 200 etapas cada) e sai da memória do SDK; etapas raiz ainda abertas ficam para o próximo flush;
120
153
  - itens, logs e progresso pendentes saem já, sem esperar o throttle;
121
154
  - aguarda a entrega com teto (`timeoutMs`, default 15 s) e **nunca lança** — pode ir num `finally`.
122
155
 
@@ -125,11 +158,11 @@ hobots.start(async () => {
125
158
  while (true) {
126
159
  const lote = await fila.pegar();
127
160
  if (lote.length) {
128
- const rodada = hobots.transaction.start('rodada', `${lote.length} pedido(s)`);
129
161
  try {
130
- for (const pedido of lote) await processar(pedido, rodada); // steps filhas + hobots.items.*
162
+ await hobots.step(`rodada: ${lote.length} pedido(s)`, async () => {
163
+ for (const pedido of lote) await processar(pedido); // sub-etapas (hobots.step) + hobots.items.*
164
+ }, { retries: 0 });
131
165
  } finally {
132
- rodada.succeeded();
133
166
  await hobots.flush(); // waterfall e itens desta rodada já na Central
134
167
  }
135
168
  }
@@ -138,7 +171,7 @@ hobots.start(async () => {
138
171
  });
139
172
  ```
140
173
 
141
- Fila vazia → não abra step: cada step também vira duas linhas (START/FINISH) no log da execução, que guarda no máximo 1000.
174
+ Fila vazia → não abra etapa: cada etapa também vira duas linhas (START/FINISH) no log da execução, que guarda no máximo 1000.
142
175
 
143
176
  ## Progresso (`hobots.progress`)
144
177
 
@@ -182,9 +215,54 @@ for (const ref of hobots.files.list()) hobots.log.info(ref.name, ref.size);
182
215
 
183
216
  `isFileRef(valor)` (export do pacote, também `hobots.isFileRef`) reconhece a forma de um `FileRef` — útil para percorrer params aninhados.
184
217
 
218
+ ## Modo local (teste sem a Central)
219
+
220
+ Para testar o robô na sua máquina sem Client Secret, sem Central e sem rede, rode-o com a env **`HOBOTS_LOCAL_RUN=1`**. `register()` e `start()` se comportam igual neste modo: o handler roda **uma vez**, com `params = JSON.parse(HOBOTS_RUN_PARAMS || '{}')`, e o processo encerra. O código do bot não muda — `hobots.log`, `hobots.items`, `hobots.progress`, `hobots.files`, `items.each`, `hobots.step` (o FINISH de cada etapa ecoa como `[hobots:step] FINISH <nome> (123ms): <motivo>`), cancelamento e retry são os mesmos de produção; só o transporte é trocado por um que não fala com ninguém.
221
+
222
+ ```bash
223
+ HOBOTS_LOCAL_RUN=1 HOBOTS_RUN_PARAMS='{"lote": 3}' node bot.js
224
+ ```
225
+
226
+ - `HOBOTS_CLIENT_SECRET`, `HOBOTS_RUN_ID` e `HOBOTS_API_URL` são ignoradas. Nenhuma requisição sai: sem heartbeat, sem poll, sem issues (`hobots.captureException` devolve `undefined`).
227
+ - `hobots.runId` é `'local'`; `HOBOTS_RUN_PARAMS` que não seja um objeto JSON válido imprime `[hobots] ERROR HOBOTS_RUN_PARAMS inválido: <motivo>` no stderr e encerra com código **2**.
228
+ - **Exit code** pelo desfecho: **0** handler terminou; **1** lançou (depois dos retries configurados) — no stderr sai `[hobots] ERROR execução falhou — <Tipo>: <mensagem>` e a stack completa; **130** cancelado — `SIGINT`/`SIGTERM` disparam `hobots.signal` na execução em voo e esperam o handler parar por até 4 s (com `installExitHandlers: false`, ou com handler próprio do programa para o sinal, o SDK não intervém).
229
+
230
+ O que sai no **stdout** (linhas estáveis, pensadas para quem lê a saída por script):
231
+
232
+ | Linha | Quando |
233
+ | --- | --- |
234
+ | `[hobots] INFO modo local — execução sem a Central; params de HOBOTS_RUN_PARAMS` | primeira linha, ao entrar no modo |
235
+ | `[hobots] INFO <mensagem>` (e `WARNING`/`ERROR` no stderr) | cada `hobots.log.*`, como sempre |
236
+ | `[hobots:progress] <current>/<total>` | `hobots.progress` (também fora do modo local) |
237
+ | `[hobots:item] <status>\t<id>\t<message>` | um item por linha, `status` = `succeeded` \| `occurrence` \| `failed`; tab e quebra de linha em id/mensagem viram espaço |
238
+ | `[hobots:summary] total=<n> ok=<n> avisos=<n> falhas=<n> pulados=0` | no fim, contagem dos itens por status |
239
+ | `[hobots] INFO execução local concluída (<succeeded\|failed\|canceled>) em <N>s` | última linha |
240
+
241
+ Imagens e anexos (`hobots.image`, `{ image }` em itens e steps) são **descartados**, com um aviso único: `imagens e anexos não são enviados no modo local (descartados)`.
242
+
243
+ **Arquivos.** `hobots.files` funciona com `storage: 'local'`; o formato `$HOBOTS_LOCAL_FILES_DIR/<id>/<nome>` continua valendo, e só no modo local o `FileRef` aceita a chave extra **`path`** (caminho absoluto ou relativo à pasta de trabalho): `download` copia de `path` **sem apagar a origem** (o teste roda quantas vezes quiser) e sem conferir `size`. `path` inexistente ou que não é arquivo regular: `arquivo "<nome>" não encontrado em <path>`. Um arquivo enviado pela Central (`storage: 's3'`) não existe aqui: `arquivo "<nome>" enviado pela Central não está disponível no modo local`. Fora do modo local a chave `path` é ignorada.
244
+
245
+ ### A CLI `hobots run`
246
+
247
+ A CLI monta as envs e os params por você — e roda o entry com o **mesmo Node** (`process.execPath`), na mesma pasta de trabalho, herdando o terminal; o exit code do bot é o da CLI:
248
+
249
+ ```bash
250
+ npx hobots run bot.js --params entrada.json --param lote=3 --param nome='"ACME"' --file planilha=./dados.xlsx -- --verbose
251
+ ```
252
+
253
+ | Opção | Efeito |
254
+ | --- | --- |
255
+ | `--params <arquivo.json>` | objeto JSON base dos params |
256
+ | `--param <chave>=<valor>` (repetível) | valor parseado como JSON quando for JSON válido (`3`, `true`, `"x"`, `[1]`), senão string crua; sobrescreve `--params` |
257
+ | `--file <campo>=<caminho>` (repetível) | vira um `FileRef` `{ id: <uuid>, name, size, contentType, storage: 'local', path: <caminho absoluto> }`; o mesmo campo repetido vira uma lista; `contentType` pela extensão (`xlsx`, `xls`, `csv`, `ods`, `pdf`, `json`, `txt`, `xml`, `png`, `jpg`/`jpeg`, `zip`; demais `application/octet-stream`); caminho inexistente → `arquivo não encontrado: <caminho>`, exit 2 |
258
+ | `--reset-ledger` | define `HOBOTS_LEDGER_RESET=1` no ambiente do bot (o ledger é do esqueleto do bot, não do SDK) |
259
+ | `-- <args>` | tudo depois de `--` vai para o bot |
260
+
261
+ `--help`/`-h` imprime a linha de uso no stdout (exit 0). Uso errado (sem entry, flag desconhecida, `--param` sem `=`): mensagem curta + linha de uso no stderr e exit **2**; entry inexistente → `arquivo não encontrado: <entry>`; `--params` com arquivo inexistente, JSON inválido ou que não é objeto → `--params: <motivo>` (exit 2). A CLI repassa `SIGINT`/`SIGTERM` ao bot e espera ele encerrar. O SDK Python tem a mesma CLI: `python -m hobots run bot.py …`.
262
+
185
263
  ## Imagens (screenshots)
186
264
 
187
- Um print vai **junto do evento** que ele ilustra — a linha de log, o item ou a step — para o
265
+ Um print vai **junto do evento** que ele ilustra — a linha de log, o item ou a etapa — para o
188
266
  usuário ver o estado do robô no momento em que aquilo aconteceu (botão de imagem no app):
189
267
 
190
268
  ```js
@@ -199,16 +277,12 @@ await hobots.items.each(notas, async (nota, item) => {
199
277
  item.occurrence('sem fatura no mês', undefined, { image: await page.screenshot() });
200
278
  });
201
279
 
202
- // na step
203
- const step = hobots.transaction.start('emitir', numero);
204
- try {
280
+ // na etapa
281
+ await hobots.step(`emitir ${numero}`, async etapa => {
205
282
  await emitir(numero);
206
- } catch (erro) {
207
- step.failed(erro, undefined, { image: await page.screenshot() });
208
- throw erro;
209
- } finally {
210
- step.succeeded();
211
- }
283
+ etapa.image(await page.screenshot()); // print do estado da tela
284
+ if (await page.isVisible('.aviso')) etapa.warning('portal demorou a responder', { image: await page.screenshot() });
285
+ }, { retries: 0 });
212
286
 
213
287
  // numa issue (módulo Issues) — o print vai na linha de log que espelha o erro
214
288
  hobots.captureException(erro, { attachment: hobots.image(await page.screenshot(), { caption: 'tela no momento do erro' }) });
@@ -220,7 +294,7 @@ O SDK não tira o print: aceita bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`) ou u
220
294
 
221
295
  Cancelar o trabalho no app **não mata** o handler: o SDK detecta (o PATCH periódico recebe 409), dispara `hobots.signal` e o próximo **ponto de parada** lança `RunCanceled`:
222
296
 
223
- - `hobots.items.*`, `hobots.progress.total/advance/set`, `hobots.transaction.start()`, `hobots.sleep()` e cada tentativa de `hobots.retry()`.
297
+ - `hobots.items.*`, `hobots.progress.total/advance/set`, `hobots.step()`, `hobots.sleep()` e cada tentativa de `hobots.retry()`.
224
298
  - **Nunca `hobots.log.*`** — log em `finally` de limpeza não pode explodir.
225
299
 
226
300
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hobots",
3
- "version": "4.5.0",
3
+ "version": "5.1.0",
4
4
  "description": "SDK JavaScript da Hobots — o agente é a própria tarefa: register() atende solicitações da plataforma (sob demanda), start() roda sempre ligado e reporta logs, itens e progresso. Credenciais por env, heartbeat sempre ligado, objeto global `hobots`.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -12,6 +12,9 @@
12
12
  "dist",
13
13
  "docs"
14
14
  ],
15
+ "bin": {
16
+ "hobots": "./dist/cli.cjs"
17
+ },
15
18
  "main": "./dist/index.cjs",
16
19
  "module": "./dist/index.js",
17
20
  "types": "./dist/index.d.ts",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../protocol-limits.json","../src/wire-limits.ts"],"sourcesContent":["{\n \"MAX_ITEMS_PER_ENVELOPE\": 50,\n \"HEARTBEAT_DEFAULT_INTERVAL_MS\": 30000,\n \"HEARTBEAT_MIN_INTERVAL_MS\": 1000,\n \"HEARTBEAT_MAX_INTERVAL_MS\": 900000,\n \"MAX_CLAIM_PER_POLL\": 5,\n \"MAX_LOG_MESSAGE_CHARS\": 10000,\n \"MAX_LOGS_PER_PATCH\": 100,\n \"MAX_ERROR_MESSAGE_CHARS\": 10000,\n \"MAX_ERROR_STACK_CHARS\": 20000,\n \"MAX_ITEMS_PER_REQUEST\": 500,\n \"MAX_ITEM_MESSAGE_CHARS\": 2000,\n \"MAX_ITEM_PAYLOAD_BYTES\": 16384,\n \"MAX_ITEM_COLUMNS\": 12,\n \"MAX_ITEM_COLUMN_LABEL_CHARS\": 60,\n \"MAX_ATTACHMENT_BYTES\": 5242880,\n \"MAX_ATTACHMENTS_PER_RUN\": 20,\n \"MAX_ATTACHMENTS_PER_LOG_ENTRY\": 4,\n \"MAX_CAPTION_CHARS\": 200,\n \"MAX_IMAGE_DIMENSION_PX\": 20000\n}\n","import limits from '../protocol-limits.json';\n\n/**\n * Limites do wire SDK ↔ servidor — fonte ÚNICA (protocol-limits.json): o SDK\n * Node lê daqui, a @hobots/api importa de `hobots/protocol` e o SDK Python\n * confere a paridade num teste que lê o mesmo JSON. Mudar um limite = mudar o\n * JSON (e, no Python, a constante espelhada — o teste acusa).\n */\nexport const WIRE_LIMITS = limits as Readonly<typeof limits>;\nexport type WireLimits = typeof WIRE_LIMITS;\n"],"mappings":";AAAA;AAAA,EACE,wBAA0B;AAAA,EAC1B,+BAAiC;AAAA,EACjC,2BAA6B;AAAA,EAC7B,2BAA6B;AAAA,EAC7B,oBAAsB;AAAA,EACtB,uBAAyB;AAAA,EACzB,oBAAsB;AAAA,EACtB,yBAA2B;AAAA,EAC3B,uBAAyB;AAAA,EACzB,uBAAyB;AAAA,EACzB,wBAA0B;AAAA,EAC1B,wBAA0B;AAAA,EAC1B,kBAAoB;AAAA,EACpB,6BAA+B;AAAA,EAC/B,sBAAwB;AAAA,EACxB,yBAA2B;AAAA,EAC3B,+BAAiC;AAAA,EACjC,mBAAqB;AAAA,EACrB,wBAA0B;AAC5B;;;ACZO,IAAM,cAAc;","names":[]}