hobots 4.0.0 → 4.2.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.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  WIRE_LIMITS
3
- } from "./chunk-5WIIACWC.js";
3
+ } from "./chunk-HKCDMUA3.js";
4
4
 
5
5
  // src/stacktrace.ts
6
6
  var MAX_FRAMES = 50;
@@ -57,7 +57,7 @@ function scrubSecrets(text) {
57
57
 
58
58
  // src/utils.ts
59
59
  var SDK_NAME = "hobots";
60
- var SDK_VERSION = true ? "4.0.0" : "0.0.0-dev";
60
+ var SDK_VERSION = true ? "4.2.0" : "0.0.0-dev";
61
61
  function isNode() {
62
62
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
63
63
  }
@@ -139,13 +139,13 @@ async function fetchWithRetry(send, options) {
139
139
  } catch (error) {
140
140
  retriable = error;
141
141
  }
142
+ const failure = retriable instanceof Error ? retriable : new Error(String(retriable));
142
143
  const base = delays[attempt];
143
- if (base === void 0) {
144
- throw retriable instanceof Error ? retriable : new Error(String(retriable));
145
- }
144
+ if (base === void 0 || options.signal?.aborted) throw failure;
146
145
  const delay = serverDelay ?? jitter(base);
147
146
  options.logger.warn(`${options.label}: falha (${String(retriable)}), retry em ${delay}ms`);
148
- await sleep(delay);
147
+ await (options.signal ? abortableSleep(delay, options.signal) : sleep(delay));
148
+ if (options.signal?.aborted) throw failure;
149
149
  }
150
150
  }
151
151
  var MAX_RETRY_AFTER_MS = 6e4;
@@ -831,6 +831,7 @@ var ATTACHMENT_TERMINAL_STATUSES = [429, 501, 503];
831
831
  var ITEMS_TERMINAL_STATUSES = [429];
832
832
  var UPLOAD_TIMEOUT_MS = 3e4;
833
833
  var UPLOAD_RETRY_DELAYS_MS = [1e3, 4e3];
834
+ var FILE_RETRY_DELAYS_MS = [1e3, 4e3];
834
835
  var TasksApiError = class extends Error {
835
836
  constructor(message, status) {
836
837
  super(message);
@@ -1018,7 +1019,13 @@ var TasksApi = class {
1018
1019
  signal: timeoutSignal
1019
1020
  })
1020
1021
  ),
1021
- { logger: this.logger, label: `anexo da run ${runId}`, noRetryStatuses: ATTACHMENT_TERMINAL_STATUSES }
1022
+ {
1023
+ logger: this.logger,
1024
+ label: `anexo da run ${runId}`,
1025
+ noRetryStatuses: ATTACHMENT_TERMINAL_STATUSES,
1026
+ // run abortada (timeout, 409, kill): o create em voo rejeita por abort e não entra na ladder
1027
+ signal
1028
+ }
1022
1029
  );
1023
1030
  if (!response.ok) {
1024
1031
  throw new TasksApiError(`cria\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
@@ -1052,7 +1059,7 @@ var TasksApi = class {
1052
1059
  signal: timeoutSignal
1053
1060
  })
1054
1061
  ),
1055
- { delays: UPLOAD_RETRY_DELAYS_MS, logger: this.logger, label: "upload de anexo" }
1062
+ { delays: UPLOAD_RETRY_DELAYS_MS, logger: this.logger, label: "upload de anexo", signal }
1056
1063
  );
1057
1064
  if (!response.ok) {
1058
1065
  throw new TasksApiError(`upload de anexo rejeitado (HTTP ${response.status})`, response.status);
@@ -1074,6 +1081,74 @@ var TasksApi = class {
1074
1081
  throw new TasksApiError(`confirma\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
1075
1082
  }
1076
1083
  }
