hobots 1.8.1 → 1.9.1

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/dist/index.d.cts CHANGED
@@ -1,5 +1,8 @@
1
- import { Breadcrumb, EventUser, Severity, EventPayload, TransactionPayload, HeartbeatPayload, StepStatus, RunLogLevel, StepData } from './protocol.cjs';
2
- export { AgentInfo, Envelope, EnvelopeItem, ExceptionData, PollRequest, PollResponse, RunDescriptor, RunError, RunLogEntry, RunStatus, RunUpdateRequest, RunUpdateResponse, RunUpdateStatus, StackFrame } from './protocol.cjs';
1
+ import { Breadcrumb, EventUser, Severity, EventPayload, TransactionPayload, HeartbeatPayload, StepStatus, RunLogLevel, StepData, RunAttachmentContentType } from './protocol.cjs';
2
+ export { AgentInfo, Envelope, EnvelopeItem, ExceptionData, PollRequest, PollResponse, RunAttachmentConfirmRequest, RunAttachmentConfirmResponse, RunAttachmentCreateRequest, RunAttachmentCreateResponse, RunDescriptor, RunError, RunLogEntry, RunStatus, RunUpdateRequest, RunUpdateResponse, RunUpdateStatus, StackFrame } from './protocol.cjs';
3
+
4
+ /** Aceita tudo que o handler pode ter em mãos: bytes ou um caminho de arquivo (só Node). */
5
+ type AttachInput = Uint8Array | ArrayBuffer | string;
3
6
 
4
7
  /** API oficial da Hobots — o SDK sempre fala com ela em produção. */
5
8
  declare const DEFAULT_BASE_URL = "https://api2.hobots.app";
@@ -224,17 +227,43 @@ interface InitOptions {
224
227
  /** Itens por envelope antes de flush imediato. Default: 10 */
225
228
  maxBatchSize?: number;
226
229
  }
230
+ /** Imagem enviada junto de um capture* — com legenda e content-type opcionais. */
231
+ interface CaptureAttachment {
232
+ /** Bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`) ou um caminho de arquivo (só em Node) */
233
+ image: AttachInput;
234
+ /** Legenda curta, exibida sob a miniatura no app. Passa pelo scrub de segredos. */
235
+ caption?: string;
236
+ /** Informe quando os magic bytes não bastarem (o SDK sempre prefere o que detectar). */
237
+ contentType?: RunAttachmentContentType;
238
+ }
227
239
  interface CaptureHint {
228
240
  level?: Severity;
229
241
  tags?: Record<string, string>;
230
242
  extra?: Record<string, unknown>;
231
243
  fingerprint?: string[];
244
+ /**
245
+ * Print da tela enviado junto da captura. Dentro de uma execução de task, a imagem
246
+ * vira anexo da linha de log da execução que espelha o evento — miniatura na aba
247
+ * "logs do agente". Fora de uma execução não há onde pendurar a imagem: o SDK
248
+ * descarta com um aviso. O upload roda em segundo plano e **nunca bloqueia nem lança**.
249
+ *
250
+ * PNG, JPEG ou WEBP, até 5 MB, no máximo 20 anexos por execução.
251
+ *
252
+ * ```ts
253
+ * Hobots.captureException(error, { attachment: { image: await page.screenshot(), caption: 'tela no momento do erro' } });
254
+ * ```
255
+ */
256
+ attachment?: AttachInput | CaptureAttachment;
232
257
  }
233
258
 
234
259
  /** Inicializa o SDK. Chame uma vez, o mais cedo possível no programa. */
235
260
  declare function init(options: InitOptions): void;
236
261
  declare function getClient(): Client | undefined;
237
- /** Captura uma exceção e retorna o event_id (undefined se o SDK não foi inicializado). */
262
+ /**
263
+ * Captura uma exceção e retorna o event_id (undefined se o SDK não foi inicializado).
264
+ * `hint.attachment` envia um print junto — dentro de uma execução de task, ele aparece
265
+ * como miniatura na linha de log da run correspondente ao erro.
266
+ */
238
267
  declare function captureException(error: unknown, hint?: CaptureHint): string | undefined;
239
268
  declare function captureMessage(message: string, level?: Severity, hint?: CaptureHint): string | undefined;
240
269
  /** Registra um breadcrumb — anexado aos próximos eventos como trilha do que aconteceu antes. */
