hobots 1.9.2 → 2.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/README.md CHANGED
@@ -260,6 +260,7 @@ interface RunContext {
260
260
  runId: string;
261
261
  task: string;
262
262
  log: RunLogger; // ctx.log.debug/info/warn/error/success(message)
263
+ items: RunItems; // ctx.items.succeeded/failed/occurrence — a entrega do bot
263
264
  tx: Transaction; // transaction desta run — correlacionada e finalizada pelo SDK
264
265
  signal: AbortSignal;
265
266
  throwIfCanceled(): void; // lança RunCanceled se o signal já disparou
@@ -269,6 +270,7 @@ interface RunContext {
269
270
 
270
271
  - **`params`** — os dados do formulário configurado no app (JSON validado). Tipável: `Hobots.register<{ lote: number }>(...)`.
271
272
  - **`ctx.log.<level>(message)`** — níveis `debug`, `info`, `warn`, `error` e `success`; logs enviados **incrementalmente** ao app (PATCHes com throttle), visíveis na solicitação quase em tempo real; também impressos no terminal local, coloridos por nível.
273
+ - **`ctx.items`** — registra os **itens que a execução processou/gerou** (a entrega do bot: notas fiscais baixadas, boletos emitidos…), cada um com status `succeeded`, `failed` (falha técnica) ou `occurrence` (ocorrência de negócio, ex.: "nota nº 10 não encontrada") e um `payload` de atributos livres. Aparecem na aba **itens** da solicitação, com totalizadores; as colunas exibidas são configuradas por formulário no app. Guia: [Itens de execução](./docs/tasks.md#itens-de-execução-ctxitems).
272
274
  - **`ctx.tx`** — a transaction de performance desta run, já correlacionada e finalizada pelo SDK. Use `ctx.tx.startChild(op, description)` para medir as etapas — elas viram o waterfall da execução no app. `startTransaction()` chamado dentro do handler retorna essa mesma transaction. Conceito: [ctx.tx](./docs/concepts.md#transaction-de-run-ctxtx).
273
275
  - **`ctx.signal`** — `AbortSignal` que dispara quando a run é **cancelada no app**, em **timeout** ou no **shutdown**.
274
276
  - **`ctx.throwIfCanceled()`** — o ponto de parada de uma linha; lança `RunCanceled` se o `signal` já disparou.
@@ -278,6 +280,25 @@ interface RunContext {
278
280
 
279
281
  > **Dados sensíveis:** `ctx.log` e mensagens/stack de erros **são enviados ao servidor** e ficam visíveis no app. Não inclua senhas, tokens, dados pessoais ou qualquer informação sensível em logs e mensagens de erro.
280
282
 
283
+ ### Itens de execução (`ctx.items`)
284
+
285
+ ```js
286
+ Hobots.register('baixar-notas', async (params, ctx) => {
287
+ for (const nota of await listarNotas(params)) {
288
+ try {
289
+ await baixar(nota);
290
+ ctx.items.succeeded({ numero_nota: nota.numero, cnpj_prestador: nota.cnpj }, { id: nota.numero });
291
+ } catch (erro) {
292
+ ctx.items.failed(String(erro), { numero_nota: nota.numero }, { id: nota.numero });
293
+ }
294
+ }
295
+ // ocorrência de negócio (não é falha do bot):
296
+ // ctx.items.occurrence('nota nº 10 não encontrada', { numero_nota: '10' }, { id: '10' })
297
+ });
298
+ ```
299
+
300
+ Chamadas síncronas e bufferizadas (lotes de até 500; dreno antes do reporte final). O `id` opcional é a identidade de negócio do item na run — repetido, é ignorado pelo servidor (registro único, retry idempotente). Limites: 10.000 itens/run, payload 16 KB, mensagem 2.000 caracteres. Detalhes em [Itens de execução](./docs/tasks.md#itens-de-execução-ctxitems).
301
+
281
302
  ### Anexos (screenshots)
282
303
 
283
304
  Texto de log conta o que o robô fez; um print conta **como estava a tela**. Os métodos de captura (`Hobots.captureException` e `Hobots.captureMessage`) aceitam um `attachment` no hint: dentro de uma execução de task, a imagem vira anexo da linha de log da run que espelha a captura — no app ela aparece como miniatura logo abaixo dessa linha, clicável para ampliar.
package/dist/index.cjs CHANGED
@@ -161,7 +161,7 @@ function scrubSecrets(text) {
161
161
 
162
162
  // src/utils.ts
163
163
  var SDK_NAME = "hobots";
164
- var SDK_VERSION = true ? "1.9.1" : "0.0.0-dev";
164
+ var SDK_VERSION = true ? "2.0.0" : "0.0.0-dev";
165
165
  function isNode() {
166
166
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
167
167
  }
@@ -354,7 +354,8 @@ var Transport = class {
354
354
  return;
355
355
  }
356
356
  if (this.buffer.length >= this.maxQueueSize) {
357
- this.buffer.shift();
357
+ const dropIndex = this.buffer.findIndex((queued) => queued.type !== "heartbeat");
358
+ this.buffer.splice(dropIndex === -1 ? 0 : dropIndex, 1);
358
359
  this.logger.warn("fila cheia, item mais antigo descartado");
359
360
  }
360
361
  this.buffer.push(item);
@@ -445,6 +446,8 @@ function runtimeContexts() {
445
446
  }
446
447
  var Client = class {
447
448
  logger;
449
+ /** Eco de lifecycle (Transaction.emit) e afins só imprimem no terminal com debug ligado. */
450
+ debug;
448
451
  // campos privados + getters de protótipo: o clientSecret não aparece em
449
452
  // JSON.stringify/serialização enumerável do Client (ex.: um objeto capturado
450
453
  // que referencie o client), mas client.options/config seguem funcionando
@@ -453,7 +456,8 @@ var Client = class {
453
456
  transport;
454
457
  constructor(options, config = resolveConfig(options)) {
455
458
  this.#options = options;
456
- this.logger = makeLogger(!!options.debug);
459
+ this.debug = !!options.debug;
460
+ this.logger = makeLogger(this.debug);
457
461
  this.#config = config;
458
462
  this.transport = new Transport({
459
463
  endpoint: this.#config.ingestEndpoint,
@@ -540,6 +544,8 @@ var Client = class {
540
544
 
541
545
  // src/heartbeat.ts
542
546
  var DEFAULT_INTERVAL_MS = 3e4;
547
+ var MIN_REPORTED_INTERVAL_MS = 1e3;
548
+ var MAX_REPORTED_INTERVAL_MS = 9e5;
543
549
  var Heartbeat = class {
544
550
  constructor(client2, options = {}, instanceId) {
545
551
  this.client = client2;
@@ -556,12 +562,22 @@ var Heartbeat = class {
556
562
  start() {
557
563
  if (this.timer) return;
558
564
  this.beat();
559
- this.timer = setInterval(() => this.beat(), this.intervalMs);
565
+ this.schedule();
566
+ }
567
+ /** Jitter de ±10%: uma frota iniciada junto não sincroniza todos os beats no mesmo segundo. */
568
+ schedule() {
569
+ this.timer = setTimeout(
570
+ () => {
571
+ this.beat();
572
+ this.schedule();
573
+ },
574
+ this.intervalMs * (0.9 + Math.random() * 0.2)
575
+ );
560
576
  unrefTimer(this.timer);
561
577
  }
562
578
  stop() {
563
579
  if (this.timer) {
564
- clearInterval(this.timer);
580
+ clearTimeout(this.timer);
565
581
  this.timer = void 0;
566
582
  }
567
583
  }
@@ -569,7 +585,7 @@ var Heartbeat = class {
569
585
  this.client.sendHeartbeat({
570
586
  instance_id: this.instanceId,
571
587
  name: this.name,
572
- interval_ms: this.intervalMs,
588
+ interval_ms: Math.min(Math.max(this.intervalMs, MIN_REPORTED_INTERVAL_MS), MAX_REPORTED_INTERVAL_MS),
573
589
  timestamp: nowISO(),
574
590
  metadata: this.metadata
575
591
  });
@@ -816,15 +832,18 @@ var Transaction = class {
816
832
  // antes do RunReporter conectar) — replay no setLifecycleSink
817
833
  lifecycleBacklog = [];
818
834
  /**
819
- * Ecoa no terminal o início/fim de uma step ou transaction (sempre, independente de
820
- * sampling ou do debug) e espelha a linha no sink de lifecycle, se conectado — é
821
- * assim que os eventos de performance também aparecem nos logs da run no app.
835
+ * Registra o início/fim de uma step ou transaction: espelha a linha no sink de
836
+ * lifecycle, se conectado (é assim que os eventos de performance aparecem nos
837
+ * logs da run no app), e ecoa no terminal apenas com `debug: true` — o SDK não
838
+ * escreve no stdout do processo do usuário sem permissão.
822
839
  */
823
840
  emit(kind, phase, label, startTs, endTs, status) {
824
841
  const durationMs = startTs && endTs ? new Date(endTs).getTime() - new Date(startTs).getTime() : void 0;
825
- const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
826
842
  const suffix = durationMs != null ? ` (${durationMs}ms)` : "";
827
- console.log(`${tag} ${label}${suffix}`);
843
+ if (this.client?.debug) {
844
+ const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
845
+ console.log(`${tag} ${label}${suffix}`);
846
+ }
828
847
  const level = phase === "finish" ? levelForStatus(status ?? "ok") : "info";
829
848
  const message = `[${kind}] ${phase.toUpperCase()} ${label}${suffix}`;
830
849
  if (this.lifecycleSink) this.lifecycleSink(level, message);
@@ -987,6 +1006,38 @@ var TasksApi = class {
987
1006
  signal: AbortSignal.timeout(timeoutMs)
988
1007
  });
989
1008
  }
1009
+ /**
1010
+ * Envia um lote de itens de execução. Retry em falha de rede/5xx/429-transporte;
1011
+ * 4xx é definitivo (404 = servidor sem o recurso, 429 = cap de itens por run) —
1012
+ * o RunReporter trata por status.
1013
+ */
1014
+ async createItems(runId, request) {
1015
+ let response;
1016
+ try {
1017
+ response = await fetchWithRetry(
1018
+ () => fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/items`, {
1019
+ method: "POST",
1020
+ headers: this.headers(),
1021
+ body: JSON.stringify(request),
1022
+ signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
1023
+ }),
1024
+ { logger: this.logger, label: `itens da run ${runId}` }
1025
+ );
1026
+ } catch (error) {
1027
+ if (error instanceof Error && error.message === "HTTP 429") {
1028
+ throw new TasksApiError("envio de itens rejeitado (HTTP 429)", 429);
1029
+ }
1030
+ throw new Error(`envio de itens da run ${runId} descartado ap\xF3s retries: ${String(error)}`);
1031
+ }
1032
+ if (!response.ok) {
1033
+ throw new TasksApiError(`envio de itens rejeitado (HTTP ${response.status})`, response.status);
1034
+ }
1035
+ const body = await response.json().catch(() => null);
1036
+ return {
1037
+ accepted: typeof body?.accepted === "number" ? body.accepted : request.items.length,
1038
+ duplicates: typeof body?.duplicates === "number" ? body.duplicates : 0
1039
+ };
1040
+ }
990
1041
  /**
991
1042
  * Registra o anexo e obtém a URL presignada de upload. 404/501/503 significam
992
1043
  * "esta instalação não tem anexos" — o RunReporter degrada em silêncio a partir daí.
@@ -1144,6 +1195,22 @@ var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
1144
1195
  var MAX_LOG_MESSAGE_CHARS = 1e4;
1145
1196
  var MAX_LOGS_PER_PATCH = 100;
1146
1197
  var FLUSH_AT_BUFFERED = 20;
1198
+ var MAX_ITEMS_PER_RUN = 1e4;
1199
+ var MAX_ITEMS_PER_REQUEST = 500;
1200
+ var MAX_ITEM_MESSAGE_CHARS = 2e3;
1201
+ var MAX_ITEM_PAYLOAD_BYTES = 16384;
1202
+ var ITEMS_FLUSH_AT_BUFFERED = 100;
1203
+ var ITEMS_DRAIN_MS = 15e3;
1204
+ function scrubPayloadValue(value, depth) {
1205
+ if (typeof value === "string") return scrubSecrets(value);
1206
+ if (depth >= 4 || value === null || typeof value !== "object") return value;
1207
+ if (Array.isArray(value)) return value.map((entry) => scrubPayloadValue(entry, depth + 1));
1208
+ const out = {};
1209
+ for (const [key, entry] of Object.entries(value)) {
1210
+ out[key] = scrubPayloadValue(entry, depth + 1);
1211
+ }
1212
+ return out;
1213
+ }
1147
1214
  var RunReporter = class {
1148
1215
  constructor(api, runId, task, logger, tx, flushIntervalMs, cancelCheckIntervalMs) {
1149
1216
  this.api = api;
@@ -1162,11 +1229,18 @@ var RunReporter = class {
1162
1229
  success: (message) => this.log("success", message)
1163
1230
  };
1164
1231
  tx.setLifecycleSink((level, message) => this.push(level, message));
1232
+ const items = {
1233
+ add: (item) => this.addItem(item.status, item.message, item.payload, item.id),
1234
+ succeeded: (payload, options) => this.addItem("succeeded", void 0, payload, options?.id),
1235
+ failed: (message, payload, options) => this.addItem("failed", message, payload, options?.id),
1236
+ occurrence: (message, payload, options) => this.addItem("occurrence", message, payload, options?.id)
1237
+ };
1165
1238
  const signal = this.abortController.signal;
1166
1239
  this.context = {
1167
1240
  runId,
1168
1241
  task,
1169
1242
  log,
1243
+ items,
1170
1244
  tx,
1171
1245
  signal,
1172
1246
  throwIfCanceled: () => {
@@ -1185,6 +1259,23 @@ var RunReporter = class {
1185
1259
  abortController = new AbortController();
1186
1260
  flushIntervalMs;
1187
1261
  cancelCheckIntervalMs;
1262
+ // Itens de execução vivem FORA de `this.chain` (status/liveness não pode ficar
1263
+ // atrás de milhares de itens), mas têm cadeia própria serializada — ordem de
1264
+ // envio preservada e sem lotes sobrepostos.
1265
+ itemsBuffer = [];
1266
+ itemsChain = Promise.resolve();
1267
+ itemsTimer;
1268
+ itemSeq = 0;
1269
+ itemCount = 0;
1270
+ /** Servidor sem o recurso (404) — para de tentar (e de avisar) nesta run. */
1271
+ itemsUnsupported = false;
1272
+ /** Cap de itens por run atingido (client-side ou 429) — degrada com um aviso só. */
1273
+ itemsQuotaExceeded = false;
1274
+ /**
1275
+ * Só o kill() do exit path fecha os itens — `dead` (run cancelada/finalizada no
1276
+ * servidor) NÃO fecha: o servidor aceita itens de run terminal (trabalho já feito).
1277
+ */
1278
+ itemsClosed = false;
1188
1279
  // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
1189
1280
  // status/logs e não pode ficar bloqueada esperando um binário subir.
1190
1281
  pendingAttachments = /* @__PURE__ */ new Set();
@@ -1211,6 +1302,12 @@ var RunReporter = class {
1211
1302
  this.dead = true;
1212
1303
  this.clearFlushTimer();
1213
1304
  this.buffer = [];
1305
+ this.itemsClosed = true;
1306
+ this.clearItemsTimer();
1307
+ if (this.itemsBuffer.length > 0) {
1308
+ this.logger.warn(`${this.itemsBuffer.length} item(ns) de execu\xE7\xE3o descartados (processo encerrando)`);
1309
+ this.itemsBuffer = [];
1310
+ }
1214
1311
  const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
1215
1312
  for (const resume of waiting) resume();
1216
1313
  }
@@ -1313,6 +1410,120 @@ var RunReporter = class {
1313
1410
  finalize() {
1314
1411
  return this.chain;
1315
1412
  }
1413
+ // ── Itens de execução ────────────────────────────────────────────────────────
1414
+ /**
1415
+ * Bufferiza um item (síncrono — a chamada nunca espera rede). Diferente do PATCH
1416
+ * de status, o servidor aceita itens mesmo com a run cancelada (trabalho já feito),
1417
+ * então um reporter morto não descarta: só um kill() de exit path descarta.
1418
+ */
1419
+ addItem(status, message, payload, id) {
1420
+ if (this.itemsClosed || this.itemsUnsupported) return;
1421
+ if (this.itemCount >= MAX_ITEMS_PER_RUN) {
1422
+ if (!this.itemsQuotaExceeded) {
1423
+ this.itemsQuotaExceeded = true;
1424
+ this.log("warning", `itens descartados: limite de ${MAX_ITEMS_PER_RUN} por execu\xE7\xE3o atingido`);
1425
+ }
1426
+ return;
1427
+ }
1428
+ this.itemCount++;
1429
+ let scrubbedPayload;
1430
+ if (payload) {
1431
+ scrubbedPayload = scrubPayloadValue(payload, 0);
1432
+ let serializedLength;
1433
+ try {
1434
+ serializedLength = JSON.stringify(scrubbedPayload).length;
1435
+ } catch {
1436
+ }
1437
+ if (serializedLength === void 0 || serializedLength > MAX_ITEM_PAYLOAD_BYTES) {
1438
+ this.logger.warn(
1439
+ `payload do item descartado (${serializedLength === void 0 ? "n\xE3o serializ\xE1vel" : `${serializedLength} bytes > ${MAX_ITEM_PAYLOAD_BYTES}`}) \u2014 o item foi mantido`
1440
+ );
1441
+ scrubbedPayload = void 0;
1442
+ }
1443
+ }
1444
+ this.itemsBuffer.push({
1445
+ id: id ? truncate(String(id), 255) : uuid4(),
1446
+ seq: this.itemSeq++,
1447
+ status,
1448
+ ...message ? { message: truncate(scrubSecrets(message), MAX_ITEM_MESSAGE_CHARS) } : {},
1449
+ ...scrubbedPayload ? { payload: scrubbedPayload } : {},
1450
+ ts: nowISO()
1451
+ });
1452
+ if (this.itemsBuffer.length >= ITEMS_FLUSH_AT_BUFFERED) {
1453
+ this.flushItems();
1454
+ } else {
1455
+ this.scheduleItemsFlush();
1456
+ }
1457
+ }
1458
+ scheduleItemsFlush() {
1459
+ if (this.itemsTimer) return;
1460
+ this.itemsTimer = setTimeout(() => {
1461
+ this.itemsTimer = void 0;
1462
+ this.flushItems();
1463
+ }, this.flushIntervalMs);
1464
+ unrefTimer(this.itemsTimer);
1465
+ }
1466
+ clearItemsTimer() {
1467
+ if (this.itemsTimer) {
1468
+ clearTimeout(this.itemsTimer);
1469
+ this.itemsTimer = void 0;
1470
+ }
1471
+ }
1472
+ /** Envia o buffer de itens em lotes serializados na cadeia própria. */
1473
+ flushItems() {
1474
+ this.clearItemsTimer();
1475
+ if (this.itemsBuffer.length === 0 || this.itemsUnsupported) return;
1476
+ const items = this.itemsBuffer.splice(0, this.itemsBuffer.length);
1477
+ for (let i = 0; i < items.length; i += MAX_ITEMS_PER_REQUEST) {
1478
+ const batch = items.slice(i, i + MAX_ITEMS_PER_REQUEST);
1479
+ this.itemsChain = this.itemsChain.then(() => this.sendItems(batch));
1480
+ }
1481
+ }
1482
+ async sendItems(batch) {
1483
+ if (this.itemsUnsupported) return;
1484
+ try {
1485
+ await this.api.createItems(this.runId, { items: batch });
1486
+ } catch (error) {
1487
+ if (error instanceof TasksApiError && error.status === 404) {
1488
+ this.itemsUnsupported = true;
1489
+ this.logger.warn("itens de execu\xE7\xE3o n\xE3o s\xE3o suportados por este servidor Hobots \u2014 descartados");
1490
+ return;
1491
+ }
1492
+ if (error instanceof TasksApiError && error.status === 429) {
1493
+ if (!this.itemsQuotaExceeded) {
1494
+ this.itemsQuotaExceeded = true;
1495
+ this.log("warning", "itens descartados: limite de itens por execu\xE7\xE3o atingido no servidor");
1496
+ }
1497
+ return;
1498
+ }
1499
+ if (error instanceof TasksApiError) {
1500
+ this.logger.error(`lote de ${batch.length} item(ns) rejeitado pelo servidor:`, error);
1501
+ return;
1502
+ }
1503
+ this.itemsBuffer.unshift(...batch);
1504
+ this.scheduleItemsFlush();
1505
+ this.logger.error(`lote de ${batch.length} item(ns) n\xE3o enviado (devolvido ao buffer):`, error);
1506
+ }
1507
+ }
1508
+ /**
1509
+ * Dreno final de itens antes do PATCH terminal (com teto): garante que os
1510
+ * totalizadores estejam completos quando a run vira succeeded/failed. Como o
1511
+ * servidor aceita itens tardios, perder a corrida aqui não perde dados.
1512
+ */
1513
+ async finalizeItems(timeoutMs = ITEMS_DRAIN_MS) {
1514
+ this.flushItems();
1515
+ await Promise.race([
1516
+ this.itemsChain,
1517
+ new Promise((resolve) => unrefTimer(setTimeout(resolve, timeoutMs)))
1518
+ ]);
1519
+ if (this.itemsBuffer.length > 0 && !this.itemsUnsupported && !this.itemsQuotaExceeded) {
1520
+ this.flushItems();
1521
+ await Promise.race([
1522
+ this.itemsChain,
1523
+ new Promise((resolve) => unrefTimer(setTimeout(resolve, timeoutMs)))
1524
+ ]);
1525
+ }
1526
+ }
1316
1527
  // ── Anexos ───────────────────────────────────────────────────────────────────
1317
1528
  /**
1318
1529
  * Registra a linha de log com o id do anexo (síncrono, na ordem certa) e dispara
@@ -1679,13 +1890,17 @@ var TaskAgent = class {
1679
1890
  try {
1680
1891
  const response = await this.api.poll(
1681
1892
  {
1682
- agent: {
1893
+ instance: {
1683
1894
  // identidade canônica do agente no painel — o mesmo instanceId do
1684
1895
  // heartbeat da Telemetria, então os dois canais convergem em um agente
1685
1896
  instance_id: this.instanceId,
1686
1897
  version: SDK_VERSION,
1687
1898
  host: hostname(),
1688
- tasks: [...this.registry.keys()]
1899
+ tasks: [...this.registry.keys()],
1900
+ // o servidor calcula a tolerância de offline (3×) a partir disto em
1901
+ // agentes poll-only — sem o campo, um poll lento flaparia offline/online.
1902
+ // Clampado à faixa aceita pelo schema do poll (1s..15min).
1903
+ poll_interval_ms: Math.min(Math.max(this.pollIntervalMs, 1e3), 9e5)
1689
1904
  },
1690
1905
  max: Math.min(MAX_CONCURRENCY, this.concurrency - this.activeRuns.size),
1691
1906
  tasks: [...this.registry.keys()]
@@ -1757,12 +1972,14 @@ var TaskAgent = class {
1757
1972
  () => this.invokeWithTimeout(handler, run, reporter)
1758
1973
  );
1759
1974
  await reporter.finalizeAttachments();
1975
+ await reporter.finalizeItems();
1760
1976
  tx.setStatus("ok");
1761
1977
  tx.finish();
1762
1978
  reporter.reportSuccess();
1763
1979
  this.logger.debug(`run ${run.id} conclu\xEDda`);
1764
1980
  } catch (error) {
1765
1981
  await reporter.finalizeAttachments();
1982
+ await reporter.finalizeItems();
1766
1983
  tx.setStatus("error");
1767
1984
  tx.finish();
1768
1985
  reporter.reportFailure(toRunError(error));