1084
+ /**
1085
+ * Pede a URL pré-assinada de GET de um arquivo do formulário. 404 (não existe nesta run)
1086
+ * e 410 (já baixado/expirado) viram `TasksApiError` com o status — quem chama traduz.
1087
+ */
1088
+ async requestFileDownload(runId, fileId, signal) {
1089
+ const response = await fetchWithRetry(
1090
+ () => withRequestTimeout(
1091
+ UPDATE_TIMEOUT_MS,
1092
+ signal,
1093
+ (timeoutSignal) => fetch(`${this.baseUrl}/${encodeURIComponent(runId)}/files/${encodeURIComponent(fileId)}/download`, {
1094
+ redirect: "error",
1095
+ method: "POST",
1096
+ headers: this.headers(),
1097
+ signal: timeoutSignal
1098
+ })
1099
+ ),
1100
+ { delays: FILE_RETRY_DELAYS_MS, logger: this.logger, label: `download do arquivo ${fileId}`, signal }
1101
+ );
1102
+ if (!response.ok) {
1103
+ throw new TasksApiError(`download de arquivo rejeitado (HTTP ${response.status})`, response.status);
1104
+ }
1105
+ const body = await response.json().catch(() => null);
1106
+ if (typeof body?.url !== "string") {
1107
+ throw new TasksApiError("resposta inv\xE1lida do download de arquivo (url ausente)", 0);
1108
+ }
1109
+ return {
1110
+ url: body.url,
1111
+ expires_in: typeof body.expires_in === "number" ? body.expires_in : 0,
1112
+ name: typeof body.name === "string" ? body.name : "",
1113
+ size: typeof body.size === "number" ? body.size : -1,
1114
+ content_type: typeof body.content_type === "string" ? body.content_type : ""
1115
+ };
1116
+ }
1117
+ /**
1118
+ * GET do binário direto no S3 — deliberadamente sem `authorization` (a autorização
1119
+ * está na assinatura da URL; mandar o client secret para fora da API seria vazamento).
1120
+ * Devolve a Response com o corpo ainda não lido: quem chama faz o streaming para o disco.
1121
+ */
1122
+ async fetchFile(url, signal) {
1123
+ const response = await fetchWithRetry(() => fetch(url, { redirect: "error", method: "GET", signal }), {
1124
+ delays: FILE_RETRY_DELAYS_MS,
1125
+ logger: this.logger,
1126
+ label: "download de arquivo",
1127
+ signal
1128
+ });
1129
+ if (!response.ok) {
1130
+ throw new TasksApiError(`download de arquivo falhou (HTTP ${response.status})`, response.status);
1131
+ }
1132
+ return response;
1133
+ }
1134
+ /**
1135
+ * Confirma o download: o servidor apaga o objeto do S3 na hora. Idempotente (204);
1136
+ * 404/410 também significam "não há mais nada a apagar" e contam como confirmado.
1137
+ */
1138
+ async confirmFileDownload(runId, fileId) {
1139
+ const response = await fetchWithRetry(
1140
+ () => fetch(`${this.baseUrl}/${encodeURIComponent(runId)}/files/${encodeURIComponent(fileId)}`, {
1141
+ redirect: "error",
1142
+ method: "DELETE",
1143
+ headers: this.headers(),
1144
+ signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
1145
+ }),
1146
+ { delays: FILE_RETRY_DELAYS_MS, logger: this.logger, label: `confirma\xE7\xE3o do arquivo ${fileId}` }
1147
+ );
1148
+ if (!response.ok && response.status !== 404 && response.status !== 410) {
1149
+ throw new TasksApiError(`confirma\xE7\xE3o de download rejeitada (HTTP ${response.status})`, response.status);
1150
+ }
1151
+ }
1077
1152
  };
1078
1153
 
1079
1154
  // src/tasks/backoff.ts
@@ -1208,6 +1283,255 @@ async function eachItem(list, handler, options, sink) {
1208
1283
  return result;
1209
1284
  }
1210
1285
 