@@ -267,4 +296,4 @@ declare function close(timeoutMs?: number): Promise<boolean>;
267
296
 
268
297
  declare function scrubSecrets(text: string): string;
269
298
 
270
- export { Breadcrumb, type BreadcrumbInput, type CaptureHint, Client, DEFAULT_BASE_URL, EventPayload, EventUser, type HeartbeatOptions, HeartbeatPayload, type InitOptions, type RunContext, RunLogLevel, Scope, Severity, Step, StepData, type TaskHandler, type TasksOptions, Transaction, TransactionPayload, addBreadcrumb, captureException, captureMessage, close, flush, getClient, init, register, scrubSecrets, setContext, setExtra, setTag, setTags, setUser, start, startTransaction, stop, withScope };
299
+ export { type AttachInput, Breadcrumb, type BreadcrumbInput, type CaptureAttachment, type CaptureHint, Client, DEFAULT_BASE_URL, EventPayload, EventUser, type HeartbeatOptions, HeartbeatPayload, type InitOptions, RunAttachmentContentType, type RunContext, RunLogLevel, Scope, Severity, Step, StepData, type TaskHandler, type TasksOptions, Transaction, TransactionPayload, addBreadcrumb, captureException, captureMessage, close, flush, getClient, init, register, scrubSecrets, setContext, setExtra, setTag, setTags, setUser, start, startTransaction, stop, withScope };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- import { Breadcrumb, EventUser, Severity, EventPayload, TransactionPayload, HeartbeatPayload, StepStatus, RunLogLevel, StepData } from './protocol.js';
2
- export { AgentInfo, Envelope, EnvelopeItem, ExceptionData, PollRequest, PollResponse, RunDescriptor, RunError, RunLogEntry, RunStatus, RunUpdateRequest, RunUpdateResponse, RunUpdateStatus, StackFrame } from './protocol.js';
1
+ import { Breadcrumb, EventUser, Severity, EventPayload, TransactionPayload, HeartbeatPayload, StepStatus, RunLogLevel, StepData, RunAttachmentContentType } from './protocol.js';
2
+ export { AgentInfo, Envelope, EnvelopeItem, ExceptionData, PollRequest, PollResponse, RunAttachmentConfirmRequest, RunAttachmentConfirmResponse, RunAttachmentCreateRequest, RunAttachmentCreateResponse, RunDescriptor, RunError, RunLogEntry, RunStatus, RunUpdateRequest, RunUpdateResponse, RunUpdateStatus, StackFrame } from './protocol.js';
3
+
4
+ /** Aceita tudo que o handler pode ter em mãos: bytes ou um caminho de arquivo (só Node). */
5
+ type AttachInput = Uint8Array | ArrayBuffer | string;
3
6
 
4
7
  /** API oficial da Hobots — o SDK sempre fala com ela em produção. */
5
8
  declare const DEFAULT_BASE_URL = "https://api2.hobots.app";
@@ -224,17 +227,43 @@ interface InitOptions {
224
227
  /** Itens por envelope antes de flush imediato. Default: 10 */
225
228
  maxBatchSize?: number;
226
229
  }
230
+ /** Imagem enviada junto de um capture* — com legenda e content-type opcionais. */
231
+ interface CaptureAttachment {
232
+ /** Bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`) ou um caminho de arquivo (só em Node) */
233
+ image: AttachInput;
234
+ /** Legenda curta, exibida sob a miniatura no app. Passa pelo scrub de segredos. */
235
+ caption?: string;
236
+ /** Informe quando os magic bytes não bastarem (o SDK sempre prefere o que detectar). */
237
+ contentType?: RunAttachmentContentType;
238
+ }
227
239
  interface CaptureHint {
228
240
  level?: Severity;
229
241
  tags?: Record<string, string>;
230
242
  extra?: Record<string, unknown>;
231
243
  fingerprint?: string[];
244
+ /**
245
+ * Print da tela enviado junto da captura. Dentro de uma execução de task, a imagem
246
+ * vira anexo da linha de log da execução que espelha o evento — miniatura na aba
247
+ * "logs do agente". Fora de uma execução não há onde pendurar a imagem: o SDK
248
+ * descarta com um aviso. O upload roda em segundo plano e **nunca bloqueia nem lança**.
249
+ *
250
+ * PNG, JPEG ou WEBP, até 5 MB, no máximo 20 anexos por execução.
251
+ *
252
+ * ```ts
253
+ * Hobots.captureException(error, { attachment: { image: await page.screenshot(), caption: 'tela no momento do erro' } });
254
+ * ```
255
+ */
256
+ attachment?: AttachInput | CaptureAttachment;
232
257
  }
