# Execuções

O **agente** é o seu robô, e o handler que você passa a `register()`/`start()` é o que ele faz. Cada rodada é uma **execução** (run) na plataforma, com logs, itens, progresso, anexos e resultado. Há dois modos de iniciar o agente; tudo o mais (o objeto `hobots`, cancelamento, retry) é igual nos dois.

## Os dois modos

```js
// sob demanda: uma execução por solicitação do app (formulário do agente ou agendamento)
hobots.register(async params => { /* … */ }, { concurrency: 2 });

// sempre ligado: o robô abre a própria execução e roda o handler (o seu loop) uma vez
hobots.start(async () => { /* while (true) { … } */ });
```

- `register` — o robô faz polling na plataforma; cada solicitação vira uma execução com os `params` do formulário. O polling começa sozinho.
- `start` — ao iniciar, o SDK cria a execução (`POST /v1/runs`) e roda o handler com `params = {}`. Enquanto a plataforma não responde, insiste com backoff — o handler só começa quando a execução existe. Quando o handler retorna, a execução fecha como `succeeded` (ou `failed` após os retries) e o processo segue vivo pelo heartbeat. **Uma execução por processo** — para "recomeçar", reinicie o robô.
- **Uma chamada por processo**: `register` ou `start`. A segunda lança (`o agente já foi iniciado`). Dois trabalhos diferentes são dois agentes.

> No modo start, lembre do teto de **1000 linhas de log por execução** no servidor: logue novidades e use `hobots.items` para o trabalho feito (sem teto por execução — só o armazenamento do plano limita).

## O handler e o objeto `hobots`

```ts
type RunHandler<P = Record<string, unknown>> = (params: P, ctx: RunContext) => void | Promise<void>;
```

Dentro do handler — e em **qualquer módulo** chamado por ele — o objeto global `hobots` resolve a execução atual:

```js
// notas.js — não recebe ctx nenhum
export async function baixar(nota) {
  const step = hobots.transaction.startChild('http.client', `GET /notas/${nota}`);
  const xml = await portal.baixar(nota);
  step.setData('bytes', xml.length).finish();
  hobots.items.succeeded({ numero_nota: nota }, { id: nota });
}
```

Isso funciona com `concurrency > 1` porque cada execução roda no seu contexto async (`AsyncLocalStorage`). Com uma única execução ativa, funciona até fora da cadeia async (um listener criado antes do handler, por exemplo). O `ctx` do 2º argumento é o mesmo objeto — use se preferir explícito. Tipar params: `hobots.register<{ lote: number }>(…)`.

