hobots 3.3.0 → 3.4.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
@@ -93,12 +93,12 @@ Os dois podem coexistir no mesmo processo: um monitor sempre ligado + tasks sob
93
93
 
94
94
  | Membro | O que faz |
95
95
  | --- | --- |
96
- | `hobots.log.debug/info/warn/error/success(msg, ...rest)` | Log da execução, enviado incrementalmente ao app (e ecoado no terminal). Aceita vários argumentos como `console.log` — `hobots.log.info('nota', numero, 'ok')` — cada um vira texto, unidos por espaço. **Nunca lança** — pode ir em `finally`. |
97
- | `hobots.items.succeeded(payload?, { id }?)` · `.failed(msg, payload?, …)` · `.occurrence(msg, payload?, …)` · `.add({...})` | **A entrega do robô**: cada item processado, com status e atributos (viram colunas no app — declare-as com a opção `items: { columns: [...] }` do `register`/`start`). Bufferizado, em lotes. |
96
+ | `hobots.log.debug/info/warn/error/success(msg, ...rest)` | Log da execução, enviado incrementalmente ao app (e ecoado no terminal). Aceita vários argumentos como `console.log` — `hobots.log.info('nota', numero, 'ok')` — cada um vira texto, unidos por espaço. Um `hobots.image(...)` como **último** argumento pendura um print na linha. **Nunca lança** — pode ir em `finally`. |
97
+ | `hobots.items.succeeded(payload?, { id, image }?)` · `.failed(msg, payload?, …)` · `.occurrence(msg, payload?, …)` · `.add({...})` | **A entrega do robô**: cada item processado, com status e atributos (viram colunas no app — declare-as com a opção `items: { columns: [...] }` do `register`/`start`). `image` pendura um print no item. Bufferizado, em lotes. |
98
98
  | `hobots.items.each(lista, async (el, item) => …, { id, payload }?)` | **Loop com o contrato garantido**: cada elemento vira step + item; exceção no handler = item `failed` com a mensagem e o loop **segue** até o fim; progresso automático. Só cancelamento/timeout interrompem. |
99
99
  | `hobots.progress.total(n)` · `.advance(k = 1)` · `.set(atual, total?)` · `.current` · `.snapshot` | Barra, percentual e tempo restante na solicitação. Sem `total()` nada é enviado. |
100
- | `hobots.transaction.startChild(op, descrição)` | Uma **step** no waterfall de performance da execução. A transaction da execução já existe e é finalizada pelo SDK. |
101
- | `hobots.attach(imagem, { caption?, level? })` | Pendura um **print** (PNG/JPEG/WEBP, até 5 MB, 20 por execução) na linha de log. Nunca lança nem bloqueia. |
100
+ | `hobots.transaction.startChild(op, descrição)` | Uma **step** no waterfall de performance da execução. A transaction da execução já existe e é finalizada pelo SDK. `step.setStatus(status, msg?, { image })` / `setError(err, msg?, { image })` penduram um print na step. |
101
+ | `hobots.image(bytes \| caminho, { caption?, contentType? })` | Embrulha um **print** (PNG/JPEG/WEBP, até 5 MB, 20 por execução) para ir junto de um log, item ou step. Puro: o upload roda em segundo plano quando o evento é registrado; nunca lança nem bloqueia. |
102
102
  | `hobots.captureException(erro, hint?)` | Abre uma **issue** (módulo Issues). `hint.attachment` anexa um print à linha de log correspondente. |
103
103
  | `hobots.retry(fn, { retries = 3, delayMs = 1_000, shouldRetry? })` | async-retry ciente do cancelamento da execução. `delayMs` aceita função `(tentativa) => ms`. |
104
104
  | `hobots.sleep(ms)` | Espera **cancelável**: rejeita com `RunCanceled` se a execução for cancelada. |