1286
+ // src/tasks/files.ts
1287
+ var LOCAL_FILES_DIR_ENV = "HOBOTS_LOCAL_FILES_DIR";
1288
+ var FILE_DOWNLOAD_IDLE_TIMEOUT_MS = 6e4;
1289
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1290
+ function isFileRef(value) {
1291
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1292
+ const ref = value;
1293
+ return typeof ref.id === "string" && typeof ref.name === "string" && typeof ref.size === "number" && typeof ref.contentType === "string" && (ref.storage === "s3" || ref.storage === "local");
1294
+ }
1295
+ function fileField(params, field) {
1296
+ if (!Object.prototype.hasOwnProperty.call(params, field)) return void 0;
1297
+ const value = params[field];
1298
+ if (isFileRef(value)) return value;
1299
+ if (Array.isArray(value) && value.every(isFileRef)) return value;
1300
+ return void 0;
1301
+ }
1302
+ function collectFileRefs(params) {
1303
+ const refs = [];
1304
+ for (const field of Object.keys(params)) {
1305
+ const value = fileField(params, field);
1306
+ if (value !== void 0) refs.push(...Array.isArray(value) ? value : [value]);
1307
+ }
1308
+ return refs;
1309
+ }
1310
+ function safeFileName(name, fallback) {
1311
+ const cleaned = name.replace(/^.*[\\/]/, "").replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
1312
+ return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
1313
+ }
1314
+ function isInsideDir(root, candidate, sep) {
1315
+ const base = root.endsWith(sep) ? root : root + sep;
1316
+ return candidate.startsWith(base) && candidate.length > base.length;
1317
+ }
1318
+ function unavailableLocal(ref) {
1319
+ return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
1320
+ }
1321
+ function errorCode(error) {
1322
+ return error?.code;
1323
+ }
1324
+ var RunFileStore = class {
1325
+ constructor(deps) {
1326
+ this.deps = deps;
1327
+ this.files = {
1328
+ list: () => collectFileRefs(deps.params),
1329
+ get: (field) => fileField(deps.params, field),
1330
+ // async: erro de uso (campo sem arquivo, valor inválido) chega como Promise rejeitada, nunca síncrono
1331
+ download: async (refOrField, options) => this.download(this.single(refOrField), options?.dest),
1332
+ downloadAll: async (field, options) => this.downloadAll(field, options?.dest),
1333
+ read: async (refOrField) => this.read(this.single(refOrField))
1334
+ };
1335
+ }
1336
+ deps;
1337
+ files;
1338
+ tempDir;
1339
+ /** Apaga a pasta temporária da execução (cópias que o bot não levou para `dest`). Nunca lança. */
1340
+ async cleanup() {
1341
+ const pending = this.tempDir;
1342
+ this.tempDir = void 0;
1343
+ if (!pending) return;
1344
+ try {
1345
+ const { rm } = await import("fs/promises");
1346
+ await rm(await pending, { recursive: true, force: true });
1347
+ } catch {
1348
+ }
1349
+ }
1350
+ field(field) {
1351
+ const value = fileField(this.deps.params, field);
1352
+ if (value === void 0) throw new Error(`campo "${field}" n\xE3o tem arquivo nos params`);
1353
+ return value;
1354
+ }
1355
+ single(refOrField) {
1356
+ if (typeof refOrField === "string") {
1357
+ const value = this.field(refOrField);
1358
+ if (Array.isArray(value)) {
1359
+ throw new Error(`campo "${refOrField}" tem v\xE1rios arquivos \u2014 use downloadAll("${refOrField}") ou passe um item da lista`);
1360
+ }
1361
+ return value;
1362
+ }
1363
+ if (!isFileRef(refOrField)) throw new TypeError("esperado um FileRef ou o nome de um campo de arquivo");
1364
+ return refOrField;
1365
+ }
1366
+ async downloadAll(field, dest) {
1367
+ const value = this.field(field);
1368
+ const refs = Array.isArray(value) ? value : [value];
1369
+ const paths = [];
1370
+ for (const ref of refs) paths.push(await this.download(ref, dest, true));
1371
+ return paths;
1372
+ }
1373
+ async read(ref) {
1374
+ const { readFile, rm } = await import("fs/promises");
1375
+ const { dirname } = await import("path");
1376
+ const path = await this.download(ref, void 0);
1377
+ try {
1378
+ return await readFile(path);
1379
+ } finally {
1380
+ await rm(dirname(path), { recursive: true, force: true });
1381
+ }
1382
+ }
1383
+ async download(ref, dest, destIsDir = false) {
1384
+ this.checkCanceled();
1385
+ const target = await this.resolveTarget(ref, dest, destIsDir);
1386
+ if (ref.storage === "local") await this.copyLocal(ref, target);
1387
+ else await this.downloadS3(ref, target);
1388
+ return target;
1389
+ }
1390
+ checkCanceled() {
1391
+ if (this.deps.signal.aborted) throw runCanceledError();
1392
+ }
1393
+ runTempDir() {
1394
+ this.tempDir ??= (async () => {
1395
+ const { mkdtemp } = await import("fs/promises");
1396
+ const { tmpdir } = await import("os");
1397
+ const { join } = await import("path");
1398
+ return mkdtemp(join(tmpdir(), "hobots-files-"));
1399
+ })();
1400
+ return this.tempDir;
1401
+ }
1402
+ async resolveTarget(ref, dest, destIsDir) {
1403
+ const { mkdir, mkdtemp, stat } = await import("fs/promises");
1404
+ const { dirname, join, resolve: resolve2 } = await import("path");
1405
+ const name = safeFileName(ref.name, "arquivo");
1406
+ if (dest === void 0) {
1407
+ return join(await mkdtemp(join(await this.runTempDir(), "f-")), name);
1408
+ }
1409
+ const isDir = destIsDir || dest.endsWith("/") || dest.endsWith("\\") || (await stat(dest).catch(() => void 0))?.isDirectory() === true;
1410
+ if (isDir) {
1411
+ await mkdir(dest, { recursive: true });
1412
+ return resolve2(dest, name);
1413
+ }
1414
+ await mkdir(dirname(resolve2(dest)), { recursive: true });
1415
+ return resolve2(dest);
1416
+ }
1417
+ /** storage 's3': URL pré-assinada → GET em streaming → confere o tamanho → só então DELETE. */
1418
+ async downloadS3(ref, target) {
1419
+ const { api, runId, signal } = this.deps;
1420
+ let expected;
1421
+ let url;
1422
+ try {
1423
+ const info = await api.requestFileDownload(runId, ref.id, signal);
1424
+ expected = info.size >= 0 ? info.size : ref.size;
1425
+ url = info.url;
1426
+ } catch (error) {
1427
+ throw this.translate(error, ref);
1428
+ }
1429
+ const { open, rename, rm } = await import("fs/promises");
1430
+ const part = `${target}.part`;
1431
+ const idleTimeoutMs = this.deps.idleTimeoutMs ?? FILE_DOWNLOAD_IDLE_TIMEOUT_MS;
1432
+ const controller = new AbortController();
1433
+ let timedOut = false;
1434
+ let timer;
1435
+ const arm = () => {
1436
+ clearTimeout(timer);
1437
+ timer = setTimeout(() => {
1438
+ timedOut = true;
1439
+ controller.abort();
1440
+ }, idleTimeoutMs);
1441
+ };
1442
+ const onCancel = () => controller.abort();
1443
+ signal.addEventListener("abort", onCancel, { once: true });
1444
+ try {
1445
+ arm();
1446
+ const response = await api.fetchFile(url, controller.signal);
1447
+ const handle = await open(part, "w");
1448
+ let written = 0;
1449
+ try {
1450
+ const reader = response.body?.getReader();
1451
+ const stopReading = () => void reader?.cancel().catch(() => void 0);
1452
+ controller.signal.addEventListener("abort", stopReading, { once: true });
1453
+ for (; ; ) {
1454
+ const chunk = reader ? await reader.read() : { done: true, value: void 0 };
1455
+ if (chunk.done) break;
1456
+ arm();
1457
+ written += chunk.value.byteLength;
1458
+ if (written > expected) {
1459
+ stopReading();
1460
+ break;
1461
+ }
1462
+ await handle.write(chunk.value);
1463
+ }
1464
+ } finally {
1465
+ await handle.close();
1466
+ }
1467
+ if (written !== expected) {
1468
+ throw new Error(`tamanho divergente no download de "${ref.name}": recebidos ${written} bytes, esperados ${expected}`);
1469
+ }
1470
+ await rename(part, target);
1471
+ } catch (error) {
1472
+ await rm(part, { force: true });
1473
+ if (signal.aborted) throw runCanceledError();
1474
+ if (timedOut) {
1475
+ throw new Error(`download de "${ref.name}" parado h\xE1 ${idleTimeoutMs / 1e3}s \u2014 tente de novo`);
1476
+ }
1477
+ throw this.translate(error, ref);
1478
+ } finally {
1479
+ clearTimeout(timer);
1480
+ signal.removeEventListener("abort", onCancel);
1481
+ }
1482
+ try {
1483
+ await api.confirmFileDownload(runId, ref.id);
1484
+ } catch (error) {
1485
+ this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
1486
+ }
1487
+ }
1488
+ /** storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de origem. */
1489
+ async copyLocal(ref, target) {
1490
+ const base = process.env[LOCAL_FILES_DIR_ENV];
1491
+ if (!base) throw unavailableLocal(ref);
1492
+ const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
1493
+ const { join, resolve: resolve2, sep } = await import("path");
1494
+ const root = resolve2(base);
1495
+ const folder = resolve2(root, ref.id);
1496
+ if (!isInsideDir(root, folder, sep) || !UUID_RE.test(ref.id)) {
1497
+ throw new Error(`id de arquivo local inv\xE1lido: ${JSON.stringify(ref.id)}`);
1498
+ }
1499
+ let names;
1500
+ try {
1501
+ names = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
1502
+ } catch (error) {
1503
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") throw unavailableLocal(ref);
1504
+ throw error;
1505
+ }
1506
+ if (names.length === 0) throw unavailableLocal(ref);
1507
+ if (names.length > 1) throw new Error(`pasta do arquivo "${ref.name}" tem ${names.length} arquivos \u2014 esperado 1`);
1508
+ this.checkCanceled();
1509
+ const part = `${target}.part`;
1510
+ try {
1511
+ await copyFile(join(folder, names[0]), part);
1512
+ const { size } = await stat(part);
1513
+ if (size !== ref.size) {
1514
+ throw new Error(`tamanho divergente na c\xF3pia de "${ref.name}": copiados ${size} bytes, esperados ${ref.size}`);
1515
+ }
1516
+ await rename(part, target);
1517
+ } catch (error) {
1518
+ await rm(part, { force: true });
1519
+ throw error;
1520
+ }
1521
+ await rm(folder, { recursive: true, force: true });
1522
+ }
1523
+ translate(error, ref) {
1524
+ if (this.deps.signal.aborted) return runCanceledError();
1525
+ if (error instanceof TasksApiError && error.status === 410) {
1526
+ return new TasksApiError(`arquivo "${ref.name}" j\xE1 baixado ou expirado`, 410);
1527
+ }
1528
+ if (error instanceof TasksApiError && error.status === 404) {
1529
+ return new TasksApiError(`arquivo "${ref.name}" n\xE3o encontrado nesta execu\xE7\xE3o`, 404);
1530
+ }
1531
+ return error;
1532
+ }
1533
+ };
1534
+
1211
1535
  // src/retry.ts
