hobots 1.8.1 → 1.9.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
@@ -264,6 +264,7 @@ interface RunContext {
264
264
  signal: AbortSignal;
265
265
  throwIfCanceled(): void; // lança RunCanceled se o signal já disparou
266
266
  sleep(ms: number): Promise<void>; // espera cancelável — rejeita com RunCanceled no abort
267
+ attach(image, options?): Promise<AttachResult>; // anexa um print a uma linha de log
267
268
  }
268
269
  ```
269
270
 
@@ -273,11 +274,43 @@ interface RunContext {
273
274
  - **`ctx.signal`** — `AbortSignal` que dispara quando a run é **cancelada no app**, em **timeout** ou no **shutdown**.
274
275
  - **`ctx.throwIfCanceled()`** — o ponto de parada de uma linha; lança `RunCanceled` se o `signal` já disparou.
275
276
  - **`ctx.sleep(ms)`** — espera que respeita o `signal` (rejeita com `RunCanceled` em vez de dormir até o fim).
277
+ - **`ctx.attach(image, options?)`** — anexa um print da tela à linha de log (veja [Anexos](#anexos-screenshots) abaixo).
276
278
  - **Retorno** — **ignorado**: o SDK não captura nem envia o valor retornado pelo handler (resultados podem conter dados sensíveis e não são coletados).
277
279
  - **Erro lançado** — a run é reportada como `failed` (mensagem cap 10k, stack 20k).
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
+ ### Anexos (screenshots)
284
+
285
+ Texto de log conta o que o robô fez; um print conta **como estava a tela**. `ctx.attach()` anexa uma imagem a uma linha de log — no app ela aparece como miniatura logo abaixo dessa linha, clicável para ampliar.
286
+
287
+ ```js
288
+ Hobots.register('processar-nfe', async (params, ctx) => {
289
+ await entrar();
290
+ await ctx.attach(await page.screenshot(), { caption: 'portal após o login' });
291
+
292
+ try {
293
+ await emitir(params);
294
+ } catch (erro) {
295
+ await ctx.attach(await page.screenshot(), { caption: 'tela no momento do erro', level: 'error' });
296
+ throw erro;
297
+ }
298
+ });
299
+ ```
300
+
301
+ **O SDK não tira o print** — ele recebe a imagem pronta, de onde você quiser (Playwright, Puppeteer, uma lib de captura de desktop, um arquivo em disco). Aceita bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`) ou um caminho de arquivo (só em Node).
302
+
303
+ | Limite | Valor |
304
+ | --- | --- |
305
+ | formatos | PNG, JPEG, WEBP |
306
+ | tamanho por imagem | 5 MB |
307
+ | anexos por execução | 20 |
308
+ | anexos por linha de log | 4 |
309
+
310
+ A linha de log é registrada **na hora**; o upload roda em segundo plano e vai direto para o S3 por URL presignada, sem passar pela API. `attach()` **nunca lança** — falhas viram `{ ok: false, reason }` e um aviso no log da run, então `void ctx.attach(...)` é seguro. Detalhes e a lista de `reason` em [Anexos](./docs/tasks.md#anexos-screenshots).
311
+
312
+ > **Dados sensíveis:** o print é enviado **exatamente como está** — o SDK não borra nem recorta nada, e telas de portal costumam conter CPF, nome e valores. Capture só o necessário. Solicitantes só veem os anexos quando o projeto está com "mostrar logs para solicitantes" ligado, o mesmo gate dos logs.
313
+
281
314
  ### Cancelamento cooperativo
282
315
 
283
316
  Uma run cancelada no app **não mata** o handler — o SDK apenas dispara `ctx.signal`. Chame **`ctx.throwIfCanceled()`** em loops longos e pontos de parada (e troque esperas longas por `ctx.sleep()`):
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.8.0" : "0.0.0-dev";
164
+ var SDK_VERSION = true ? "1.9.0" : "0.0.0-dev";
165
165
  function isNode() {
166
166
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
167
167
  }
@@ -328,6 +328,7 @@ var REQUEST_TIMEOUT_MS = 1e4;
328
328
  var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
329
329
  var DEFAULT_MAX_BATCH_SIZE = 10;
330
330
  var DEFAULT_MAX_QUEUE_SIZE = 200;
331
+ var MAX_ITEMS_PER_ENVELOPE = 50;
331
332
  var Transport = class {
332
333
  endpoint;
333
334
  key;
@@ -377,7 +378,15 @@ var Transport = class {
377
378
  this.timer = void 0;
378
379
  }
379
380
  if (this.buffer.length === 0) return Promise.resolve();
380
- const items = this.buffer.splice(0, this.buffer.length);
381
+ const taken = this.buffer.splice(0, this.buffer.length);
382
+ const sends = [];
383
+ for (let offset = 0; offset < taken.length; offset += MAX_ITEMS_PER_ENVELOPE) {
384
+ sends.push(this.send(taken.slice(offset, offset + MAX_ITEMS_PER_ENVELOPE)));
385
+ }
386
+ return Promise.all(sends).then(() => void 0);
387
+ }
388
+ /** Registra o envio em `this.pending` para que `flush()` aguarde todos os envelopes. */
389
+ send(items) {
381
390
  const envelope = {
382
391
  sdk: { name: SDK_NAME, version: SDK_VERSION },
383
392
  sent_at: nowISO(),
@@ -409,7 +418,7 @@ var Transport = class {
409
418
  this.logger.debug(`envelope enviado (${envelope.items.length} itens)`);
410
419
  return;
411
420
  }
412
- this.logger.error(`envelope rejeitado (HTTP ${response.status}), descartado`);
421
+ this.logger.error(`envelope rejeitado (HTTP ${response.status}), descartado (${envelope.items.length} itens)`);
413
422
  }
414
423
  /** Envia o que estiver na fila e aguarda envios em andamento (até timeoutMs). */
415
424
  async flush(timeoutMs = 5e3) {
@@ -882,6 +891,8 @@ var Transaction = class {
882
891
  // src/tasks/api.ts
883
892
  var POLL_TIMEOUT_MS = 3e4;
884
893
  var UPDATE_TIMEOUT_MS = 1e4;
894
+ var UPLOAD_TIMEOUT_MS = 3e4;
895
+ var UPLOAD_RETRY_DELAYS_MS = [1e3, 4e3];
885
896
  var TasksApiError = class extends Error {
886
897
  constructor(message, status) {
887
898
  super(message);
@@ -976,8 +987,123 @@ var TasksApi = class {
976
987
  signal: AbortSignal.timeout(timeoutMs)
977
988
  });
978
989
  }
990
+ /**
991
+ * Registra o anexo e obtém a URL presignada de upload. 404/501/503 significam
992
+ * "esta instalação não tem anexos" — o RunReporter degrada em silêncio a partir daí.
993
+ */
994
+ async createAttachment(runId, request, signal) {
995
+ const { signal: timeoutSignal, cleanup } = withTimeoutSignal(UPDATE_TIMEOUT_MS, signal);
996
+ let response;
997
+ try {
998
+ response = await fetchWithRetry(
999
+ () => fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments`, {
1000
+ method: "POST",
1001
+ headers: this.headers(),
1002
+ body: JSON.stringify(request),
1003
+ signal: timeoutSignal
1004
+ }),
1005
+ { logger: this.logger, label: `anexo da run ${runId}` }
1006
+ );
1007
+ } finally {
1008
+ cleanup();
1009
+ }
1010
+ if (!response.ok) {
1011
+ throw new TasksApiError(`cria\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
1012
+ }
1013
+ const body = await response.json();
1014
+ if (typeof body?.upload_url !== "string") {
1015
+ throw new TasksApiError("resposta inv\xE1lida do anexo (upload_url ausente)", 0);
1016
+ }
1017
+ return {
1018
+ id: body.id ?? request.id,
1019
+ upload_url: body.upload_url,
1020
+ headers: body.headers ?? { "content-type": request.content_type },
1021
+ expires_in: body.expires_in ?? 900
1022
+ };
1023
+ }
1024
+ /**
1025
+ * PUT do binário direto no S3. Deliberadamente sem `authorization`: a autorização
1026
+ * está na assinatura da URL, e mandar o client secret para fora da API seria vazamento.
1027
+ */
1028
+ async uploadAttachment(url, headers, body, signal) {
1029
+ const { signal: timeoutSignal, cleanup } = withTimeoutSignal(UPLOAD_TIMEOUT_MS, signal);
1030
+ let response;
1031
+ try {
1032
+ response = await fetchWithRetry(
1033
+ () => fetch(url, {
1034
+ method: "PUT",
1035
+ headers,
1036
+ // BodyInit não aceita Uint8Array<ArrayBufferLike> em todos os lib.dom; o buffer é o mesmo
1037
+ body,
1038
+ signal: timeoutSignal
1039
+ }),
1040
+ { delays: UPLOAD_RETRY_DELAYS_MS, logger: this.logger, label: "upload de anexo" }
1041
+ );
1042
+ } finally {
1043
+ cleanup();
1044
+ }
1045
+ if (!response.ok) {
1046
+ throw new TasksApiError(`upload de anexo rejeitado (HTTP ${response.status})`, response.status);
1047
+ }
1048
+ }
1049
+ /** Marca o anexo como enviado (ou falho). Best-effort — a leitura não depende disso. */
1050
+ async confirmAttachment(runId, attachmentId, request) {
1051
+ const response = await fetch(
1052
+ `${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments/${encodeURIComponent(attachmentId)}`,
1053
+ {
1054
+ method: "PATCH",
1055
+ headers: this.headers(),
1056
+ body: JSON.stringify(request),
1057
+ signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
1058
+ }
1059
+ );
1060
+ if (!response.ok) {
1061
+ throw new TasksApiError(`confirma\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
1062
+ }
1063
+ }
979
1064
  };
980
1065
 
1066
+ // src/tasks/attachments.ts
1067
+ var MAX_ATTACHMENT_BYTES = 5242880;
1068
+ var MAX_ATTACHMENTS_PER_RUN = 20;
1069
+ var MAX_CONCURRENT_UPLOADS = 3;
1070
+ var MAX_CAPTION_CHARS = 200;
1071
+ var ATTACHMENT_DRAIN_MS = 15e3;
1072
+ async function readImageFile(path) {
1073
+ if (!isNode()) {
1074
+ throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
1075
+ }
1076
+ const { readFile } = await import("fs/promises");
1077
+ return new Uint8Array(await readFile(path));
1078
+ }
1079
+ function startsWith(bytes, signature, offset = 0) {
1080
+ if (bytes.length < offset + signature.length) return false;
1081
+ return signature.every((byte, index) => bytes[offset + index] === byte);
1082
+ }
1083
+ var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
1084
+ var JPEG_MAGIC = [255, 216, 255];
1085
+ var RIFF_MAGIC = [82, 73, 70, 70];
1086
+ var WEBP_MAGIC = [87, 69, 66, 80];
1087
+ function sniffContentType(bytes) {
1088
+ if (startsWith(bytes, PNG_MAGIC)) return "image/png";
1089
+ if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
1090
+ if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
1091
+ return void 0;
1092
+ }
1093
+ function readPngDimensions(bytes) {
1094
+ if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
1095
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1096
+ const width = view.getUint32(16);
1097
+ const height = view.getUint32(20);
1098
+ if (width === 0 || height === 0) return void 0;
1099
+ return { width, height };
1100
+ }
1101
+ async function toBytes(input) {
1102
+ if (typeof input === "string") return readImageFile(input);
1103
+ if (input instanceof Uint8Array) return input;
1104
+ return new Uint8Array(input);
1105
+ }
1106
+
981
1107
  // src/tasks/context.ts
982
1108
  var LOG_LEVEL_COLOR = {
983
1109
  debug: "gray",
@@ -1046,7 +1172,8 @@ var RunReporter = class {
1046
1172
  throwIfCanceled: () => {
1047
1173
  if (signal.aborted) throw runCanceledError();
1048
1174
  },
1049
- sleep: (ms) => cancelableSleep(ms, signal)
1175
+ sleep: (ms) => cancelableSleep(ms, signal),
1176
+ attach: (image, options) => this.attach(image, options)
1050
1177
  };
1051
1178
  }
1052
1179
  context;
@@ -1059,6 +1186,14 @@ var RunReporter = class {
1059
1186
  abortController = new AbortController();
1060
1187
  flushIntervalMs;
1061
1188
  cancelCheckIntervalMs;
1189
+ // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
1190
+ // status/logs e não pode ficar bloqueada esperando um binário subir.
1191
+ pendingAttachments = /* @__PURE__ */ new Set();
1192
+ attachmentCount = 0;
1193
+ uploadSlots = MAX_CONCURRENT_UPLOADS;
1194
+ uploadQueue = [];
1195
+ /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
1196
+ attachmentsUnsupported = false;
1062
1197
  get isDead() {
1063
1198
  return this.dead;
1064
1199
  }
@@ -1077,6 +1212,8 @@ var RunReporter = class {
1077
1212
  this.dead = true;
1078
1213
  this.clearFlushTimer();
1079
1214
  this.buffer = [];
1215
+ const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
1216
+ for (const resume of waiting) resume();
1080
1217
  }
1081
1218
  /**
1082
1219
  * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
@@ -1094,15 +1231,22 @@ var RunReporter = class {
1094
1231
  this.push(level, scrubbed, true);
1095
1232
  }
1096
1233
  /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
1097
- push(level, message, alreadyScrubbed = false) {
1098
- if (this.dead) return;
1234
+ push(level, message, alreadyScrubbed = false, attachments) {
1235
+ const ts = nowISO();
1236
+ if (this.dead) return ts;
1099
1237
  const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
1100
- this.buffer.push({ ts: nowISO(), level, message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS) });
1238
+ this.buffer.push({
1239
+ ts,
1240
+ level,
1241
+ message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
1242
+ ...attachments && attachments.length > 0 ? { attachments } : {}
1243
+ });
1101
1244
  if (this.buffer.length >= FLUSH_AT_BUFFERED) {
1102
1245
  this.flushLogs();
1103
1246
  } else {
1104
1247
  this.scheduleFlush();
1105
1248
  }
1249
+ return ts;
1106
1250
  }
1107
1251
  scheduleFlush() {
1108
1252
  if (this.timer) return;
@@ -1170,6 +1314,130 @@ var RunReporter = class {
1170
1314
  finalize() {
1171
1315
  return this.chain;
1172
1316
  }
1317
+ // ── Anexos ───────────────────────────────────────────────────────────────────
1318
+ /**
1319
+ * Registra a linha de log com o id do anexo (síncrono, na ordem certa) e dispara
1320
+ * o upload em segundo plano. Nunca lança: todo caminho de erro vira `{ ok: false }`
1321
+ * com um aviso no log da run.
1322
+ */
1323
+ async attach(image, options = {}) {
1324
+ const id = uuid4();
1325
+ const level = options.level ?? "info";
1326
+ const caption = options.caption ? truncate(scrubSecrets(options.caption), MAX_CAPTION_CHARS) : void 0;
1327
+ const message = options.message ?? options.caption ?? "captura de tela";
1328
+ if (this.dead) return { id, ok: false, reason: "canceled" };
1329
+ if (this.attachmentsUnsupported) {
1330
+ this.log(level, message);
1331
+ return { id, ok: false, reason: "unsupported" };
1332
+ }
1333
+ if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
1334
+ this.log(level, message);
1335
+ this.log("warning", `anexo descartado: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
1336
+ return { id, ok: false, reason: "quota" };
1337
+ }
1338
+ this.attachmentCount++;
1339
+ const scrubbed = scrubSecrets(message);
1340
+ printRunLog(this.context.task, level, `${scrubbed} [imagem]`);
1341
+ const logTs = this.push(level, scrubbed, true, [id]);
1342
+ let settle;
1343
+ const result = new Promise((resolve) => {
1344
+ settle = resolve;
1345
+ });
1346
+ const work = this.uploadAttachment(id, image, { caption, logTs, contentType: options.contentType }).then((reason) => settle(reason ? { id, ok: false, reason } : { id, ok: true })).catch(() => settle({ id, ok: false, reason: "upload-failed" }));
1347
+ this.pendingAttachments.add(work);
1348
+ void work.finally(() => this.pendingAttachments.delete(work));
1349
+ return result;
1350
+ }
1351
+ /** Retorna o motivo da falha, ou undefined em sucesso. */
1352
+ async uploadAttachment(id, image, meta) {
1353
+ let bytes;
1354
+ try {
1355
+ bytes = await toBytes(image);
1356
+ } catch (error) {
1357
+ this.log("warning", `anexo n\xE3o p\xF4de ser lido: ${String(error)}`);
1358
+ return "read-failed";
1359
+ }
1360
+ const contentType = sniffContentType(bytes) ?? meta.contentType;
1361
+ if (!contentType) {
1362
+ this.log("warning", "anexo descartado: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
1363
+ return "unsupported-format";
1364
+ }
1365
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
1366
+ this.log(
1367
+ "warning",
1368
+ `anexo descartado: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
1369
+ );
1370
+ return "too-large";
1371
+ }
1372
+ const dimensions = readPngDimensions(bytes);
1373
+ const release = await this.acquireUploadSlot();
1374
+ try {
1375
+ if (this.abortController.signal.aborted || this.dead) return "canceled";
1376
+ const created = await this.api.createAttachment(
1377
+ this.runId,
1378
+ {
1379
+ id,
1380
+ content_type: contentType,
1381
+ size_bytes: bytes.byteLength,
1382
+ log_ts: meta.logTs,
1383
+ ...meta.caption ? { caption: meta.caption } : {},
1384
+ ...dimensions ?? {}
1385
+ },
1386
+ this.abortController.signal
1387
+ );
1388
+ await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
1389
+ await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
1390
+ });
1391
+ return void 0;
1392
+ } catch (error) {
1393
+ if (this.isUnsupported(error)) {
1394
+ this.attachmentsUnsupported = true;
1395
+ this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
1396
+ return "unsupported";
1397
+ }
1398
+ if (error instanceof TasksApiError && error.status === 429) {
1399
+ this.log("warning", "anexo descartado: limite de anexos por execu\xE7\xE3o atingido no servidor");
1400
+ return "quota";
1401
+ }
1402
+ if (this.abortController.signal.aborted) return "canceled";
1403
+ this.log("warning", `falha ao enviar o anexo: ${String(error)}`);
1404
+ void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
1405
+ });
1406
+ return "upload-failed";
1407
+ } finally {
1408
+ release();
1409
+ }
1410
+ }
1411
+ /** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
1412
+ isUnsupported(error) {
1413
+ return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
1414
+ }
1415
+ /** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
1416
+ acquireUploadSlot() {
1417
+ const release = () => {
1418
+ const next = this.uploadQueue.shift();
1419
+ if (next) next();
1420
+ else this.uploadSlots++;
1421
+ };
1422
+ if (this.uploadSlots > 0) {
1423
+ this.uploadSlots--;
1424
+ return Promise.resolve(release);
1425
+ }
1426
+ return new Promise((resolve) => {
1427
+ this.uploadQueue.push(() => resolve(release));
1428
+ });
1429
+ }
1430
+ /**
1431
+ * Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
1432
+ * exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
1433
+ */
1434
+ async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
1435
+ if (this.pendingAttachments.size === 0) return;
1436
+ await Promise.race([
1437
+ Promise.allSettled([...this.pendingAttachments]),
1438
+ new Promise((resolve) => unrefTimer(setTimeout(resolve, timeoutMs)))
1439
+ ]);
1440
+ }
1173
1441
  enqueue(update) {
1174
1442
  if (this.dead) {
1175
1443
  this.salvageLogs(update.logs);
@@ -1479,11 +1747,13 @@ var TaskAgent = class {
1479
1747
  this.logger.debug(`executando run ${run.id} (${run.task})`);
1480
1748
  try {
1481
1749
  await runWithRunContext({ runId: run.id, tx }, () => this.invokeWithTimeout(handler, run, reporter));
1750
+ await reporter.finalizeAttachments();
1482
1751
  tx.setStatus("ok");
1483
1752
  tx.finish();
1484
1753
  reporter.reportSuccess();
1485
1754
  this.logger.debug(`run ${run.id} conclu\xEDda`);
1486
1755
  } catch (error) {
1756
+ await reporter.finalizeAttachments();
1487
1757
  tx.setStatus("error");
1488
1758
  tx.finish();
1489
1759
  reporter.reportFailure(toRunError(error));