package/dist/index.cjs CHANGED
@@ -34,6 +34,7 @@ __export(src_exports, {
34
34
  close: () => close2,
35
35
  default: () => src_default,
36
36
  hobots: () => hobots,
37
+ image: () => image2,
37
38
  register: () => register2,
38
39
  retry: () => retry,
39
40
  start: () => start2
@@ -95,7 +96,7 @@ function scrubSecrets(text) {
95
96
 
96
97
  // src/utils.ts
97
98
  var SDK_NAME = "hobots";
98
- var SDK_VERSION = true ? "3.3.0" : "0.0.0-dev";
99
+ var SDK_VERSION = true ? "3.4.0" : "0.0.0-dev";
99
100
  function isNode() {
100
101
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
101
102
  }
@@ -377,6 +378,7 @@ var Transport = class {
377
378
  try {
378
379
  response = await fetchWithRetry(
379
380
  () => fetch(this.endpoint, {
381
+ redirect: "error",
380
382
  method: "POST",
381
383
  headers: { "content-type": "application/json", authorization: `Bearer ${this.key}` },
382
384
  body: JSON.stringify(envelope),
@@ -554,7 +556,6 @@ function envBaseUrl() {
554
556
  function isLoopbackHost(hostname2) {
555
557
  return hostname2 === "localhost" || hostname2.endsWith(".localhost") || hostname2.startsWith("127.") || hostname2 === "[::1]" || hostname2 === "::1" || hostname2 === "0.0.0.0";
556
558
  }
557
- var warnedInsecureBaseUrl = false;
558
559
  function validateBaseUrl(raw, prefix) {
559
560
  let url;
560
561
  try {
@@ -568,11 +569,8 @@ function validateBaseUrl(raw, prefix) {
568
569
  if (url.username || url.password) {
569
570
  throw new Error(`${prefix} HOBOTS_API_URL n\xE3o pode conter credenciais na URL \u2014 a autentica\xE7\xE3o \xE9 s\xF3 pelo Client Secret`);
570
571
  }
571
- if (url.protocol === "http:" && !isLoopbackHost(url.hostname) && !warnedInsecureBaseUrl) {
572
- warnedInsecureBaseUrl = true;
573
- console.warn(
574
- `${prefix} HOBOTS_API_URL usa http para o host remoto "${url.hostname}" \u2014 o Client Secret trafega sem TLS`
575
- );
572
+ if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
573
+ throw new Error(`${prefix} HOBOTS_API_URL usa http para o host remoto "${url.hostname}" \u2014 o Client Secret trafegaria sem TLS; use https`);
576
574
  }
577
575
  return raw.replace(/\/+$/, "");
578
576
  }
@@ -936,6 +934,7 @@ var TasksApi = class {
936
934
  let response;
937
935
  try {
938
936
  response = await fetch(`${this.baseUrl}/poll`, {
937
+ redirect: "error",
939
938
  method: "POST",
940
939
  headers: this.headers(),
941
940
  body: JSON.stringify(request),
@@ -959,6 +958,7 @@ var TasksApi = class {
959
958
  let response;
960
959
  try {
961
960
  response = await fetch(`${this.baseUrl}/runs`, {
961
+ redirect: "error",
962
962
  method: "POST",
963
963
  headers: this.headers(),
964
964
  body: JSON.stringify(request),
@@ -1009,6 +1009,7 @@ var TasksApi = class {
1009
1009
  try {
1010
1010
  response = await fetchWithRetry(
1011
1011
  () => fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}`, {
1012
+ redirect: "error",
1012
1013
  method: "PATCH",
1013
1014
  headers: this.headers(),
1014
1015
  body: JSON.stringify(update),
@@ -1030,6 +1031,7 @@ var TasksApi = class {
1030
1031
  */
1031
1032
  async updateRunOnce(runId, update, timeoutMs = 2e3) {
1032
1033
  await fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}`, {
1034
+ redirect: "error",
1033
1035
  method: "PATCH",
1034
1036
  headers: this.headers(),
1035
1037
  body: JSON.stringify(update),
@@ -1046,6 +1048,7 @@ var TasksApi = class {
1046
1048
  try {
1047
1049
  response = await fetchWithRetry(
1048
1050
  () => fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/items`, {
1051
+ redirect: "error",
1049
1052
  method: "POST",
1050
1053
  headers: this.headers(),
1051
1054
  body: JSON.stringify(request),
@@ -1075,6 +1078,7 @@ var TasksApi = class {
1075
1078
  UPDATE_TIMEOUT_MS,
1076
1079
  signal,
1077
1080
  (timeoutSignal) => fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments`, {
1081
+ redirect: "error",
1078
1082
  method: "POST",
1079
1083
  headers: this.headers(),
1080
1084
  body: JSON.stringify(request),
@@ -1107,6 +1111,7 @@ var TasksApi = class {
1107
1111
  UPLOAD_TIMEOUT_MS,
1108
1112
  signal,
1109
1113
  (timeoutSignal) => fetch(url, {
1114
+ redirect: "error",
1110
1115
  method: "PUT",
1111
1116
  headers,
1112
1117
  // BodyInit não aceita Uint8Array<ArrayBufferLike> em todos os lib.dom; o buffer é o mesmo
@@ -1125,6 +1130,7 @@ var TasksApi = class {
1125
1130
  const response = await fetch(
1126
1131
  `${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments/${encodeURIComponent(attachmentId)}`,
1127
1132
  {
1133
+ redirect: "error",
1128
1134
  method: "PATCH",
1129
1135
  headers: this.headers(),
1130
1136
  body: JSON.stringify(request),
@@ -1207,22 +1213,22 @@ var ItemHandle = class {
1207
1213
  sink;
1208
1214
  reported = false;
1209
1215
  status;
1210
- succeeded(payload) {
1211
- this.record("succeeded", void 0, payload);
1216
+ succeeded(payload, options) {
1217
+ this.record("succeeded", void 0, payload, options?.image);
1212
1218
  }
1213
- failed(message, payload) {
1214
- this.record("failed", message, payload);
1219
+ failed(message, payload, options) {
1220
+ this.record("failed", message, payload, options?.image);
1215
1221
  if (this.step && this.step.status !== "error") this.step.setStatus("error", message);
1216
1222
  }
1217
- occurrence(message, payload) {
1218
- this.record("occurrence", message, payload);
1223
+ occurrence(message, payload, options) {
1224
+ this.record("occurrence", message, payload, options?.image);
1219
1225
  if (this.step && this.step.status === "ok") this.step.setStatus("warning", message);
1220
1226
  }
1221
- record(status, message, payload) {
1227
+ record(status, message, payload, image3) {
1222
1228
  this.reported = true;
1223
1229
  this.status = status;
1224
1230
  const merged = { ...this.basePayload ?? {}, ...payload ?? {} };
1225
- this.sink.report(status, message, Object.keys(merged).length > 0 ? merged : void 0, this.id);
1231
+ this.sink.report(status, message, Object.keys(merged).length > 0 ? merged : void 0, this.id, image3);
1226
1232
  }
1227
1233
  };
1228
1234
  function resolveId(options, element, index) {
@@ -1302,6 +1308,27 @@ var MAX_ATTACHMENTS_PER_LOG_ENTRY = WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY;
1302
1308
  var MAX_CONCURRENT_UPLOADS = 3;
1303
1309
  var MAX_CAPTION_CHARS = WIRE_LIMITS.MAX_CAPTION_CHARS;
1304
1310
  var ATTACHMENT_DRAIN_MS = 15e3;
1311
+ var IMAGE_BRAND = /* @__PURE__ */ Symbol.for("hobots.image");
1312
+ var ImageAttachment = class {
1313
+ constructor(input, caption, contentType) {
1314
+ this.input = input;
1315
+ this.caption = caption;
1316
+ this.contentType = contentType;
1317
+ }
1318
+ input;
1319
+ caption;
1320
+ contentType;
1321
+ [IMAGE_BRAND] = true;
1322
+ };
1323
+ function image(input, options = {}) {
1324
+ return new ImageAttachment(input, options.caption, options.contentType);
1325
+ }
1326
+ function isImageAttachment(value) {
1327
+ return typeof value === "object" && value !== null && value[IMAGE_BRAND] === true;
1328
+ }
1329
+ function toImageAttachment(value) {
1330
+ return isImageAttachment(value) ? value : new ImageAttachment(value);
1331
+ }
1305
1332
  async function readImageFile(path) {
1306
1333
  if (!isNode()) {
1307
1334
  throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
@@ -1351,6 +1378,14 @@ function joinLogArgs(message, rest) {
1351
1378
  if (rest.length === 0) return safeString(message);
1352
1379
  return [message, ...rest].map(safeString).join(" ");
1353
1380
  }
1381
+ function splitLogArgs(message, rest) {
1382
+ const args = [message, ...rest];
1383
+ const last = args[args.length - 1];
1384
+ if (!isImageAttachment(last)) return { text: joinLogArgs(message, rest) };
1385
+ const text = args.slice(0, -1);
1386
+ if (text.length === 0) return { text: last.caption !== void 0 ? safeString(last.caption) : "captura de tela", image: last };
1387
+ return { text: joinLogArgs(text[0], text.slice(1)), image: last };
1388
+ }
1354
1389
  function printRunLog(task, level, message) {
1355
1390
  const line = colorize(`[hobots:${task}] ${level.toUpperCase()}`, LOG_LEVEL_COLOR[level]);
1356
1391
  const method2 = level === "warning" ? "warn" : level === "success" ? "info" : level;
@@ -1407,18 +1442,23 @@ var RunReporter = class {
1407
1442
  MAX_CANCEL_CHECK_INTERVAL_MS,
1408
1443
  Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
1409
1444
  );
1445
+ const logAt = (level) => (message, ...rest) => {
1446
+ const { text, image: image3 } = splitLogArgs(message, rest);
1447
+ this.log(level, text, image3);
1448
+ };
1410
1449
  const log = {
1411
- debug: (message, ...rest) => this.log("debug", joinLogArgs(message, rest)),
1412
- info: (message, ...rest) => this.log("info", joinLogArgs(message, rest)),
1413
- warn: (message, ...rest) => this.log("warning", joinLogArgs(message, rest)),
1414
- error: (message, ...rest) => this.log("error", joinLogArgs(message, rest)),
1415
- success: (message, ...rest) => this.log("success", joinLogArgs(message, rest))
1450
+ debug: logAt("debug"),
1451
+ info: logAt("info"),
1452
+ warn: logAt("warning"),
1453
+ error: logAt("error"),
1454
+ success: logAt("success")
1416
1455
  };
1417
1456
  tx.setLifecycleSink((level, message) => this.push(level, message));
1457
+ tx.setAttachmentSink((image3, ts) => this.queueAttachment(image3, ts));
1418
1458
  const checkCanceled = () => this.checkCanceled();
1419
1459
  tx.setStepGuard(checkCanceled);
1420
1460
  const eachSink = {
1421
- report: (status, message, payload, id) => this.addItem(status, message, payload, id),
1461
+ report: (status, message, payload, id, image3) => this.addItem(status, message, payload, id, image3),
1422
1462
  startStep: (op, description) => tx.startChild(op, description),
1423
1463
  declareTotal: (total) => {
1424
1464
  if (this.progressTotal == null) this.setProgress(this.progressCurrent, total);
@@ -1431,19 +1471,19 @@ var RunReporter = class {
1431
1471
  const items = {
1432
1472
  add: (item) => {
1433
1473
  checkCanceled();
1434
- this.addItem(item.status, item.message, item.payload, item.id);
1474
+ this.addItem(item.status, item.message, item.payload, item.id, item.image);
1435
1475
  },
1436
1476
  succeeded: (payload, options) => {
1437
1477
  checkCanceled();
1438
- this.addItem("succeeded", void 0, payload, options?.id);
1478
+ this.addItem("succeeded", void 0, payload, options?.id, options?.image);
1439
1479
  },
1440
1480
  failed: (message, payload, options) => {
1441
1481
  checkCanceled();
1442
- this.addItem("failed", message, payload, options?.id);
1482
+ this.addItem("failed", message, payload, options?.id, options?.image);
1443
1483
  },
1444
1484
  occurrence: (message, payload, options) => {
1445
1485
  checkCanceled();
1446
- this.addItem("occurrence", message, payload, options?.id);
1486
+ this.addItem("occurrence", message, payload, options?.id, options?.image);
1447
1487
  },
1448
1488
  each: (list, handler, options = {}) => eachItem(list, handler, options, eachSink)
1449
1489
  };
@@ -1484,7 +1524,6 @@ var RunReporter = class {
1484
1524
  signal,
1485
1525
  throwIfCanceled: checkCanceled,
1486
1526
  sleep: sleep2,
1487
- attach: (image, options) => this.attach(image, options),
1488
1527
  retry: (fn, options) => retryWithBackoff(fn, { ...options, signal, wait: sleep2 })
1489
1528
  };
1490
1529
  }
@@ -1575,15 +1614,21 @@ var RunReporter = class {
1575
1614
  this.clearFlushTimer();
1576
1615
  return this.takeLogs().slice(-MAX_LOGS_PER_PATCH);
1577
1616
  }
1578
- log(level, message) {
1617
+ /**
1618
+ * Linha de log da run com eco no terminal. `image` (opcional) vira o anexo da linha:
1619
+ * o id entra na entrada ANTES de qualquer I/O — é isso que mantém a ordem dos logs
1620
+ * intacta e o handler livre da latência do upload.
1621
+ */
1622
+ log(level, message, image3) {
1579
1623
  if (this.dead) return;
1580
1624
  const scrubbed = scrubSecrets(safeString(message));
1581
- printRunLog(this.context.task, level, scrubbed);
1582
- this.push(level, scrubbed, true);
1625
+ const ts = nowISO();
1626
+ const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
1627
+ printRunLog(this.context.task, level, attachmentId ? `${scrubbed} [imagem]` : scrubbed);
1628
+ this.push(level, scrubbed, true, attachmentId ? [attachmentId] : void 0, ts);
1583
1629
  }
1584
1630
  /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
1585
- push(level, message, alreadyScrubbed = false, attachments) {
1586
- const ts = nowISO();
1631
+ push(level, message, alreadyScrubbed = false, attachments, ts = nowISO()) {
1587
1632
  if (this.dead) return ts;
1588
1633
  const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
1589
1634
  this.buffer.push({
@@ -1733,7 +1778,7 @@ var RunReporter = class {
1733
1778
  * de status, o servidor aceita itens mesmo com a run cancelada (trabalho já feito),
1734
1779
  * então um reporter morto não descarta: só um kill() de exit path descarta.
1735
1780
  */
1736
- addItem(status, message, payload, id) {
1781
+ addItem(status, message, payload, id, image3) {
1737
1782
  if (this.itemsClosed || this.itemsUnsupported) return;
1738
1783
  if (this.itemCount >= MAX_ITEMS_PER_RUN) {
1739
1784
  if (!this.itemsQuotaExceeded) {
@@ -1758,13 +1803,16 @@ var RunReporter = class {
1758
1803
  scrubbedPayload = void 0;
1759
1804
  }
1760
1805
  }
1806
+ const ts = nowISO();
1807
+ const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
1761
1808
  this.itemsBuffer.push({
1762
1809
  id: id ? truncate(String(id), 255) : uuid4(),
1763
1810
  seq: this.itemSeq++,
1764
1811
  status,
1765
1812
  ...message ? { message: truncate(scrubSecrets(safeString(message)), MAX_ITEM_MESSAGE_CHARS) } : {},
1766
1813
  ...scrubbedPayload ? { payload: scrubbedPayload } : {},
1767
- ts: nowISO()
1814
+ ts,
1815
+ ...attachmentId ? { attachments: [attachmentId] } : {}
1768
1816
  });
1769
1817
  if (this.itemsBuffer.length >= ITEMS_FLUSH_AT_BUFFERED) {
1770
1818
  this.flushItems();
@@ -1844,59 +1892,51 @@ var RunReporter = class {
1844
1892
  }
1845
1893
  // ── Anexos ───────────────────────────────────────────────────────────────────
1846
1894
  /**
1847
- * Registra a linha de log com o id do anexo (síncrono, na ordem certa) e dispara
1848
- * o upload em segundo plano. Nunca lança: todo caminho de erro vira `{ ok: false }`
1849
- * com um aviso no log da run. É o `hobots.attach()`; o hint `attachment` de
1850
- * `hobots.captureException` também cai aqui via run-context-store.
1895
+ * Pendura uma imagem num evento da run (log, item ou step): faz as checagens que não
1896
+ * dependem de I/O (reporter morto, instalação sem anexos, cota), reserva o id e dispara
1897
+ * o upload em segundo plano. Devolve o id — já referenciável pelo evento — ou undefined
1898
+ * quando a imagem foi descartada (sempre com aviso no log da run). Nunca lança: todo
1899
+ * caminho de erro do upload vira aviso. O hint `attachment` de `hobots.captureException`
1900
+ * também cai aqui via `log()`.
1851
1901
  */
1852
- async attach(image, options = {}) {
1853
- const id = uuid4();
1854
- const level = options.level ?? "info";
1855
- const rawCaption = options.caption !== void 0 ? safeString(options.caption) : void 0;
1856
- const rawMessage = options.message !== void 0 ? safeString(options.message) : void 0;
1857
- const caption = rawCaption ? truncate(scrubSecrets(rawCaption), MAX_CAPTION_CHARS) : void 0;
1858
- const message = rawMessage ?? rawCaption ?? "captura de tela";
1859
- if (this.dead) return { id, ok: false, reason: "canceled" };
1860
- if (this.attachmentsUnsupported) {
1861
- this.log(level, message);
1862
- return { id, ok: false, reason: "unsupported" };
1863
- }
1902
+ queueAttachment(imageIn, ts) {
1903
+ if (this.dead) return void 0;
1904
+ const image3 = toImageAttachment(imageIn);
1905
+ if (this.attachmentsUnsupported) return void 0;
1864
1906
  if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
1865
- this.log(level, message);
1866
- this.log("warning", `anexo descartado: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
1867
- return { id, ok: false, reason: "quota" };
1907
+ if (this.attachmentCount === MAX_ATTACHMENTS_PER_RUN) {
1908
+ this.attachmentCount++;
1909
+ this.log("warning", `imagem descartada: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
1910
+ }
1911
+ return void 0;
1868
1912
  }
1869
1913
  this.attachmentCount++;
1870
- const scrubbed = scrubSecrets(message);
1871
- printRunLog(this.context.task, level, `${scrubbed} [imagem]`);
1872
- const logTs = this.push(level, scrubbed, true, [id]);
1873
- let settle;
1874
- const result = new Promise((resolve2) => {
1875
- settle = resolve2;
1876
- });
1877
- 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" }));
1914
+ const id = uuid4();
1915
+ const rawCaption = image3.caption !== void 0 ? safeString(image3.caption) : void 0;
1916
+ const caption = rawCaption ? truncate(scrubSecrets(rawCaption), MAX_CAPTION_CHARS) : void 0;
1917
+ const work = this.uploadAttachment(id, image3.input, { caption, logTs: ts, contentType: image3.contentType }).then(() => void 0).catch(() => void 0);
1878
1918
  this.pendingAttachments.add(work);
1879
1919
  void work.finally(() => this.pendingAttachments.delete(work));
1880
- return result;
1920
+ return id;
1881
1921
  }
1882
1922
  /** Retorna o motivo da falha, ou undefined em sucesso. */
1883
- async uploadAttachment(id, image, meta) {
1923
+ async uploadAttachment(id, image3, meta) {
1884
1924
  let bytes;
1885
1925
  try {
1886
- bytes = await toBytes(image);
1926
+ bytes = await toBytes(image3);
1887
1927
  } catch (error) {
1888
- this.log("warning", `anexo n\xE3o p\xF4de ser lido: ${String(error)}`);
1928
+ this.log("warning", `imagem n\xE3o p\xF4de ser lida: ${String(error)}`);
1889
1929
  return "read-failed";
1890
1930
  }
1891
1931
  const contentType = sniffContentType(bytes) ?? meta.contentType;
1892
1932
  if (!contentType) {
1893
- this.log("warning", "anexo descartado: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
1933
+ this.log("warning", "imagem descartada: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
1894
1934
  return "unsupported-format";
1895
1935
  }
1896
1936
  if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
1897
1937
  this.log(
1898
1938
  "warning",
1899
- `anexo descartado: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
1939
+ `imagem descartada: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
1900
1940
  );
1901
1941
  return "too-large";
1902
1942
  }
@@ -1927,11 +1967,11 @@ var RunReporter = class {
1927
1967
  return "unsupported";
1928
1968
  }
1929
1969
  if (error instanceof TasksApiError && error.status === 429) {
1930
- this.log("warning", "anexo descartado: limite de anexos por execu\xE7\xE3o atingido no servidor");
1970
+ this.log("warning", "imagem descartada: limite de anexos por execu\xE7\xE3o atingido no servidor");
1931
1971
  return "quota";
1932
1972
  }
1933
1973
  if (this.abortController.signal.aborted) return "canceled";
1934
- this.log("warning", `falha ao enviar o anexo: ${String(error)}`);
1974
+ this.log("warning", `falha ao enviar a imagem: ${String(error)}`);
1935
1975
  void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
1936
1976
  });
1937
1977
  return "upload-failed";
@@ -2095,23 +2135,39 @@ var Step = class {
2095
2135
  /**
2096
2136
  * Desfecho da step. `message` (opcional) é o motivo legível — vai em `data.message`
2097
2137
  * e aparece na Central (performance e banner da execução) e no log de FINISH.
2138
+ * `options.image` pendura um print do estado da tela em `data.attachments`.
2098
2139
  */
2099
- setStatus(status, message) {
2140
+ setStatus(status, message, options) {
2100
2141
  this.status = status;
2101
2142
  if (message) this.setData("message", stepMessage(message));
2143
+ if (options?.image !== void 0) this.attachImage(options.image);
2102
2144
  return this;
2103
2145
  }
2104
2146
  /**
2105
2147
  * Marca a step como `error` guardando o motivo em `data.error` ({ type, message }).
2106
2148
  * Aceita qualquer valor lançado; `message` sobrescreve a mensagem do erro.
2149
+ * `options.image` pendura um print do estado da tela em `data.attachments`.
2107
2150
  */
2108
- setError(error, message) {
2151
+ setError(error, message, options) {
2109
2152
  this.status = "error";
2110
2153
  const type = error instanceof Error ? error.name : typeof error === "object" && error !== null ? "Error" : void 0;
2111
2154
  const raw = message ?? (error instanceof Error ? error.message || error.name : String(error));
2112
2155
  this.setData("error", { ...type ? { type } : {}, message: stepMessage(raw) });
2156
+ if (options?.image !== void 0) this.attachImage(options.image);
2113
2157
  return this;
2114
2158
  }
2159
+ /**
2160
+ * Dispara o upload pela run (sink do RunReporter) e referencia o anexo em
2161
+ * `data.attachments` (chave reservada; teto igual ao das linhas de log). Fora de uma
2162
+ * run não há onde pendurar a imagem — descarta com aviso único.
2163
+ */
2164
+ attachImage(image3) {
2165
+ const current = Array.isArray(this.data?.attachments) ? this.data.attachments : [];
2166
+ if (current.length >= MAX_ATTACHMENTS_PER_LOG_ENTRY) return;
2167
+ const id = this.transaction.attachImage(image3);
2168
+ if (!id) return;
2169
+ this.setData("attachments", [...current, id]);
2170
+ }
2115
2171
  /** Motivo registrado via setStatus(…, message) ou setError — usado no log de FINISH. */
2116
2172
  reason() {
2117
2173
  const error = this.data?.error;
@@ -2172,6 +2228,8 @@ var Transaction = class {
2172
2228
  tags = {};
2173
2229
  finished = false;
2174
2230
  lifecycleSink;
2231
+ attachmentSink;
2232
+ warnedNoAttachmentSink = false;
2175
2233
  // instalado pelo RunReporter: abrir uma step é um ponto de parada cooperativo (lança RunCanceled)
2176
2234
  stepGuard;
2177
2235
  // eventos emitidos antes do sink existir (o START da transaction sai no construtor,
@@ -2202,6 +2260,19 @@ var Transaction = class {
2202
2260
  this.lifecycleBacklog = [];
2203
2261
  for (const [level, message] of backlog) sink(level, message);
2204
2262
  }
2263
+ /** Conecta o pipeline de anexos da run — é o que dá imagem às steps. */
2264
+ setAttachmentSink(sink) {
2265
+ this.attachmentSink = sink;
2266
+ }
2267
+ /** Imagem de uma step → id do anexo pela run; sem run, descarta com aviso único. */
2268
+ attachImage(image3) {
2269
+ if (this.attachmentSink) return this.attachmentSink(image3, nowISO());
2270
+ if (!this.warnedNoAttachmentSink) {
2271
+ this.warnedNoAttachmentSink = true;
2272
+ console.warn("[hobots] imagem de step descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o de task");
2273
+ }
2274
+ return void 0;
2275
+ }
2205
2276
  /**
2206
2277
  * Conecta a checagem de cancelamento chamada antes de cada step nova — é assim que
2207
2278
  * `hobots.transaction.startChild()` (e `step.startChild()`) param uma run cancelada.
@@ -2969,13 +3040,8 @@ function forwardAttachment(rt, attachment, message, type) {
2969
3040
  rt.logger.warn("attachment descartado \u2014 s\xF3 \xE9 suportado dentro de uma execu\xE7\xE3o de task");
2970
3041
  return;
2971
3042
  }
2972
- const withMeta = typeof attachment === "object" && "image" in attachment ? attachment : { image: attachment };
2973
- void reporter.attach(withMeta.image, {
2974
- message,
2975
- level: toRunLogLevel(type),
2976
- caption: withMeta.caption,
2977
- contentType: withMeta.contentType
2978
- });
3043
+ const withMeta = typeof attachment === "object" && !isImageAttachment(attachment) && "image" in attachment ? image(attachment.image, { caption: attachment.caption, contentType: attachment.contentType }) : attachment;
3044
+ reporter.log(toRunLogLevel(type), message, withMeta);
2979
3045
  }
2980
3046
  function captureException(error, hint) {
2981
3047
  const rt = runtime;
@@ -3022,12 +3088,16 @@ function warnOutsideRun(member) {
3022
3088
  else console.warn(`[hobots] ${message}`);
3023
3089
  }
3024
3090
  var FALLBACK_TASK = "hobots";
3091
+ var fallbackLogAt = (level) => (message, ...rest) => {
3092
+ const { text, image: dropped } = splitLogArgs(message, rest);
3093
+ printRunLog(FALLBACK_TASK, level, dropped ? `${text} [imagem descartada]` : text);
3094
+ };
3025
3095
  var fallbackLog = {
3026
- debug: (message, ...rest) => printRunLog(FALLBACK_TASK, "debug", joinLogArgs(message, rest)),
3027
- info: (message, ...rest) => printRunLog(FALLBACK_TASK, "info", joinLogArgs(message, rest)),
3028
- warn: (message, ...rest) => printRunLog(FALLBACK_TASK, "warning", joinLogArgs(message, rest)),
3029
- error: (message, ...rest) => printRunLog(FALLBACK_TASK, "error", joinLogArgs(message, rest)),
3030
- success: (message, ...rest) => printRunLog(FALLBACK_TASK, "success", joinLogArgs(message, rest))
3096
+ debug: fallbackLogAt("debug"),
3097
+ info: fallbackLogAt("info"),
3098
+ warn: fallbackLogAt("warning"),
3099
+ error: fallbackLogAt("error"),
3100
+ success: fallbackLogAt("success")
3031
3101
  };
3032
3102
  var noop = () => void 0;
3033
3103
  var fallbackEachSink = {
@@ -3073,9 +3143,6 @@ var hobots = Object.defineProperties({}, {
3073
3143
  signal: getter("signal", () => neverAborts),
3074
3144
  throwIfCanceled: method(() => resolve("throwIfCanceled")?.throwIfCanceled()),
3075
3145
  sleep: method((ms) => resolve("sleep")?.sleep(ms) ?? sleep(ms)),
3076
- attach: method(
3077
- (image, options) => resolve("attach")?.attach(image, options) ?? Promise.resolve({ id: "", ok: false, reason: "canceled" })
3078
- ),
3079
3146
  retry: method((fn, options) => {
3080
3147
  const reporter = currentReporter();
3081
3148
  return reporter ? reporter.context.retry(fn, options) : retryWithBackoff(fn, options);
@@ -3090,7 +3157,8 @@ var hobots = Object.defineProperties({}, {
3090
3157
  installGlobalHobots();
3091
3158
  }),
3092
3159
  close: method((timeoutMs) => close(timeoutMs)),
3093
- captureException: method((error, hint) => captureException(error, hint))
3160
+ captureException: method((error, hint) => captureException(error, hint)),
3161
+ image: method(image)
3094
3162
  });
3095
3163
  function installGlobalHobots() {
3096
3164
  if (globalThis.hobots === hobots) return;
@@ -3101,6 +3169,7 @@ var start2 = hobots.start;
3101
3169
  var close2 = hobots.close;
3102
3170
  var captureException2 = hobots.captureException;
3103
3171
  var retry = hobots.retry;
3172
+ var image2 = hobots.image;
3104
3173
 
3105
3174
  // src/index.ts
3106
3175
  var src_default = hobots;