| Membro | Notas |
| --- | --- |
| `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. |
| `hobots.items` | Ver [Itens](#itens-de-execução-hobotsitems) |
| `hobots.progress` | Ver [Progresso](#progresso-hobotsprogress) |
| `hobots.transaction` | Transaction da execução (`run`), já correlacionada e finalizada pelo SDK. `startChild(op, descrição)` = uma step no waterfall. |
| `hobots.image(imagem, opts?)` | Ver [Imagens](#imagens-screenshots) |
| `hobots.captureException(erro, hint?)` | Issue no módulo Issues; `hint.attachment` anexa um print à linha de log |
| `hobots.retry(fn, opts?)` | Ver [Retry](#retry) |
| `hobots.sleep(ms)` · `hobots.signal` · `hobots.throwIfCanceled()` | Ver [Cancelamento](#cancelamento-cooperativo) |
| `hobots.runId` · `hobots.params` | Identidade da execução |

- **Retorno** do handler: **ignorado** (resultados podem conter dados sensíveis). Reporte por logs e itens.
- **Erro lançado**: nova tentativa (ver Retry) e, esgotadas, `failed` com tipo, mensagem (cap 10k) e stack (cap 20k), passando pelo scrub de segredos.

> **Dados sensíveis:** logs, itens, erros e prints são enviados ao servidor e ficam visíveis no app.

## Itens de execução (`hobots.items`)

A **entrega do robô**: cada item processado, com status e atributos livres (viram colunas na aba de itens da execução no app), totalizadores no trabalho.

```js
hobots.items.succeeded({ numero_nota: nota.numero, cnpj: nota.cnpj }, { id: nota.numero });
hobots.items.failed('portal fora do ar', { numero_nota: nota.numero }, { id: nota.numero });   // falha técnica
hobots.items.occurrence('nota não encontrada', { numero_nota: '10' }, { id: '10' });           // ocorrência de negócio
hobots.items.add({ status: 'succeeded', id: '11', payload: { … } });                            // forma genérica
```

- Chamadas síncronas e bufferizadas (lotes de até 500); o SDK drena o buffer antes do reporte final.
- `id` é a identidade de negócio do item na execução — repetido, **vale o último reporte** (o servidor faz upsert: uma conta que falhou numa tentativa e deu certo no retry fica como sucesso; retry de rede é no-op). Sem `id`, cada chamada é um item novo.
- Limites: sem teto de itens por execução (só o armazenamento do plano), payload 16 KB, mensagem 2.000 caracteres. Uma execução cancelada ainda aceita os itens já bufferizados (trabalho feito).
- Cada chamada é um **ponto de parada cooperativo** (lança `RunCanceled` se a execução foi cancelada).

### Processar uma lista inteira (`hobots.items.each`)

O contrato da plataforma — **todo item previsto termina com um desfecho registrado, e a falha de um não interrompe os outros** — fica garantido pelo SDK quando o loop é dele:

```js
const resultado = await hobots.items.each(
  contas,
  async (conta, item) => {
    await login(conta);                       // lançou → item `failed` com a mensagem, step em erro, segue
    const fatura = await baixar(conta);
    if (!fatura) return item.occurrence('sem fatura no mês');   // ocorrência de negócio
    item.succeeded({ fatura: fatura.numero, valor: fatura.valor }); // mescla com o payload base
    // retornou sem registrar nada → `succeeded`
  },
  { id: c => c.numero_cliente, payload: c => ({ cpf_cnpj: c.cpf_cnpj, nome: c.nome }) }
);
// resultado = { total, succeeded, failed, occurrence }
```

- Cada elemento vira uma **step** no waterfall (`op` 'item', descrição = `id`; `item.step.startChild(...)` mede sub-etapas) e um **item** com o `id` e o payload base.
- **Exceção no handler** = item `failed` com a mensagem do erro, `step.setError(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.
- **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.
- 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.
- Aceita `Iterable` ou `AsyncIterable` (sem `length`, nenhum total é declarado).

> Se o handler termina com o progresso parado antes do total declarado (ex.: 45/76), o SDK deixa um aviso no log da execução — e a Central sinaliza o desencontro entre processados e itens reportados.

### Colunas da aba de itens

As colunas vêm do código, não do formulário — é **uma declaração só, do agente**, válida para `register()` (com ou sem formulário) e `start()`:

```js
hobots.register(handler, {
  items: { columns: ['numero_nota', { key: 'cnpj', label: 'CNPJ' }, { key: 'valor', label: 'valor (R$)' }] },
});
```

- Ordem da lista = ordem das colunas; até 12; chave com letras, números, `.`, `-`, `_` (inválidas são descartadas com aviso em `debug`).
- 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).
- Sem declaração nem personalização, o app mostra as chaves encontradas nos próprios payloads.

## Entregar enquanto processa (`hobots.flush()`)

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:

- 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;
- itens, logs e progresso pendentes saem já, sem esperar o throttle;
- aguarda a entrega com teto (`timeoutMs`, default 15 s) e **nunca lança** — pode ir num `finally`.

```js
hobots.start(async () => {
  while (true) {
    const lote = await fila.pegar();
    if (lote.length) {
      const rodada = hobots.transaction.startChild('rodada', `${lote.length} pedido(s)`);
      try {
        for (const pedido of lote) await processar(pedido, rodada); // steps filhas + hobots.items.*
      } finally {
        rodada.finish();
        await hobots.flush(); // waterfall e itens desta rodada já na Central
      }
    }
    await hobots.sleep(6_000);
  }
});
```

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.

## Progresso (`hobots.progress`)

```js
hobots.progress.total(notas.length);
for (const nota of notas) {
  await baixar(nota);
  hobots.progress.advance(); // barra, percentual e tempo restante no app
}
```

Opcional e independente de `items`. Sem `total()` nada é enviado. Snapshot absoluto que pega carona nos PATCHes de log (no pior caso 1/s); o PATCH terminal leva o valor final. `set(atual, total?)`, `current` e `snapshot` completam a API. `total/advance/set` são pontos de parada cooperativos; `current`/`snapshot` nunca lançam.

## Arquivos do formulário (`hobots.files`)