1212
1536
  var DEFAULT_RETRIES = 3;
1213
1537
  var DEFAULT_DELAY_MS = 1e3;
@@ -1348,7 +1672,6 @@ var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
1348
1672
  var MAX_LOG_MESSAGE_CHARS = WIRE_LIMITS.MAX_LOG_MESSAGE_CHARS;
1349
1673
  var MAX_LOGS_PER_PATCH = WIRE_LIMITS.MAX_LOGS_PER_PATCH;
1350
1674
  var FLUSH_AT_BUFFERED = 20;
1351
- var MAX_ITEMS_PER_RUN = WIRE_LIMITS.MAX_ITEMS_PER_RUN;
1352
1675
  var MAX_ITEMS_PER_REQUEST = WIRE_LIMITS.MAX_ITEMS_PER_REQUEST;
1353
1676
  var MAX_ITEM_MESSAGE_CHARS = WIRE_LIMITS.MAX_ITEM_MESSAGE_CHARS;
1354
1677
  var MAX_ITEM_PAYLOAD_BYTES = WIRE_LIMITS.MAX_ITEM_PAYLOAD_BYTES;
@@ -1370,6 +1693,7 @@ var RunReporter = class {
1370
1693
  this.api = api;
1371
1694
  this.runId = runId;
1372
1695
  this.logger = logger;
1696
+ this.tx = tx;
1373
1697
  this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
1374
1698
  this.cancelCheckIntervalMs = Math.min(
1375
1699
  MAX_CANCEL_CHECK_INTERVAL_MS,
@@ -1446,13 +1770,16 @@ var RunReporter = class {
1446
1770
  );
1447
1771
  const signal = this.abortController.signal;
1448
1772
  const sleep2 = (ms) => cancelableSleep(ms, signal);
1773
+ this.fileStore = new RunFileStore({ api, runId, params, signal, warn: (message) => this.log("warning", message) });
1449
1774
  this.context = {
1450
1775
  runId,
1451
1776
  params,
1452
1777
  log,
1453
1778
  items,
1454
1779
  progress,
1780
+ files: this.fileStore.files,
1455
1781
  transaction: tx,
1782
+ flush: (timeoutMs) => this.flush(timeoutMs),
1456
1783
  signal,
1457
1784
  throwIfCanceled: checkCanceled,
1458
1785
  sleep: sleep2,
@@ -1462,6 +1789,7 @@ var RunReporter = class {
1462
1789
  api;
1463
1790
  runId;
1464
1791
  logger;
1792
+ tx;
1465
1793
  context;
1466
1794
  buffer = [];
1467
1795
  dead = false;
@@ -1479,7 +1807,6 @@ var RunReporter = class {
1479
1807
  itemsChain = Promise.resolve();
1480
1808
  itemsTimer;
1481
1809
  itemSeq = 0;
1482
- itemCount = 0;
1483
1810
  /** Servidor sem o recurso (404) — para de tentar (e de avisar) nesta run. */
1484
1811
  itemsUnsupported = false;
1485
1812
  /** Cap de itens por run atingido (client-side ou 429) — degrada com um aviso só. */
@@ -1505,6 +1832,12 @@ var RunReporter = class {
1505
1832
  progressDirty = false;
1506
1833
  progressTimer;
1507
1834
  progressWarned = false;
1835
+ /** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
1836
+ fileStore;
1837
+ /** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
1838
+ cleanupFiles() {
1839
+ return this.fileStore.cleanup();
1840
+ }
1508
1841
  get isDead() {
1509
1842
  return this.dead;
1510
1843
  }
@@ -1602,6 +1935,21 @@ var RunReporter = class {
1602
1935
  this.enqueue({ status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) });
1603
1936
  }
1604
1937
  }
1938
+ /**
1939
+ * `hobots.flush()`: o bot decide quando entregar o que já fez — steps raiz finalizadas
1940
+ * viram transactions parciais da run, e itens, logs e progresso pendentes saem já, sem
1941
+ * esperar o throttle. Aguarda a entrega com teto. Nunca lança (pode ir num `finally`).
1942
+ */
1943
+ async flush(timeoutMs = ITEMS_DRAIN_MS) {
1944
+ this.tx.flush();
1945
+ this.flushItems();
1946
+ if (this.progressDirty) this.flushProgress();
1947
+ else this.flushLogs();
1948
+ await Promise.race([
1949
+ Promise.all([this.itemsChain, this.chain, this.tx.drain(timeoutMs)]).catch(() => void 0),
1950
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
1951
+ ]);
1952
+ }
1605
1953
  /** Primeiro PATCH (claimed → running). Retorna false se a run já morreu (cancelada). */
1606
1954
  async reportRunning() {
1607
1955
  this.enqueue({ status: "running" });
@@ -1712,14 +2060,6 @@ var RunReporter = class {
1712
2060
  */
1713
2061
  addItem(status, message, payload, id, image3) {
1714
2062
  if (this.itemsClosed || this.itemsUnsupported) return;
1715
- if (this.itemCount >= MAX_ITEMS_PER_RUN) {
1716
- if (!this.itemsQuotaExceeded) {
1717
- this.itemsQuotaExceeded = true;
1718
- this.log("warning", `itens descartados: limite de ${MAX_ITEMS_PER_RUN} por execu\xE7\xE3o atingido`);
1719
- }
1720
- return;
1721
- }
1722
- this.itemCount++;
1723
2063
  let scrubbedPayload;
1724
2064
  if (payload) {
1725
2065
  scrubbedPayload = scrubPayloadValue(payload, 0);
@@ -2014,6 +2354,7 @@ function currentRunReporter() {
2014
2354
  }
2015
2355
 
2016
2356
  // src/transaction.ts
2357
+ var FLUSH_TRANSACTION_OP = "hobots.run.flush";
2017
2358
  var MAX_STEP_MESSAGE_CHARS = 2e3;
2018
2359
  function stepMessage(text) {
2019
2360
  return truncate(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
@@ -2235,6 +2576,60 @@ var Transaction = class {
2235
2576
  this.name = name;
2236
2577
  return this;
2237
2578
  }
2579
+ /**
2580
+ * Envia já as steps raiz finalizadas (e todas as descendentes, fechando as que
2581
+ * ficaram abertas) como transactions parciais da mesma run (`op` hobots.run.flush,
2582
+ * `event_id` novo, até 200 steps cada) e as tira da memória. Steps raiz ainda abertas
2583
+ * ficam para o próximo flush ou para o `finish()`. É assim que um agente sempre
2584
+ * ligado (`start()`), cuja run não termina, entrega as etapas enquanto processa.
2585
+ * Devolve quantas steps foram enviadas.
2586
+ */
2587
+ flush() {
2588
+ if (this.finished) return 0;
2589
+ const ready = /* @__PURE__ */ new Set();
2590
+ for (const step of this.steps) {
2591
+ if (step.parentStepId === this.stepId && step.endTimestamp) ready.add(step.stepId);
2592
+ }
2593
+ if (ready.size === 0) return 0;
2594
+ const batch = [];
2595
+ const remaining = [];
2596
+ for (const step of this.steps) {
2597
+ if (ready.has(step.stepId) || ready.has(step.parentStepId)) {
2598
+ ready.add(step.stepId);
2599
+ batch.push(step);
2600
+ } else {
2601
+ remaining.push(step);
2602
+ }
2603
+ }
2604
+ for (const step of batch) step.finish();
2605
+ this.steps.splice(0, this.steps.length, ...remaining);
2606
+ if (this.client) {
2607
+ for (let i = 0; i < batch.length; i += MAX_STEPS) {
2608
+ const chunk = batch.slice(i, i + MAX_STEPS);
2609
+ const startTimestamp = chunk.reduce((min, step) => step.startTimestamp < min ? step.startTimestamp : min, chunk[0].startTimestamp);
2610
+ const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2611
+ this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, startTimestamp, nowISO(), chunk));
2612
+ }
2613
+ }
2614
+ return batch.length;
2615
+ }
2616
+ /** Entrega já (com teto) o que está na fila do transport — as transactions parciais inclusive. */
2617
+ async drain(timeoutMs) {
2618
+ await this.client?.flush(timeoutMs);
2619
+ }
2620
+ payload(eventId, op, status, startTimestamp, timestamp, steps) {
2621
+ return {
2622
+ event_id: eventId,
2623
+ name: this.name,
2624
+ op,
2625
+ status,
2626
+ start_timestamp: startTimestamp,
2627
+ timestamp,
2628
+ steps: steps.map((step) => step.toJSON()),
2629
+ tags: Object.keys(this.tags).length > 0 ? { ...this.tags } : void 0,
2630
+ run_id: this.runId
2631
+ };
2632
+ }
2238
2633
  finish() {
2239
2634
  if (this.finished) return;
2240
2635
  this.finished = true;
@@ -2249,18 +2644,15 @@ var Transaction = class {
2249
2644
  this.status
2250
2645
  );
2251
2646
  if (!this.client) return;
2252
- const payload = {
2253
- event_id: this.eventId,
2254
- name: this.name,
2255
- op: this.op,
2256
- status: this.status,
2257
- start_timestamp: this.startTimestamp,
2258
- timestamp: endTimestamp,
2259
- steps: this.steps.map((step) => step.toJSON()),
2260
- tags: Object.keys(this.tags).length > 0 ? this.tags : void 0,
2261
- run_id: this.runId
2262
- };
2263
- this.client.sendTransaction(payload);
2647
+ const steps = this.steps;
2648
+ let first = 0;
2649
+ while (steps.length - first > MAX_STEPS) {
2650
+ const chunk = steps.slice(first, first + MAX_STEPS);
2651
+ const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2652
+ this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, chunk[0].startTimestamp, endTimestamp, chunk));
2653
+ first += MAX_STEPS;
2654
+ }
2655
+ this.client.sendTransaction(this.payload(this.eventId, this.op, this.status, this.startTimestamp, endTimestamp, steps.slice(first)));
2264
2656
  }
2265
2657
  };
2266
2658
 
@@ -2308,6 +2700,7 @@ async function executeRun(input) {
2308
2700
  }
2309
2701
  await reporter.finalize();
2310
2702
  } finally {
2703
+ await reporter.cleanupFiles();
2311
2704
  tracker.remove(reporter);
2312
2705
  }
2313
2706
  }
@@ -2763,7 +3156,9 @@ function resolveRunOptions(options, mode, logger) {
2763
3156
  }
2764
3157
  function instanceInfo(rt) {
2765
3158
  return {
3159
+ /* v8 ignore start -- defensivo: o boot define o instanceId antes de qualquer poll/criação de run */
2766
3160
  instance_id: rt.instanceId ?? "unknown",
3161
+ /* v8 ignore stop */
2767
3162
  version: SDK_VERSION,
2768
3163
  host: hostname(),
2769
3164
  // ambiente da instância: junto do heartbeat, é como o servidor sabe onde o robô roda
@@ -2940,6 +3335,14 @@ var fallbackItems = {
2940
3335
  each: (list, handler, options = {}) => eachItem(list, handler, options, fallbackEachSink)
2941
3336
  };
2942
3337
  var fallbackProgress = { total: noop, advance: noop, set: noop, current: 0, snapshot: null };
3338
+ var outsideRunFiles = () => Promise.reject(new Error("hobots.files s\xF3 baixa arquivos dentro de uma execu\xE7\xE3o (handler do register()/start())"));
3339
+ var fallbackFiles = {
3340
+ list: () => [],
3341
+ get: () => void 0,
3342
+ download: outsideRunFiles,
3343
+ downloadAll: outsideRunFiles,
3344
+ read: outsideRunFiles
3345
+ };
2943
3346
  var neverAborts = new AbortController().signal;
2944
3347
  var detachedTransaction;
2945
3348
  function resolve(member) {
@@ -2961,10 +3364,12 @@ var hobots = Object.defineProperties({}, {
2961
3364
  log: getter("log", () => fallbackLog),
2962
3365
  items: getter("items", () => fallbackItems),
2963
3366
  progress: getter("progress", () => fallbackProgress),
3367
+ files: getter("files", () => fallbackFiles),
2964
3368
  transaction: getter("transaction", () => detachedTransaction ??= new Transaction(void 0, "hobots.detached")),
2965
3369
  signal: getter("signal", () => neverAborts),
2966
3370
  throwIfCanceled: method(() => resolve("throwIfCanceled")?.throwIfCanceled()),
2967
3371
  sleep: method((ms) => resolve("sleep")?.sleep(ms) ?? sleep(ms)),
3372
+ flush: method((timeoutMs) => resolve("flush")?.flush(timeoutMs) ?? Promise.resolve()),
2968
3373
  retry: method((fn, options) => {
2969
3374
  const reporter = currentReporter();
2970
3375
  return reporter ? reporter.context.retry(fn, options) : retryWithBackoff(fn, options);
@@ -2980,7 +3385,8 @@ var hobots = Object.defineProperties({}, {
2980
3385
  }),
2981
3386
  close: method((timeoutMs) => close(timeoutMs)),
2982
3387
  captureException: method((error, hint) => captureException(error, hint)),
2983
- image: method(image)
3388
+ image: method(image),
3389
+ isFileRef: method(isFileRef)
2984
3390
  });
2985
3391
  function installGlobalHobots() {
2986
3392
  if (globalThis.hobots === hobots) return;
@@ -2992,6 +3398,7 @@ var close2 = hobots.close;
2992
3398
  var captureException2 = hobots.captureException;
2993
3399
  var retry = hobots.retry;
2994
3400
  var image2 = hobots.image;
3401
+ var isFileRef2 = hobots.isFileRef;
2995
3402
 
2996
3403
  // src/index.ts
2997
3404
  var src_default = hobots;
@@ -3001,6 +3408,7 @@ export {
3001
3408
  src_default as default,
3002
3409
  hobots,
3003
3410
  image2 as image,
3411
+ isFileRef2 as isFileRef,
3004
3412
  register2 as register,
3005
3413
  retry,
3006
3414
  start2 as start