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/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
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
3
 
4
4
  /** API oficial da Hobots — o SDK sempre fala com ela em produção. */
5
5
  declare const DEFAULT_BASE_URL = "https://api2.hobots.app";
@@ -123,6 +123,31 @@ declare class Transaction {
123
123
  finish(): void;
124
124
  }
125
125
 
126
+ interface AttachOptions {
127
+ /** Legenda curta, exibida sob a miniatura no app. Passa pelo scrub de segredos. */
128
+ caption?: string;
129
+ /** Nível da linha de log que carrega o anexo. Default: 'info' */
130
+ level?: RunLogLevel;
131
+ /** Texto da linha de log. Default: a legenda, ou 'captura de tela' */
132
+ message?: string;
133
+ /** Informe quando os magic bytes não bastarem (o SDK sempre prefere o que detectar). */
134
+ contentType?: RunAttachmentContentType;
135
+ }
136
+ type AttachFailureReason =
137
+ /** A instalação do Hobots não tem anexos habilitados (ou a API é anterior ao recurso) */
138
+ 'unsupported' | 'too-large' | 'quota' | 'read-failed'
139
+ /** Formato não reconhecido ou fora de PNG/JPEG/WEBP */
140
+ | 'unsupported-format' | 'upload-failed' | 'canceled';
141
+ interface AttachResult {
142
+ /** Id do anexo, o mesmo referenciado na linha de log */
143
+ id: string;
144
+ ok: boolean;
145
+ /** Motivo do insucesso — para diagnóstico; `attach` nunca lança */
146
+ reason?: AttachFailureReason;
147
+ }
148
+ /** Aceita tudo que o handler pode ter em mãos: bytes ou um caminho de arquivo (só Node). */
149
+ type AttachInput = Uint8Array | ArrayBuffer | string;
150
+
126
151
  /** Opções de tasks dentro de `init({ tasks: ... })`. Credencial (clientSecret), identidade (instanceId) e debug vêm do init. */