Um campo de upload do formulário chega nos `params` como um `FileRef` — `{ id, name, size, contentType, storage }` — ou como uma lista deles (campo "vários arquivos"). `hobots.files` traz o arquivo para o disco desta máquina:

```js
const caminho = await hobots.files.download('planilha');              // campo único
const caminhos = await hobots.files.downloadAll('notas', { dest: './entrada' }); // lista
const bytes = await hobots.files.read('contrato');                    // Buffer em memória
for (const ref of hobots.files.list()) hobots.log.info(ref.name, ref.size);
```

| Método | Retorno | Notas |
| --- | --- | --- |
| `list()` | `FileRef[]` | Todos os arquivos dos params (campos de topo, valor único ou lista), na ordem dos campos. |
| `get(campo)` | `FileRef \| FileRef[] \| undefined` | `undefined` se o campo não tem arquivo. |
| `download(refOuCampo, { dest? })` | `Promise<string>` | Caminho local. Campo com vários arquivos lança — use `downloadAll` ou passe um item. |
| `downloadAll(campo, { dest? })` | `Promise<string[]>` | Em ordem; aqui `dest` é sempre uma pasta. |
| `read(refOuCampo)` | `Promise<Buffer>` | Baixa, lê e apaga a cópia. |

**Origem consumida.** O arquivo é baixado **uma vez**:

- `storage: 's3'` (enviado pela Central): o SDK pede a URL pré-assinada, baixa em streaming para um `.part`, confere o tamanho contra o anunciado e só então confirma — o servidor apaga o objeto do S3 na hora. Uma falha (rede, tamanho divergente, 60 s sem bytes novos, cancelamento) apaga o `.part`, **não confirma** e o download pode ser tentado de novo. Depois de confirmado, um novo download lança `TasksApiError` 410 `arquivo "x" já baixado ou expirado`; arquivo que não existe na execução, 404 `arquivo "x" não encontrado nesta execução`. Se a confirmação falhar (o arquivo já está no disco), o download dá certo e sai um aviso no log da execução — o servidor apaga o objeto quando ele expirar.
- `storage: 'local'` (escolhido no Studio): o arquivo **nunca sai do computador**. O Studio o deixa em `$HOBOTS_LOCAL_FILES_DIR/<id>/<nome>`; o SDK confere que o id é um uuid e que a pasta resolvida fica dentro do diretório, copia o único arquivo regular dela (links simbólicos não contam), confere o tamanho e apaga a pasta de origem. Sem a env ou sem a pasta (bot em outro computador, arquivo já consumido): `arquivo "x" enviado pelo Studio de outro computador ou já consumido`.

**Destino.** Sem `dest`, a cópia vai para uma subpasta própria dentro da pasta temporária da execução, **apagada quando a execução termina** (arquivo de cliente não fica esquecido no disco). `dest` que é uma pasta existente — ou termina em `/` — recebe o arquivo com o nome original (só o último segmento, sem caracteres proibidos no Windows); qualquer outro caminho é o arquivo final (as pastas intermediárias são criadas).

**Cancelamento.** `download`/`downloadAll`/`read` respeitam `hobots.signal`: cancelada a execução, lançam `RunCanceled` e o arquivo não é consumido. Fora de uma execução, `list()` é vazio e os downloads rejeitam.

`isFileRef(valor)` (export do pacote, também `hobots.isFileRef`) reconhece a forma de um `FileRef` — útil para percorrer params aninhados.

## Imagens (screenshots)

Um print vai **junto do evento** que ele ilustra — a linha de log, o item ou a step — para o
usuário ver o estado do robô no momento em que aquilo aconteceu (botão de imagem no app):

```js
// na linha de log: hobots.image(...) como ÚLTIMO argumento
hobots.log.info('portal após o login', hobots.image(await page.screenshot()));
hobots.log.error('login falhou', hobots.image(await page.screenshot(), { caption: 'tela do erro' }));

// no item
hobots.items.failed('nota rejeitada', { numero }, { image: await page.screenshot() });
await hobots.items.each(notas, async (nota, item) => {
  …
  item.occurrence('sem fatura no mês', undefined, { image: await page.screenshot() });
});

// na step
const step = hobots.transaction.startChild('emitir', numero);
try {
  await emitir(numero);
} catch (erro) {
  step.setError(erro, undefined, { image: await page.screenshot() });
  throw erro;
} finally {
  step.finish();
}

// numa issue (módulo Issues) — o print vai na linha de log que espelha o erro
hobots.captureException(erro, { attachment: hobots.image(await page.screenshot(), { caption: 'tela no momento do erro' }) });
```

