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