233
258
 
234
259
  /** Inicializa o SDK. Chame uma vez, o mais cedo possível no programa. */
235
260
  declare function init(options: InitOptions): void;
236
261
  declare function getClient(): Client | undefined;
237
- /** Captura uma exceção e retorna o event_id (undefined se o SDK não foi inicializado). */
262
+ /**
263
+ * Captura uma exceção e retorna o event_id (undefined se o SDK não foi inicializado).
264
+ * `hint.attachment` envia um print junto — dentro de uma execução de task, ele aparece
265
+ * como miniatura na linha de log da run correspondente ao erro.
266
+ */
238
267
  declare function captureException(error: unknown, hint?: CaptureHint): string | undefined;
239
268
  declare function captureMessage(message: string, level?: Severity, hint?: CaptureHint): string | undefined;
240
269
  /** Registra um breadcrumb — anexado aos próximos eventos como trilha do que aconteceu antes. */
@@ -267,4 +296,4 @@ declare function close(timeoutMs?: number): Promise<boolean>;
267
296
 
268
297
  declare function scrubSecrets(text: string): string;
269
298
 
270
- export { Breadcrumb, type BreadcrumbInput, type CaptureHint, Client, DEFAULT_BASE_URL, EventPayload, EventUser, type HeartbeatOptions, HeartbeatPayload, type InitOptions, type RunContext, RunLogLevel, Scope, Severity, Step, StepData, type TaskHandler, type TasksOptions, Transaction, TransactionPayload, addBreadcrumb, captureException, captureMessage, close, flush, getClient, init, register, scrubSecrets, setContext, setExtra, setTag, setTags, setUser, start, startTransaction, stop, withScope };
299
+ export { type AttachInput, Breadcrumb, type BreadcrumbInput, type CaptureAttachment, type CaptureHint, Client, DEFAULT_BASE_URL, EventPayload, EventUser, type HeartbeatOptions, HeartbeatPayload, type InitOptions, RunAttachmentContentType, type RunContext, RunLogLevel, Scope, Severity, Step, StepData, type TaskHandler, type TasksOptions, Transaction, TransactionPayload, addBreadcrumb, captureException, captureMessage, close, flush, getClient, init, register, scrubSecrets, setContext, setExtra, setTag, setTags, setUser, start, startTransaction, stop, withScope };
package/dist/index.js CHANGED
@@ -103,7 +103,7 @@ function scrubSecrets(text) {
103
103
 
104
104
  // src/utils.ts
105
105
  var SDK_NAME = "hobots";
106
- var SDK_VERSION = true ? "1.8.0" : "0.0.0-dev";
106
+ var SDK_VERSION = true ? "1.9.0" : "0.0.0-dev";
107
107
  function isNode() {
108
108
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
109
109
  }
@@ -270,6 +270,7 @@ var REQUEST_TIMEOUT_MS = 1e4;
270
270
  var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
271
271
  var DEFAULT_MAX_BATCH_SIZE = 10;
272
272
  var DEFAULT_MAX_QUEUE_SIZE = 200;
273
+ var MAX_ITEMS_PER_ENVELOPE = 50;
273
274
  var Transport = class {
274
275
  endpoint;
275
276
  key;
@@ -319,7 +320,15 @@ var Transport = class {
319
320
  this.timer = void 0;
320
321
  }
321
322
  if (this.buffer.length === 0) return Promise.resolve();
322
- const items = this.buffer.splice(0, this.buffer.length);
323
+ const taken = this.buffer.splice(0, this.buffer.length);
324
+ const sends = [];
325
+ for (let offset = 0; offset < taken.length; offset += MAX_ITEMS_PER_ENVELOPE) {
326
+ sends.push(this.send(taken.slice(offset, offset + MAX_ITEMS_PER_ENVELOPE)));
327
+ }
328
+ return Promise.all(sends).then(() => void 0);
329
+ }
330
+ /** Registra o envio em `this.pending` para que `flush()` aguarde todos os envelopes. */
331
+ send(items) {
323
332
  const envelope = {
324
333
  sdk: { name: SDK_NAME, version: SDK_VERSION },
325
334
  sent_at: nowISO(),
@@ -351,7 +360,7 @@ var Transport = class {
351
360
  this.logger.debug(`envelope enviado (${envelope.items.length} itens)`);
352
361
  return;
353
362
  }
354
- this.logger.error(`envelope rejeitado (HTTP ${response.status}), descartado`);
363
+ this.logger.error(`envelope rejeitado (HTTP ${response.status}), descartado (${envelope.items.length} itens)`);
355
364
  }
356
365
  /** Envia o que estiver na fila e aguarda envios em andamento (até timeoutMs). */
357
366
  async flush(timeoutMs = 5e3) {
@@ -824,6 +833,8 @@ var Transaction = class {
824
833
  // src/tasks/api.ts
825
834
  var POLL_TIMEOUT_MS = 3e4;
826
835
  var UPDATE_TIMEOUT_MS = 1e4;
836
+ var UPLOAD_TIMEOUT_MS = 3e4;
837
+ var UPLOAD_RETRY_DELAYS_MS = [1e3, 4e3];
827
838
  var TasksApiError = class extends Error {
828
839
  constructor(message, status) {
829
840
  super(message);
@@ -918,8 +929,123 @@ var TasksApi = class {
918
929
  signal: AbortSignal.timeout(timeoutMs)
919
930
  });
920
931
  }
932
+ /**
933
+ * Registra o anexo e obtém a URL presignada de upload. 404/501/503 significam
934
+ * "esta instalação não tem anexos" — o RunReporter degrada em silêncio a partir daí.
935
+ */
936
+ async createAttachment(runId, request, signal) {
937
+ const { signal: timeoutSignal, cleanup } = withTimeoutSignal(UPDATE_TIMEOUT_MS, signal);
938
+ let response;
939
+ try {
940
+ response = await fetchWithRetry(
941
+ () => fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments`, {
942
+ method: "POST",
943
+ headers: this.headers(),
944
+ body: JSON.stringify(request),
945
+ signal: timeoutSignal
946
+ }),
947
+ { logger: this.logger, label: `anexo da run ${runId}` }
948
+ );
949
+ } finally {
950
+ cleanup();
951
+ }
952
+ if (!response.ok) {
953
+ throw new TasksApiError(`cria\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
954
+ }
955
+ const body = await response.json();
956
+ if (typeof body?.upload_url !== "string") {
957
+ throw new TasksApiError("resposta inv\xE1lida do anexo (upload_url ausente)", 0);
958
+ }
959
+ return {
960
+ id: body.id ?? request.id,
961
+ upload_url: body.upload_url,
962
+ headers: body.headers ?? { "content-type": request.content_type },
963
+ expires_in: body.expires_in ?? 900
964
+ };
965
+ }
966
+ /**
967
+ * PUT do binário direto no S3. Deliberadamente sem `authorization`: a autorização
968
+ * está na assinatura da URL, e mandar o client secret para fora da API seria vazamento.
969
+ */
970
+ async uploadAttachment(url, headers, body, signal) {
971
+ const { signal: timeoutSignal, cleanup } = withTimeoutSignal(UPLOAD_TIMEOUT_MS, signal);
972
+ let response;
973
+ try {
974
+ response = await fetchWithRetry(
975
+ () => fetch(url, {
976
+ method: "PUT",
977
+ headers,
978
+ // BodyInit não aceita Uint8Array<ArrayBufferLike> em todos os lib.dom; o buffer é o mesmo
979
+ body,
980
+ signal: timeoutSignal
981
+ }),
982
+ { delays: UPLOAD_RETRY_DELAYS_MS, logger: this.logger, label: "upload de anexo" }
983
+ );
984
+ } finally {
985
+ cleanup();
986
+ }
987
+ if (!response.ok) {
988
+ throw new TasksApiError(`upload de anexo rejeitado (HTTP ${response.status})`, response.status);
989
+ }
990
+ }
991
+ /** Marca o anexo como enviado (ou falho). Best-effort — a leitura não depende disso. */
992
+ async confirmAttachment(runId, attachmentId, request) {
993
+ const response = await fetch(
994
+ `${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments/${encodeURIComponent(attachmentId)}`,
995
+ {
996
+ method: "PATCH",
997
+ headers: this.headers(),
998
+ body: JSON.stringify(request),
999
+ signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
1000
+ }
1001
+ );
1002
+ if (!response.ok) {
1003
+ throw new TasksApiError(`confirma\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
1004
+ }
1005
+ }
921
1006
  };
922
1007
 
1008
+ // src/tasks/attachments.ts
1009
+ var MAX_ATTACHMENT_BYTES = 5242880;
1010
+ var MAX_ATTACHMENTS_PER_RUN = 20;
1011
+ var MAX_CONCURRENT_UPLOADS = 3;
1012
+ var MAX_CAPTION_CHARS = 200;
1013
+ var ATTACHMENT_DRAIN_MS = 15e3;
1014
+ async function readImageFile(path) {
1015
+ if (!isNode()) {
1016
+ throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
1017
+ }
1018
+ const { readFile } = await import("fs/promises");
1019
+ return new Uint8Array(await readFile(path));
1020
+ }
1021
+ function startsWith(bytes, signature, offset = 0) {
1022
+ if (bytes.length < offset + signature.length) return false;
1023
+ return signature.every((byte, index) => bytes[offset + index] === byte);
1024
+ }
1025
+ var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
1026
+ var JPEG_MAGIC = [255, 216, 255];
1027
+ var RIFF_MAGIC = [82, 73, 70, 70];
1028
+ var WEBP_MAGIC = [87, 69, 66, 80];
1029
+ function sniffContentType(bytes) {
1030
+ if (startsWith(bytes, PNG_MAGIC)) return "image/png";
1031
+ if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
1032
+ if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
1033
+ return void 0;
1034
+ }
1035
+ function readPngDimensions(bytes) {
1036
+ if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
1037
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1038
+ const width = view.getUint32(16);
1039
+ const height = view.getUint32(20);
1040
+ if (width === 0 || height === 0) return void 0;
1041
+ return { width, height };
1042
+ }
1043
+ async function toBytes(input) {
1044
+ if (typeof input === "string") return readImageFile(input);
1045
+ if (input instanceof Uint8Array) return input;
1046
+ return new Uint8Array(input);
1047
+ }
1048
+
923
1049
  // src/tasks/context.ts
924
1050
  var LOG_LEVEL_COLOR = {
925
1051
  debug: "gray",
@@ -1001,6 +1127,14 @@ var RunReporter = class {
1001
1127
  abortController = new AbortController();
1002
1128
  flushIntervalMs;
1003
1129
  cancelCheckIntervalMs;
1130
+ // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
1131
+ // status/logs e não pode ficar bloqueada esperando um binário subir.
1132
+ pendingAttachments = /* @__PURE__ */ new Set();
1133
+ attachmentCount = 0;
1134
+ uploadSlots = MAX_CONCURRENT_UPLOADS;
1135
+ uploadQueue = [];
1136
+ /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
1137
+ attachmentsUnsupported = false;
1004
1138
  get isDead() {
1005
1139
  return this.dead;
1006
1140
  }
@@ -1019,6 +1153,8 @@ var RunReporter = class {
1019
1153
  this.dead = true;
1020
1154
  this.clearFlushTimer();
1021
1155
  this.buffer = [];
1156
+ const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
1157
+ for (const resume of waiting) resume();
1022
1158
  }
1023
1159
  /**
1024
1160
  * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
@@ -1036,15 +1172,22 @@ var RunReporter = class {
1036
1172
  this.push(level, scrubbed, true);
1037
1173
  }
1038
1174
  /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
1039
- push(level, message, alreadyScrubbed = false) {
1040
- if (this.dead) return;
1175
+ push(level, message, alreadyScrubbed = false, attachments) {
1176
+ const ts = nowISO();
1177
+ if (this.dead) return ts;
1041
1178
  const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
1042
- this.buffer.push({ ts: nowISO(), level, message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS) });
1179
+ this.buffer.push({
1180
+ ts,
1181
+ level,
1182
+ message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
1183
+ ...attachments && attachments.length > 0 ? { attachments } : {}
1184
+ });
1043
1185
  if (this.buffer.length >= FLUSH_AT_BUFFERED) {
1044
1186
  this.flushLogs();
1045
1187
  } else {
1046
1188
  this.scheduleFlush();
1047
1189
  }
1190
+ return ts;
1048
1191
  }
1049
1192
  scheduleFlush() {
1050
1193
  if (this.timer) return;
@@ -1112,6 +1255,131 @@ var RunReporter = class {
1112
1255
  finalize() {
1113
1256
  return this.chain;
1114
1257
  }
1258
+ // ── Anexos ───────────────────────────────────────────────────────────────────
1259
+ /**
1260
+ * Registra a linha de log com o id do anexo (síncrono, na ordem certa) e dispara
1261
+ * o upload em segundo plano. Nunca lança: todo caminho de erro vira `{ ok: false }`
1262
+ * com um aviso no log da run. Chega ao handler pelos capture* do SDK — o hint
1263
+ * `attachment` de Hobots.captureException/captureMessage cai aqui via run-context-store.
1264
+ */
1265
+ async attach(image, options = {}) {
1266
+ const id = uuid4();
1267
+ const level = options.level ?? "info";
1268
+ const caption = options.caption ? truncate(scrubSecrets(options.caption), MAX_CAPTION_CHARS) : void 0;
1269
+ const message = options.message ?? options.caption ?? "captura de tela";
1270
+ if (this.dead) return { id, ok: false, reason: "canceled" };
1271
+ if (this.attachmentsUnsupported) {
1272
+ this.log(level, message);
1273
+ return { id, ok: false, reason: "unsupported" };
1274
+ }
1275
+ if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
1276
+ this.log(level, message);
1277
+ this.log("warning", `anexo descartado: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
1278
+ return { id, ok: false, reason: "quota" };
1279
+ }
1280
+ this.attachmentCount++;
1281
+ const scrubbed = scrubSecrets(message);
1282
+ printRunLog(this.context.task, level, `${scrubbed} [imagem]`);
1283
+ const logTs = this.push(level, scrubbed, true, [id]);
1284
+ let settle;
1285
+ const result = new Promise((resolve) => {
1286
+ settle = resolve;
1287
+ });
1288
+ 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" }));
1289
+ this.pendingAttachments.add(work);
1290
+ void work.finally(() => this.pendingAttachments.delete(work));
1291
+ return result;
1292
+ }
1293
+ /** Retorna o motivo da falha, ou undefined em sucesso. */
1294
+ async uploadAttachment(id, image, meta) {
1295
+ let bytes;
1296
+ try {
1297
+ bytes = await toBytes(image);
1298
+ } catch (error) {
1299
+ this.log("warning", `anexo n\xE3o p\xF4de ser lido: ${String(error)}`);
1300
+ return "read-failed";
1301
+ }
1302
+ const contentType = sniffContentType(bytes) ?? meta.contentType;
1303
+ if (!contentType) {
1304
+ this.log("warning", "anexo descartado: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
1305
+ return "unsupported-format";
1306
+ }
1307
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
1308
+ this.log(
1309
+ "warning",
1310
+ `anexo descartado: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
1311
+ );
1312
+ return "too-large";
1313
+ }
1314
+ const dimensions = readPngDimensions(bytes);
1315
+ const release = await this.acquireUploadSlot();
1316
+ try {
1317
+ if (this.abortController.signal.aborted || this.dead) return "canceled";
1318
+ const created = await this.api.createAttachment(
1319
+ this.runId,
1320
+ {
1321
+ id,
1322
+ content_type: contentType,
1323
+ size_bytes: bytes.byteLength,
1324
+ log_ts: meta.logTs,
1325
+ ...meta.caption ? { caption: meta.caption } : {},
1326
+ ...dimensions ?? {}
1327
+ },
1328
+ this.abortController.signal
1329
+ );
1330
+ await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
1331
+ await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
1332
+ });
1333
+ return void 0;
1334
+ } catch (error) {
1335
+ if (this.isUnsupported(error)) {
1336
+ this.attachmentsUnsupported = true;
1337
+ this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
1338
+ return "unsupported";
1339
+ }
1340
+ if (error instanceof TasksApiError && error.status === 429) {
1341
+ this.log("warning", "anexo descartado: limite de anexos por execu\xE7\xE3o atingido no servidor");
1342
+ return "quota";
1343
+ }
1344
+ if (this.abortController.signal.aborted) return "canceled";
1345
+ this.log("warning", `falha ao enviar o anexo: ${String(error)}`);
1346
+ void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
1347
+ });
1348
+ return "upload-failed";
1349
+ } finally {
1350
+ release();
1351
+ }
1352
+ }
1353
+ /** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
1354
+ isUnsupported(error) {
1355
+ return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
1356
+ }
1357
+ /** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
1358
+ acquireUploadSlot() {
1359
+ const release = () => {
1360
+ const next = this.uploadQueue.shift();
1361
+ if (next) next();
1362
+ else this.uploadSlots++;
1363
+ };
1364
+ if (this.uploadSlots > 0) {
1365
+ this.uploadSlots--;
1366
+ return Promise.resolve(release);
1367
+ }
1368
+ return new Promise((resolve) => {
1369
+ this.uploadQueue.push(() => resolve(release));
1370
+ });
1371
+ }
1372
+ /**
1373
+ * Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
1374
+ * exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
1375
+ */
1376
+ async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
1377
+ if (this.pendingAttachments.size === 0) return;
1378
+ await Promise.race([
1379
+ Promise.allSettled([...this.pendingAttachments]),
1380
+ new Promise((resolve) => unrefTimer(setTimeout(resolve, timeoutMs)))
1381
+ ]);
1382
+ }
1115
1383
  enqueue(update) {
1116
1384
  if (this.dead) {
1117
1385
  this.salvageLogs(update.logs);
@@ -1179,6 +1447,9 @@ async function runWithRunContext(store, fn) {
1179
1447
  function currentRunTransaction() {
1180
1448
  return storage?.getStore()?.tx;
1181
1449
  }
1450
+ function currentRunAttach() {
1451
+ return storage?.getStore()?.attach;
1452
+ }
1182
1453
 
1183
1454
  // src/tasks/shutdown.ts
1184
1455
  var WATCHED_SIGNALS = ["SIGINT", "SIGTERM"];
@@ -1420,12 +1691,17 @@ var TaskAgent = class {
1420
1691
  }
1421
1692
  this.logger.debug(`executando run ${run.id} (${run.task})`);
1422
1693
  try {
1423
- await runWithRunContext({ runId: run.id, tx }, () => this.invokeWithTimeout(handler, run, reporter));
1694
+ await runWithRunContext(
1695
+ { runId: run.id, tx, attach: (image, options) => reporter.attach(image, options) },
1696
+ () => this.invokeWithTimeout(handler, run, reporter)
1697
+ );
1698
+ await reporter.finalizeAttachments();
1424
1699
  tx.setStatus("ok");
1425
1700
  tx.finish();
1426
1701
  reporter.reportSuccess();
1427
1702
  this.logger.debug(`run ${run.id} conclu\xEDda`);
1428
1703
  } catch (error) {
1704
+ await reporter.finalizeAttachments();
1429
1705
  tx.setStatus("error");
1430
1706
  tx.finish();
1431
1707
  reporter.reportFailure(toRunError(error));
@@ -1508,10 +1784,34 @@ function init(options) {
1508
1784
  function getClient() {
1509
1785
  return client;
1510
1786
  }
1787
+ function toRunLogLevel(level) {
1788
+ return level === "fatal" ? "error" : level;
1789
+ }
1790
+ function forwardAttachment(attachment, message, level) {
1791
+ const attach = currentRunAttach();
1792
+ if (!attach) {
1793
+ client?.logger.warn("attachment descartado \u2014 s\xF3 \xE9 suportado dentro de uma execu\xE7\xE3o de task (register/start)");
1794
+ return;
1795
+ }
1796
+ const withMeta = typeof attachment === "object" && "image" in attachment ? attachment : { image: attachment };
1797
+ void attach(withMeta.image, {
1798
+ message,
1799
+ level: toRunLogLevel(level),
1800
+ caption: withMeta.caption,
1801
+ contentType: withMeta.contentType
1802
+ });
1803
+ }
1511
1804
  function captureException(error, hint) {
1805
+ if (hint?.attachment) {
1806
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1807
+ forwardAttachment(hint.attachment, message, hint.level ?? "error");
1808
+ }
1512
1809
  return client?.captureException(error, hint, scope);
1513
1810
  }
1514
1811
  function captureMessage(message, level = "info", hint) {
1812
+ if (hint?.attachment) {
1813
+ forwardAttachment(hint.attachment, message, hint.level ?? level);
1814
+ }
1515
1815
  return client?.captureMessage(message, level, hint, scope);
1516
1816
  }
1517
1817
  function addBreadcrumb(crumb) {