O SDK não tira o print: aceita bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`) ou um caminho de arquivo, crus ou embrulhados por `hobots.image(bytes, { caption?, contentType? })` (só o embrulho leva legenda; em `log.*` ele é obrigatório para distinguir a imagem de um argumento comum). O evento entra na hora com o id do anexo; o upload sobe em segundo plano direto para o S3 (URL presignada) e **nunca lança nem bloqueia** — falhas viram um aviso no log da execução. Limites: PNG/JPEG/WEBP, 5 MB, 20 por execução, 4 por evento. Fora de uma execução não há onde pendurar a imagem (descartada com aviso).

## Cancelamento cooperativo

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`:

- `hobots.items.*`, `hobots.progress.total/advance/set`, `hobots.transaction.startChild()`, `hobots.sleep()` e cada tentativa de `hobots.retry()`.
- **Nunca `hobots.log.*`** — log em `finally` de limpeza não pode explodir.

```js
hobots.register(async params => {
  const browser = await abrir();
  try {
    for (const nota of params.notas) {
      await processar(nota);
      hobots.items.succeeded({ numero_nota: nota }, { id: nota }); // para aqui se cancelada
    }
  } finally {
    hobots.log.info('fechando navegador'); // seguro: log nunca lança
    await browser.close();
  }
});
```

`hobots.throwIfCanceled()` e `hobots.signal` (um `AbortSignal`, útil para passar a `fetch`) ficam para controle fino. O `RunCanceled` de um cancelamento vindo do app é suprimido — nada é reportado por cima. Se o handler ignorar o signal por `cancelGraceMs` (10 s), o SDK libera o slot e o handler segue em segundo plano, com todos os reportes descartados.

> Não use `if (hobots.signal.aborted) return;` — retornar reporta `succeeded`. É preciso lançar.

## Retry

O handler que lança é **re-invocado na mesma execução**: aviso no log da execução (`tentativa 1/2 falhou: <erro> — nova tentativa em 5000ms`), espera cancelável, mesmos `params`. Default `retries: 1` (2 tentativas), `retryDelayMs: 5_000` (ms ou `(tentativa) => ms`). Esgotadas → `failed` com o último erro.

Nunca há retry em `RunCanceled`, `TaskTimeout` (o timeout é por tentativa e terminal) ou execução já cancelada no app. Itens e progresso já enviados permanecem — o handler precisa ser **idempotente** (use o `id` de negócio nos itens).

Para uma chamada instável dentro do handler:

```js
const dados = await hobots.retry(() => portal.buscar(nota), { retries: 3, delayMs: attempt => attempt * 500, shouldRetry: e => e.code !== 'ENOTFOUND' });
```

`hobots.retry` usa o signal da execução: cancelada, ele lança `RunCanceled` em vez de insistir. Fora de uma execução funciona igual, sem cancelamento.

## Concorrência (`register`)

`concurrency` (1..5, default 1) é **do agente**: quantas solicitações ele atende ao mesmo tempo. O agente só pede à plataforma o que consegue rodar (`max` = slots livres, teto 5) e entra em **drain mode** — re-pesquisa imediatamente enquanto vem trabalho; sem trabalho, espera `pollIntervalMs`.

## Timeout

`timeoutMs` (default: nenhum) é por tentativa e **client-side**: no estouro o SDK dispara o signal, libera o slot e reporta `failed`/`TaskTimeout` — sem nova tentativa. O handler não é morto; respeite o signal para parar de verdade.

## Idempotência (at-least-once até `running`)

Se o agente morrer logo após capturar uma execução (antes de reportar `running`), ela é re-entregue quando um agente voltar (até 3 tentativas; depois `AgentLost`). A partir de `running`, a morte do agente **não** re-entrega — a execução vira `AgentTerminated`, porque o trabalho pode ter efeitos parciais. Somado ao retry do handler, a regra é uma: processar a mesma coisa duas vezes não pode duplicar efeito.

## Encerramento inesperado

Sem handler próprio de `SIGINT`/`SIGTERM` (ou em erro fatal), o SDK reporta as execuções ativas como `failed`/`AgentTerminated` antes de sair. Com handler próprio, chame `hobots.close()` nele. Se o processo morrer sem reportar, o servidor marca a execução após ~120 s sem sinal de vida.