127
152
  interface TasksOptions {
128
153
  /** Intervalo entre polls sem trabalho. Default: 5_000 (mínimo 1_000) */
@@ -177,6 +202,22 @@ interface RunContext {
177
202
  throwIfCanceled(): void;
178
203
  /** Sleep cancelável: rejeita com `RunCanceled` se ctx.signal disparar antes de ms. */
179
204
  sleep(ms: number): Promise<void>;
205
+ /**
206
+ * Anexa uma imagem (print da tela) a uma linha de log da execução — a evidência
207
+ * visual aparece como miniatura junto do log, na aba "logs do agente".
208
+ *
209
+ * Aceita bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`) ou um caminho de arquivo
210
+ * (só em Node). A linha de log é registrada imediatamente; o upload roda em
211
+ * segundo plano e **nunca bloqueia nem lança** — falhas viram um aviso no log da
212
+ * run e um `{ ok: false, reason }` no retorno, que pode ser ignorado com `void`.
213
+ *
214
+ * PNG, JPEG ou WEBP, até 5 MB, no máximo 20 anexos por execução.
215
+ *
216
+ * ```ts
217
+ * await ctx.attach(await page.screenshot(), { caption: 'portal após o login' });
218
+ * ```
219
+ */
220
+ attach(image: AttachInput, options?: AttachOptions): Promise<AttachResult>;
180
221
  }
181
222
  /** O retorno do handler é ignorado — nada além de status/logs/erro é enviado ao servidor. */
182
223
  type TaskHandler<P = Record<string, unknown>> = (params: P, ctx: RunContext) => void | Promise<void>;
@@ -267,4 +308,4 @@ declare function close(timeoutMs?: number): Promise<boolean>;
267
308
 
268
309
  declare function scrubSecrets(text: string): string;
269
310
 
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 };
311
+ export { type AttachFailureReason, type AttachOptions, type AttachResult, Breadcrumb, type BreadcrumbInput, 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,5 @@
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
3
 
4
4
  /** API oficial da Hobots — o SDK sempre fala com ela em produção. */
5
5
  declare const DEFAULT_BASE_URL = "https://api2.hobots.app";
@@ -123,6 +123,31 @@ declare class Transaction {
123
123
  finish(): void;
124
124
  }
125
125
 
126
+ interface AttachOptions {
127
+ /** Legenda curta, exibida sob a miniatura no app. Passa pelo scrub de segredos. */
128
+ caption?: string;
129
+ /** Nível da linha de log que carrega o anexo. Default: 'info' */
130
+ level?: RunLogLevel;
131
+ /** Texto da linha de log. Default: a legenda, ou 'captura de tela' */
132
+ message?: string;
133
+ /** Informe quando os magic bytes não bastarem (o SDK sempre prefere o que detectar). */
134
+ contentType?: RunAttachmentContentType;
135
+ }
136
+ type AttachFailureReason =
137
+ /** A instalação do Hobots não tem anexos habilitados (ou a API é anterior ao recurso) */
138
+ 'unsupported' | 'too-large' | 'quota' | 'read-failed'
139
+ /** Formato não reconhecido ou fora de PNG/JPEG/WEBP */
140
+ | 'unsupported-format' | 'upload-failed' | 'canceled';
141
+ interface AttachResult {
142
+ /** Id do anexo, o mesmo referenciado na linha de log */
143
+ id: string;
144
+ ok: boolean;
145
+ /** Motivo do insucesso — para diagnóstico; `attach` nunca lança */
146
+ reason?: AttachFailureReason;
147
+ }
148
+ /** Aceita tudo que o handler pode ter em mãos: bytes ou um caminho de arquivo (só Node). */
149
+ type AttachInput = Uint8Array | ArrayBuffer | string;
150
+
126
151
  /** Opções de tasks dentro de `init({ tasks: ... })`. Credencial (clientSecret), identidade (instanceId) e debug vêm do init. */
127
152
  interface TasksOptions {
128
153
  /** Intervalo entre polls sem trabalho. Default: 5_000 (mínimo 1_000) */
@@ -177,6 +202,22 @@ interface RunContext {
177
202
  throwIfCanceled(): void;
178
203
  /** Sleep cancelável: rejeita com `RunCanceled` se ctx.signal disparar antes de ms. */
179
204
  sleep(ms: number): Promise<void>;
205
+ /**
206
+ * Anexa uma imagem (print da tela) a uma linha de log da execução — a evidência
207
+ * visual aparece como miniatura junto do log, na aba "logs do agente".
208
+ *
209
+ * Aceita bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`) ou um caminho de arquivo
210
+ * (só em Node). A linha de log é registrada imediatamente; o upload roda em
211
+ * segundo plano e **nunca bloqueia nem lança** — falhas viram um aviso no log da
212
+ * run e um `{ ok: false, reason }` no retorno, que pode ser ignorado com `void`.
213
+ *
214
+ * PNG, JPEG ou WEBP, até 5 MB, no máximo 20 anexos por execução.
215
+ *
216
+ * ```ts
217
+ * await ctx.attach(await page.screenshot(), { caption: 'portal após o login' });
218
+ * ```
219
+ */
220
+ attach(image: AttachInput, options?: AttachOptions): Promise<AttachResult>;
180
221
  }
181
222
  /** O retorno do handler é ignorado — nada além de status/logs/erro é enviado ao servidor. */
182
223
  type TaskHandler<P = Record<string, unknown>> = (params: P, ctx: RunContext) => void | Promise<void>;
@@ -267,4 +308,4 @@ declare function close(timeoutMs?: number): Promise<boolean>;
267
308
 
268
309
  declare function scrubSecrets(text: string): string;
269
310
 
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 };
311
+ export { type AttachFailureReason, type AttachOptions, type AttachResult, Breadcrumb, type BreadcrumbInput, 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",
@@ -988,7 +1114,8 @@ var RunReporter = class {
988
1114
  throwIfCanceled: () => {
989
1115
  if (signal.aborted) throw runCanceledError();
990
1116
  },
991
- sleep: (ms) => cancelableSleep(ms, signal)
1117
+ sleep: (ms) => cancelableSleep(ms, signal),
1118
+ attach: (image, options) => this.attach(image, options)
992
1119
  };
993
1120
  }
994
1121
  context;
@@ -1001,6 +1128,14 @@ var RunReporter = class {
1001
1128
  abortController = new AbortController();
1002
1129
  flushIntervalMs;
1003
1130
  cancelCheckIntervalMs;
1131
+ // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
1132
+ // status/logs e não pode ficar bloqueada esperando um binário subir.
1133
+ pendingAttachments = /* @__PURE__ */ new Set();
1134
+ attachmentCount = 0;
1135
+ uploadSlots = MAX_CONCURRENT_UPLOADS;
1136
+ uploadQueue = [];
1137
+ /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
1138
+ attachmentsUnsupported = false;
1004
1139
  get isDead() {
1005
1140
  return this.dead;
1006
1141
  }
@@ -1019,6 +1154,8 @@ var RunReporter = class {
1019
1154
  this.dead = true;
1020
1155
  this.clearFlushTimer();
1021
1156
  this.buffer = [];
1157
+ const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
1158
+ for (const resume of waiting) resume();
1022
1159
  }
1023
1160
  /**
1024
1161
  * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
@@ -1036,15 +1173,22 @@ var RunReporter = class {
1036
1173
  this.push(level, scrubbed, true);
1037
1174
  }
1038
1175
  /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
1039
- push(level, message, alreadyScrubbed = false) {
1040
- if (this.dead) return;
1176
+ push(level, message, alreadyScrubbed = false, attachments) {
1177
+ const ts = nowISO();
1178
+ if (this.dead) return ts;
1041
1179
  const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
1042
- this.buffer.push({ ts: nowISO(), level, message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS) });
1180
+ this.buffer.push({
1181
+ ts,
1182
+ level,
1183
+ message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
1184
+ ...attachments && attachments.length > 0 ? { attachments } : {}
1185
+ });
1043
1186
  if (this.buffer.length >= FLUSH_AT_BUFFERED) {
1044
1187
  this.flushLogs();
1045
1188
  } else {
1046
1189
  this.scheduleFlush();
1047
1190
  }
1191
+ return ts;
1048
1192
  }
1049
1193
  scheduleFlush() {
1050
1194
  if (this.timer) return;
@@ -1112,6 +1256,130 @@ var RunReporter = class {
1112
1256
  finalize() {
1113
1257
  return this.chain;
1114
1258
  }
1259
+ // ── Anexos ───────────────────────────────────────────────────────────────────
1260
+ /**
1261
+ * Registra a linha de log com o id do anexo (síncrono, na ordem certa) e dispara
1262
+ * o upload em segundo plano. Nunca lança: todo caminho de erro vira `{ ok: false }`
1263
+ * com um aviso no log da run.
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);
@@ -1421,11 +1689,13 @@ var TaskAgent = class {
1421
1689
  this.logger.debug(`executando run ${run.id} (${run.task})`);
1422
1690
  try {
1423
1691
  await runWithRunContext({ runId: run.id, tx }, () => this.invokeWithTimeout(handler, run, reporter));
1692
+ await reporter.finalizeAttachments();
1424
1693
  tx.setStatus("ok");
1425
1694
  tx.finish();
1426
1695
  reporter.reportSuccess();
1427
1696
  this.logger.debug(`run ${run.id} conclu\xEDda`);
1428
1697
  } catch (error) {
1698
+ await reporter.finalizeAttachments();
1429
1699
  tx.setStatus("error");
1430
1700
  tx.finish();
1431
1701
  reporter.reportFailure(toRunError(error));