hobots 1.8.0 → 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",
@@ -958,7 +1084,6 @@ var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
958
1084
  var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
959
1085
  var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
960
1086
  var MAX_LOG_MESSAGE_CHARS = 1e4;
961
- var MAX_LOGS_PER_RUN = 500;
962
1087
  var MAX_LOGS_PER_PATCH = 100;
963
1088
  var FLUSH_AT_BUFFERED = 20;
964
1089
  var RunReporter = class {
@@ -989,13 +1114,12 @@ var RunReporter = class {
989
1114
  throwIfCanceled: () => {
990
1115
  if (signal.aborted) throw runCanceledError();
991
1116
  },
992
- sleep: (ms) => cancelableSleep(ms, signal)
1117
+ sleep: (ms) => cancelableSleep(ms, signal),
1118
+ attach: (image, options) => this.attach(image, options)
993
1119
  };
994
1120
  }
995
1121
  context;
996
1122
  buffer = [];
997
- loggedCount = 0;
998
- droppedCount = 0;
999
1123
  dead = false;
1000
1124
  chain = Promise.resolve();
1001
1125
  pendingUpdates = 0;
@@ -1004,6 +1128,14 @@ var RunReporter = class {
1004
1128
  abortController = new AbortController();
1005
1129
  flushIntervalMs;
1006
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;
1007
1139
  get isDead() {
1008
1140
  return this.dead;
1009
1141
  }
@@ -1022,6 +1154,8 @@ var RunReporter = class {
1022
1154
  this.dead = true;
1023
1155
  this.clearFlushTimer();
1024
1156
  this.buffer = [];
1157
+ const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
1158
+ for (const resume of waiting) resume();
1025
1159
  }
1026
1160
  /**
1027
1161
  * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
@@ -1039,20 +1173,22 @@ var RunReporter = class {
1039
1173
  this.push(level, scrubbed, true);
1040
1174
  }
1041
1175
  /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
1042
- push(level, message, alreadyScrubbed = false) {
1043
- if (this.dead) return;
1176
+ push(level, message, alreadyScrubbed = false, attachments) {
1177
+ const ts = nowISO();
1178
+ if (this.dead) return ts;
1044
1179
  const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
1045
- if (this.loggedCount >= MAX_LOGS_PER_RUN) {
1046
- this.droppedCount++;
1047
- return;
1048
- }
1049
- this.loggedCount++;
1050
- 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
+ });
1051
1186
  if (this.buffer.length >= FLUSH_AT_BUFFERED) {
1052
1187
  this.flushLogs();
1053
1188
  } else {
1054
1189
  this.scheduleFlush();
1055
1190
  }
1191
+ return ts;
1056
1192
  }
1057
1193
  scheduleFlush() {
1058
1194
  if (this.timer) return;
@@ -1069,16 +1205,7 @@ var RunReporter = class {
1069
1205
  }
1070
1206
  }
1071
1207
  takeLogs() {
1072
- const logs = this.buffer.splice(0, this.buffer.length);
1073
- if (this.droppedCount > 0) {
1074
- logs.push({
1075
- ts: nowISO(),
1076
- level: "warning",
1077
- message: `${this.droppedCount} logs descartados (limite de ${MAX_LOGS_PER_RUN} por execu\xE7\xE3o)`
1078
- });
1079
- this.droppedCount = 0;
1080
- }
1081
- return logs;
1208
+ return this.buffer.splice(0, this.buffer.length);
1082
1209
  }
1083
1210
  /** Flush incremental: envia o buffer como status 'running' (em chunks). */
1084
1211
  flushLogs() {
@@ -1129,6 +1256,130 @@ var RunReporter = class {
1129
1256
  finalize() {
1130
1257
  return this.chain;
1131
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
+ }
1132
1383
  enqueue(update) {
1133
1384
  if (this.dead) {
1134
1385
  this.salvageLogs(update.logs);
@@ -1438,11 +1689,13 @@ var TaskAgent = class {
1438
1689
  this.logger.debug(`executando run ${run.id} (${run.task})`);
1439
1690
  try {
1440
1691
  await runWithRunContext({ runId: run.id, tx }, () => this.invokeWithTimeout(handler, run, reporter));
1692
+ await reporter.finalizeAttachments();
1441
1693
  tx.setStatus("ok");
1442
1694
  tx.finish();
1443
1695
  reporter.reportSuccess();
1444
1696
  this.logger.debug(`run ${run.id} conclu\xEDda`);
1445
1697
  } catch (error) {
1698
+ await reporter.finalizeAttachments();
1446
1699
  tx.setStatus("error");
1447
1700
  tx.finish();
1448
1701
  reporter.reportFailure(toRunError(error));