hobots 5.0.0 → 5.1.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.cjs CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
+ Occurrence: () => Occurrence,
33
34
  captureException: () => captureException2,
34
35
  close: () => close2,
35
36
  default: () => src_default,
@@ -44,7 +45,7 @@ module.exports = __toCommonJS(src_exports);
44
45
 
45
46
  // src/utils.ts
46
47
  var SDK_NAME = "hobots";
47
- var SDK_VERSION = true ? "5.0.0" : "0.0.0-dev";
48
+ var SDK_VERSION = true ? "5.1.0" : "0.0.0-dev";
48
49
  function isNode() {
49
50
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
50
51
  }
@@ -357,6 +358,7 @@ var protocol_limits_default = {
357
358
  MAX_ITEM_PAYLOAD_BYTES: 16384,
358
359
  MAX_ITEM_COLUMNS: 12,
359
360
  MAX_ITEM_COLUMN_LABEL_CHARS: 60,
361
+ MAX_ITEMS_DONE_PER_PAGE: 1e3,
360
362
  MAX_ATTACHMENT_BYTES: 5242880,
361
363
  MAX_ATTACHMENTS_PER_RUN: 20,
362
364
  MAX_ATTACHMENTS_PER_LOG_ENTRY: 4,
@@ -1165,6 +1167,36 @@ var TasksApi = class {
1165
1167
  duplicates: typeof body?.duplicates === "number" ? body.duplicates : 0
1166
1168
  };
1167
1169
  }
1170
+ /**
1171
+ * Uma página dos ids já concluídos neste trabalho (`items.each` pula). Retry em falha
1172
+ * de rede/5xx; 4xx é definitivo (404 = servidor sem o recurso).
1173
+ */
1174
+ async listDoneItems(runId, request) {
1175
+ let response;
1176
+ try {
1177
+ response = await fetchWithRetry(
1178
+ () => fetch(`${this.baseUrl}/${encodeURIComponent(runId)}/items/done`, {
1179
+ redirect: "error",
1180
+ method: "POST",
1181
+ headers: this.headers(),
1182
+ body: JSON.stringify(request),
1183
+ signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
1184
+ }),
1185
+ { logger: this.logger, label: `itens conclu\xEDdos da run ${runId}` }
1186
+ );
1187
+ } catch (error) {
1188
+ throw new Error(`consulta de itens conclu\xEDdos da run ${runId} descartada ap\xF3s retries: ${String(error)}`);
1189
+ }
1190
+ if (!response.ok) {
1191
+ throw new TasksApiError(`consulta de itens conclu\xEDdos rejeitada (HTTP ${response.status})`, response.status);
1192
+ }
1193
+ const body = await response.json().catch(() => null);
1194
+ if (!Array.isArray(body?.ids)) throw new TasksApiError("consulta de itens conclu\xEDdos: resposta malformada", 0);
1195
+ return {
1196
+ ids: body.ids.map((id) => String(id)),
1197
+ next: typeof body.next === "string" && body.next.length > 0 ? body.next : null
1198
+ };
1199
+ }
1168
1200
  /**
1169
1201
  * Registra o anexo e obtém a URL presignada de upload. 404/501/503 significam
1170
1202
  * "esta instalação não tem anexos" — o RunReporter degrada em silêncio a partir daí.
@@ -1351,6 +1383,20 @@ var Backoff = class {
1351
1383
  };
1352
1384
 
1353
1385
  // src/tasks/errors.ts
1386
+ var Occurrence = class extends Error {
1387
+ payload;
1388
+ /** Print do item (bytes/caminho ou `hobots.image(...)`) — o `each` pendura no item. */
1389
+ image;
1390
+ constructor(message, payload, options) {
1391
+ super(message);
1392
+ this.name = "Occurrence";
1393
+ this.payload = payload;
1394
+ this.image = options?.image;
1395
+ }
1396
+ };
1397
+ function isOccurrence(error) {
1398
+ return error instanceof Error && error.name === "Occurrence";
1399
+ }
1354
1400
  function runCanceledError() {
1355
1401
  return Object.assign(new Error("execu\xE7\xE3o cancelada"), { name: "RunCanceled" });
1356
1402
  }
@@ -1367,1617 +1413,1745 @@ function isCancellation(error) {
1367
1413
  return error instanceof Error && (error.name === "RunCanceled" || error.name === "AbortError");
1368
1414
  }
1369
1415
 
1370
- // src/tasks/each.ts
1371
- function thrownMessage(error) {
1372
- if (error instanceof Error) return error.message || error.name;
1373
- return safeString(error);
1374
- }
1375
- var ItemHandle = class {
1376
- constructor(index, id, step, basePayload, sink) {
1377
- this.index = index;
1378
- this.id = id;
1379
- this.step = step;
1380
- this.basePayload = basePayload;
1381
- this.sink = sink;
1382
- }
1383
- index;
1384
- id;
1385
- step;
1386
- basePayload;
1387
- sink;
1388
- reported = false;
1389
- status;
1390
- succeeded(payload, options) {
1391
- this.record("succeeded", void 0, payload, options?.image);
1392
- }
1393
- failed(message, payload, options) {
1394
- this.record("failed", message, payload, options?.image);
1395
- if (this.step && this.step.status !== "error") this.step._setStatus("error", message);
1396
- }
1397
- occurrence(message, payload, options) {
1398
- this.record("occurrence", message, payload, options?.image);
1399
- if (this.step && this.step.status === "ok") this.step._setStatus("warning", message);
1400
- }
1401
- record(status, message, payload, image3) {
1402
- this.reported = true;
1403
- this.status = status;
1404
- const merged = { ...this.basePayload ?? {}, ...payload ?? {} };
1405
- this.sink.report(status, message, Object.keys(merged).length > 0 ? merged : void 0, this.id, image3);
1416
+ // src/tasks/attachments.ts
1417
+ var MAX_ATTACHMENT_BYTES = WIRE_LIMITS.MAX_ATTACHMENT_BYTES;
1418
+ var MAX_ATTACHMENTS_PER_RUN = WIRE_LIMITS.MAX_ATTACHMENTS_PER_RUN;
1419
+ var MAX_ATTACHMENTS_PER_LOG_ENTRY = WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY;
1420
+ var MAX_CONCURRENT_UPLOADS = 3;
1421
+ var MAX_CAPTION_CHARS = WIRE_LIMITS.MAX_CAPTION_CHARS;
1422
+ var ATTACHMENT_DRAIN_MS = 15e3;
1423
+ var IMAGE_BRAND = /* @__PURE__ */ Symbol.for("hobots.image");
1424
+ var ImageAttachment = class {
1425
+ constructor(input, caption, contentType) {
1426
+ this.input = input;
1427
+ this.caption = caption;
1428
+ this.contentType = contentType;
1406
1429
  }
1430
+ input;
1431
+ caption;
1432
+ contentType;
1433
+ [IMAGE_BRAND] = true;
1407
1434
  };
1408
- function resolveId(options, element, index) {
1409
- const raw = options.id?.(element, index);
1410
- if (raw === null || raw === void 0) return void 0;
1411
- const text = safeString(raw);
1412
- return text.length > 0 ? text : void 0;
1435
+ function image(input, options = {}) {
1436
+ return new ImageAttachment(input, options.caption, options.contentType);
1413
1437
  }
1414
- function knownLength(list) {
1415
- const length = list?.length;
1416
- return typeof length === "number" && Number.isInteger(length) && length >= 1 ? length : void 0;
1438
+ function isImageAttachment(value) {
1439
+ return typeof value === "object" && value !== null && value[IMAGE_BRAND] === true;
1417
1440
  }
1418
- async function eachItem(list, handler, options, sink) {
1419
- const result = { total: 0, succeeded: 0, failed: 0, occurrence: 0 };
1420
- const length = options.progress === false ? void 0 : knownLength(list);
1421
- if (length !== void 0) sink.declareTotal(length);
1422
- let index = 0;
1423
- for await (const element of list) {
1424
- sink.checkCanceled();
1425
- const id = resolveId(options, element, index);
1426
- const description = options.description?.(element, index) ?? id ?? `#${index + 1}`;
1427
- const step = sink.startStep(options.op ?? "item", description);
1428
- const handle = new ItemHandle(index, id, step, options.payload?.(element, index), sink);
1429
- try {
1430
- await sink.stepScope(step, async () => handler(element, handle, index));
1431
- if (!handle.reported) handle.succeeded();
1432
- } catch (error) {
1433
- if (isTerminalError(error) || sink.isAborted()) {
1434
- step?._setStatus("cancelled")._close();
1435
- throw error;
1436
- }
1437
- const message = thrownMessage(error);
1438
- if (!handle.reported) handle.failed(message);
1439
- step?._failed(error);
1440
- sink.logError(`item ${description} falhou: ${message}`);
1441
- }
1442
- step?._close();
1443
- if (options.progress !== false) sink.advance();
1444
- result.total += 1;
1445
- result[handle.status ?? "succeeded"] += 1;
1446
- index += 1;
1447
- if (options.flush !== false) await sink.flush();
1441
+ function toImageAttachment(value) {
1442
+ return isImageAttachment(value) ? value : new ImageAttachment(value);
1443
+ }
1444
+ async function readImageFile(path) {
1445
+ if (!isNode()) {
1446
+ throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
1448
1447
  }
1449
- return result;
1448
+ const { readFile } = await import("fs/promises");
1449
+ return new Uint8Array(await readFile(path));
1450
1450
  }
1451
-
1452
- // src/tasks/files.ts
1453
- var LOCAL_FILES_DIR_ENV = "HOBOTS_LOCAL_FILES_DIR";
1454
- var FILE_DOWNLOAD_IDLE_TIMEOUT_MS = 6e4;
1455
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1456
- function isFileRef(value) {
1457
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1458
- const ref = value;
1459
- return typeof ref.id === "string" && typeof ref.name === "string" && typeof ref.size === "number" && typeof ref.contentType === "string" && (ref.storage === "s3" || ref.storage === "local");
1451
+ function startsWith(bytes, signature, offset = 0) {
1452
+ if (bytes.length < offset + signature.length) return false;
1453
+ return signature.every((byte, index) => bytes[offset + index] === byte);
1460
1454
  }
1461
- function fileField(params, field) {
1462
- if (!Object.prototype.hasOwnProperty.call(params, field)) return void 0;
1463
- const value = params[field];
1464
- if (isFileRef(value)) return value;
1465
- if (Array.isArray(value) && value.every(isFileRef)) return value;
1455
+ var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
1456
+ var MAX_PNG_DIMENSION = WIRE_LIMITS.MAX_IMAGE_DIMENSION_PX;
1457
+ var JPEG_MAGIC = [255, 216, 255];
1458
+ var RIFF_MAGIC = [82, 73, 70, 70];
1459
+ var WEBP_MAGIC = [87, 69, 66, 80];
1460
+ function sniffContentType(bytes) {
1461
+ if (startsWith(bytes, PNG_MAGIC)) return "image/png";
1462
+ if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
1463
+ if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
1466
1464
  return void 0;
1467
1465
  }
1468
- function collectFileRefs(params) {
1469
- const refs = [];
1470
- for (const field of Object.keys(params)) {
1471
- const value = fileField(params, field);
1472
- if (value !== void 0) refs.push(...Array.isArray(value) ? value : [value]);
1473
- }
1474
- return refs;
1466
+ function readPngDimensions(bytes) {
1467
+ if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
1468
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1469
+ const width = view.getUint32(16);
1470
+ const height = view.getUint32(20);
1471
+ if (width === 0 || height === 0) return void 0;
1472
+ if (width > MAX_PNG_DIMENSION || height > MAX_PNG_DIMENSION) return void 0;
1473
+ return { width, height };
1475
1474
  }
1476
- function safeFileName(name, fallback) {
1477
- const cleaned = name.replace(/^.*[\\/]/, "").replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
1478
- return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
1475
+ async function toBytes(input) {
1476
+ if (typeof input === "string") return readImageFile(input);
1477
+ if (input instanceof Uint8Array) return input;
1478
+ return new Uint8Array(input);
1479
1479
  }
1480
- function isInsideDir(root, candidate, sep) {
1481
- const base = root.endsWith(sep) ? root : root + sep;
1482
- return candidate.startsWith(base) && candidate.length > base.length;
1480
+
1481
+ // src/tasks/run-context-store.ts
1482
+ var storage;
1483
+ var stepStorage;
1484
+ var initPromise;
1485
+ async function ensureStorage() {
1486
+ if (storage || !isNode()) return;
1487
+ initPromise ??= import("async_hooks").then(({ AsyncLocalStorage: ALS }) => {
1488
+ storage = new ALS();
1489
+ stepStorage = new ALS();
1490
+ });
1491
+ await initPromise;
1483
1492
  }
1484
- function unavailableLocal(ref) {
1485
- return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
1493
+ async function runWithRunContext(store, fn) {
1494
+ await ensureStorage();
1495
+ return storage ? storage.run(store, fn) : fn();
1486
1496
  }
1487
- function localRunPath(ref) {
1488
- if (!isLocalRun()) return void 0;
1489
- const path = ref.path;
1490
- return typeof path === "string" && path.trim() !== "" ? path : void 0;
1497
+ function currentRunReporter() {
1498
+ return storage?.getStore()?.reporter;
1491
1499
  }
1492
- function errorCode(error) {
1493
- return error?.code;
1500
+ async function runWithStepScope(scope, fn) {
1501
+ await ensureStorage();
1502
+ return stepStorage ? stepStorage.run(scope, fn) : fn();
1494
1503
  }
1495
- var RunFileStore = class {
1496
- constructor(deps) {
1497
- this.deps = deps;
1498
- this.files = {
1499
- list: () => collectFileRefs(deps.params),
1500
- get: (field) => fileField(deps.params, field),
1501
- // async: erro de uso (campo sem arquivo, valor inválido) chega como Promise rejeitada, nunca síncrono
1502
- download: async (refOrField, options) => this.download(this.single(refOrField), options?.dest),
1503
- downloadAll: async (field, options) => this.downloadAll(field, options?.dest),
1504
- read: async (refOrField) => this.read(this.single(refOrField))
1505
- };
1506
- }
1507
- deps;
1508
- files;
1509
- tempDir;
1510
- /** Apaga a pasta temporária da execução (cópias que o bot não levou para `dest`). Nunca lança. */
1511
- async cleanup() {
1512
- const pending = this.tempDir;
1513
- this.tempDir = void 0;
1514
- if (!pending) return;
1504
+ function currentStep(transaction) {
1505
+ const scope = stepStorage?.getStore();
1506
+ return scope?.transaction === transaction ? scope.step : void 0;
1507
+ }
1508
+
1509
+ // src/retry.ts
1510
+ var DEFAULT_RETRIES = 3;
1511
+ var DEFAULT_DELAY_MS = 1e3;
1512
+ function resolveDelay(delay, attempt, fallbackMs) {
1513
+ const value = typeof delay === "function" ? delay(attempt) : delay ?? fallbackMs;
1514
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1515
+ }
1516
+ async function retryWithBackoff(fn, options = {}) {
1517
+ const requested = options.retries ?? DEFAULT_RETRIES;
1518
+ const retries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_RETRIES;
1519
+ const wait = options.wait ?? sleep;
1520
+ for (let attempt = 1; ; attempt++) {
1521
+ if (options.signal?.aborted) throw runCanceledError();
1515
1522
  try {
1516
- const { rm } = await import("fs/promises");
1517
- await rm(await pending, { recursive: true, force: true });
1518
- } catch {
1519
- }
1520
- }
1521
- field(field) {
1522
- const value = fileField(this.deps.params, field);
1523
- if (value === void 0) throw new Error(`campo "${field}" n\xE3o tem arquivo nos params`);
1524
- return value;
1525
- }
1526
- single(refOrField) {
1527
- if (typeof refOrField === "string") {
1528
- const value = this.field(refOrField);
1529
- if (Array.isArray(value)) {
1530
- throw new Error(`campo "${refOrField}" tem v\xE1rios arquivos \u2014 use downloadAll("${refOrField}") ou passe um item da lista`);
1531
- }
1532
- return value;
1523
+ return await fn(attempt);
1524
+ } catch (error) {
1525
+ const exhausted = attempt > retries;
1526
+ const retriable = !isTerminalError(error) && !options.signal?.aborted && (options.shouldRetry?.(error) ?? true);
1527
+ if (exhausted || !retriable) throw error;
1528
+ const delay = resolveDelay(options.delayMs, attempt, DEFAULT_DELAY_MS);
1529
+ options.onRetry?.(error, attempt, delay);
1530
+ await wait(delay);
1533
1531
  }
1534
- if (!isFileRef(refOrField)) throw new TypeError("esperado um FileRef ou o nome de um campo de arquivo");
1535
- return refOrField;
1536
1532
  }
1537
- async downloadAll(field, dest) {
1538
- const value = this.field(field);
1539
- const refs = Array.isArray(value) ? value : [value];
1540
- const paths = [];
1541
- for (const ref of refs) paths.push(await this.download(ref, dest, true));
1542
- return paths;
1533
+ }
1534
+
1535
+ // src/transaction.ts
1536
+ var FLUSH_TRANSACTION_OP = "hobots.run.flush";
1537
+ var DEFAULT_STEP_RETRIES = 1;
1538
+ var DEFAULT_STEP_RETRY_DELAY_MS = 5e3;
1539
+ var MAX_STEP_MESSAGE_CHARS = 2e3;
1540
+ var MAX_BACKLOG = 100;
1541
+ function stepMessage(text) {
1542
+ return clip(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
1543
+ }
1544
+ function stepId() {
1545
+ return uuid4().slice(0, 16);
1546
+ }
1547
+ function stepLabel(op, description) {
1548
+ return [op, description].filter(Boolean).join(" \u2014 ") || "(sem nome)";
1549
+ }
1550
+ function errorText(error) {
1551
+ if (error instanceof Error) return error.message || error.name;
1552
+ return safeString(error);
1553
+ }
1554
+ function errorReason(error) {
1555
+ return error instanceof Error ? `${error.name}: ${errorText(error)}` : errorText(error);
1556
+ }
1557
+ function levelForStatus(status) {
1558
+ switch (status) {
1559
+ case "warning":
1560
+ return "warning";
1561
+ case "error":
1562
+ return "error";
1563
+ case "cancelled":
1564
+ return "warning";
1565
+ default:
1566
+ return "success";
1543
1567
  }
1544
- async read(ref) {
1545
- const { readFile, rm } = await import("fs/promises");
1546
- const { dirname } = await import("path");
1547
- const path = await this.download(ref, void 0);
1548
- try {
1549
- return await readFile(path);
1550
- } finally {
1551
- await rm(dirname(path), { recursive: true, force: true });
1552
- }
1568
+ }
1569
+ function colorForStatus(status) {
1570
+ switch (status) {
1571
+ case "warning":
1572
+ return "yellow";
1573
+ case "error":
1574
+ return "red";
1575
+ case "cancelled":
1576
+ return "gray";
1577
+ default:
1578
+ return "green";
1553
1579
  }
1554
- async download(ref, dest, destIsDir = false) {
1555
- this.checkCanceled();
1556
- const target = await this.resolveTarget(ref, dest, destIsDir);
1557
- if (ref.storage === "local") await this.copyLocal(ref, target);
1558
- else await this.downloadS3(ref, target);
1559
- return target;
1580
+ }
1581
+ var Step = class {
1582
+ constructor(transaction, parentStepId, op, description) {
1583
+ this.transaction = transaction;
1584
+ this.parentStepId = parentStepId;
1585
+ this.op = op;
1586
+ this.description = description;
1560
1587
  }
1561
- checkCanceled() {
1562
- if (this.deps.signal.aborted) throw runCanceledError();
1588
+ transaction;
1589
+ parentStepId;
1590
+ op;
1591
+ description;
1592
+ stepId = stepId();
1593
+ startTimestamp = nowISO();
1594
+ status = "ok";
1595
+ endTimestamp;
1596
+ _data;
1597
+ // ── API do bot ────────────────────────────────────────────────────────────
1598
+ warning(message, options) {
1599
+ if (this.status !== "error") this._setStatus("warning", message);
1600
+ if (options?.image !== void 0) this.attachImage(options.image);
1601
+ return this;
1563
1602
  }
1564
- runTempDir() {
1565
- this.tempDir ??= (async () => {
1566
- const { mkdtemp } = await import("fs/promises");
1567
- const { tmpdir } = await import("os");
1568
- const { join } = await import("path");
1569
- return mkdtemp(join(tmpdir(), "hobots-files-"));
1570
- })();
1571
- return this.tempDir;
1603
+ image(image3) {
1604
+ this.attachImage(image3);
1605
+ return this;
1572
1606
  }
1573
- async resolveTarget(ref, dest, destIsDir) {
1574
- const { mkdir, mkdtemp, stat } = await import("fs/promises");
1575
- const { dirname, join, resolve: resolve2 } = await import("path");
1576
- const name = safeFileName(ref.name, "arquivo");
1577
- if (dest === void 0) {
1578
- return join(await mkdtemp(join(await this.runTempDir(), "f-")), name);
1579
- }
1580
- const isDir = destIsDir || dest.endsWith("/") || dest.endsWith("\\") || (await stat(dest).catch(() => void 0))?.isDirectory() === true;
1581
- if (isDir) {
1582
- await mkdir(dest, { recursive: true });
1583
- return resolve2(dest, name);
1584
- }
1585
- await mkdir(dirname(resolve2(dest)), { recursive: true });
1586
- return resolve2(dest);
1607
+ data(key, value) {
1608
+ if (key === "attachments") return this;
1609
+ this._setData(key, value);
1610
+ return this;
1587
1611
  }
1588
- /** storage 's3': URL pré-assinada → GET em streaming → confere o tamanho → só então DELETE. */
1589
- async downloadS3(ref, target) {
1590
- const { api, runId, signal } = this.deps;
1591
- let expected;
1592
- let url;
1593
- try {
1594
- const info = await api.requestFileDownload(runId, ref.id, signal);
1595
- expected = info.size >= 0 ? info.size : ref.size;
1596
- url = info.url;
1597
- } catch (error) {
1598
- throw this.translate(error, ref);
1599
- }
1600
- const { open, rename, rm } = await import("fs/promises");
1601
- const part = `${target}.part`;
1602
- const idleTimeoutMs = this.deps.idleTimeoutMs ?? FILE_DOWNLOAD_IDLE_TIMEOUT_MS;
1603
- const controller = new AbortController();
1604
- let timedOut = false;
1605
- let timer;
1606
- const arm = () => {
1607
- clearTimeout(timer);
1608
- timer = setTimeout(() => {
1609
- timedOut = true;
1610
- controller.abort();
1611
- }, idleTimeoutMs);
1612
- };
1613
- const onCancel = () => controller.abort();
1614
- signal.addEventListener("abort", onCancel, { once: true });
1615
- try {
1616
- arm();
1617
- const response = await api.fetchFile(url, controller.signal);
1618
- const handle = await open(part, "w");
1619
- let written = 0;
1620
- try {
1621
- const reader = response.body?.getReader();
1622
- const stopReading = () => void reader?.cancel().catch(() => void 0);
1623
- controller.signal.addEventListener("abort", stopReading, { once: true });
1624
- for (; ; ) {
1625
- const chunk = reader ? await reader.read() : { done: true, value: void 0 };
1626
- if (chunk.done) break;
1627
- arm();
1628
- written += chunk.value.byteLength;
1629
- if (written > expected) {
1630
- stopReading();
1631
- break;
1632
- }
1633
- await handle.write(chunk.value);
1634
- }
1635
- } finally {
1636
- await handle.close();
1637
- }
1638
- if (written !== expected) {
1639
- throw new Error(`tamanho divergente no download de "${ref.name}": recebidos ${written} bytes, esperados ${expected}`);
1640
- }
1641
- await rename(part, target);
1642
- } catch (error) {
1643
- await rm(part, { force: true });
1644
- if (signal.aborted) throw runCanceledError();
1645
- if (timedOut) {
1646
- throw new Error(`download de "${ref.name}" parado h\xE1 ${idleTimeoutMs / 1e3}s \u2014 tente de novo`);
1647
- }
1648
- throw this.translate(error, ref);
1649
- } finally {
1650
- clearTimeout(timer);
1651
- signal.removeEventListener("abort", onCancel);
1652
- }
1653
- try {
1654
- await api.confirmFileDownload(runId, ref.id);
1655
- } catch (error) {
1656
- this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
1657
- }
1612
+ // ── Internos (chamados pelo SDK) ──────────────────────────────────────────
1613
+ /** `data` da etapa como vai no wire (undefined quando vazio). */
1614
+ get payload() {
1615
+ return this._data;
1616
+ }
1617
+ _setStatus(status, message) {
1618
+ this.status = status;
1619
+ if (message) this._setData("message", stepMessage(safeString(message)));
1620
+ return this;
1658
1621
  }
1659
1622
  /**
1660
- * storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de
1661
- * origem. No modo local, um FileRef com `path` copia direto desse caminho, sem apagar a origem.
1623
+ * Marca `error` guardando `{ type, message }` em `data.error` e fecha a etapa. Aceita qualquer
1624
+ * valor lançado; `message` sobrescreve a mensagem do erro. Numa etapa já fechada não faz nada.
1662
1625
  */
1663
- async copyLocal(ref, target) {
1664
- const direct = localRunPath(ref);
1665
- if (direct !== void 0) {
1666
- await this.copyFromPath(ref, direct, target);
1667
- return;
1668
- }
1669
- const base = process.env[LOCAL_FILES_DIR_ENV];
1670
- if (!base) throw unavailableLocal(ref);
1671
- const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
1672
- const { join, resolve: resolve2, sep } = await import("path");
1673
- const root = resolve2(base);
1674
- const folder = resolve2(root, ref.id);
1675
- if (!isInsideDir(root, folder, sep) || !UUID_RE.test(ref.id)) {
1676
- throw new Error(`id de arquivo local inv\xE1lido: ${JSON.stringify(ref.id)}`);
1677
- }
1678
- let names;
1679
- try {
1680
- names = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
1681
- } catch (error) {
1682
- if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") throw unavailableLocal(ref);
1683
- throw error;
1684
- }
1685
- if (names.length === 0) throw unavailableLocal(ref);
1686
- if (names.length > 1) throw new Error(`pasta do arquivo "${ref.name}" tem ${names.length} arquivos \u2014 esperado 1`);
1687
- this.checkCanceled();
1688
- const part = `${target}.part`;
1689
- try {
1690
- await copyFile(join(folder, names[0]), part);
1691
- const { size } = await stat(part);
1692
- if (size !== ref.size) {
1693
- throw new Error(`tamanho divergente na c\xF3pia de "${ref.name}": copiados ${size} bytes, esperados ${ref.size}`);
1694
- }
1695
- await rename(part, target);
1696
- } catch (error) {
1697
- await rm(part, { force: true });
1698
- throw error;
1699
- }
1700
- await rm(folder, { recursive: true, force: true });
1626
+ _failed(error, message) {
1627
+ if (this.endTimestamp) return this;
1628
+ this.status = "error";
1629
+ const type = error instanceof Error ? error.name : typeof error === "object" && error !== null ? "Error" : void 0;
1630
+ const raw = message ?? errorText(error);
1631
+ this._setData("error", { ...type ? { type } : {}, message: stepMessage(safeString(raw)) });
1632
+ this._close();
1633
+ return this;
1634
+ }
1635
+ _setData(key, value) {
1636
+ this._data = { ...this._data, [key]: value };
1637
+ }
1638
+ _deleteData(key) {
1639
+ if (!this._data || !(key in this._data)) return;
1640
+ const rest = { ...this._data };
1641
+ delete rest[key];
1642
+ this._data = Object.keys(rest).length > 0 ? rest : void 0;
1701
1643
  }
1702
1644
  /**
1703
- * Modo local: cópia do arquivo apontado por `path` (teste roda várias vezes — a fonte fica).
1704
- * Sem conferência de tamanho: o `size` do FileRef é informativo aqui.
1645
+ * Dispara o upload pela run (sink do RunReporter) e referencia o anexo em
1646
+ * `data.attachments` (chave reservada; teto igual ao das linhas de log). Fora de uma
1647
+ * run não há onde pendurar a imagem — descarta com aviso único.
1705
1648
  */
1706
- async copyFromPath(ref, path, target) {
1707
- const { copyFile, rename, rm, stat } = await import("fs/promises");
1708
- const { resolve: resolve2 } = await import("path");
1709
- const source = resolve2(path);
1710
- const info = await stat(source).catch(() => void 0);
1711
- if (!info?.isFile()) throw new Error(`arquivo "${ref.name}" n\xE3o encontrado em ${path}`);
1712
- this.checkCanceled();
1713
- const part = `${target}.part`;
1714
- try {
1715
- await copyFile(source, part);
1716
- await rename(part, target);
1717
- } catch (error) {
1718
- await rm(part, { force: true });
1719
- throw error;
1720
- }
1649
+ attachImage(image3) {
1650
+ const current = Array.isArray(this._data?.attachments) ? this._data.attachments : [];
1651
+ if (current.length >= MAX_ATTACHMENTS_PER_LOG_ENTRY) return;
1652
+ const id = this.transaction.attachImage(image3);
1653
+ if (!id) return;
1654
+ this._setData("attachments", [...current, id]);
1721
1655
  }
1722
- translate(error, ref) {
1723
- if (this.deps.signal.aborted) return runCanceledError();
1724
- if (error instanceof TasksApiError && error.status === 410) {
1725
- return new TasksApiError(`arquivo "${ref.name}" j\xE1 baixado ou expirado`, 410);
1656
+ /** Motivo registrado (warning/erro) — usado no log de FINISH. */
1657
+ reason() {
1658
+ const error = this._data?.error;
1659
+ const text = typeof error?.message === "string" ? error.message : this._data?.message;
1660
+ return typeof text === "string" && text ? text : void 0;
1661
+ }
1662
+ /** Fecha a etapa mantendo o status que estiver. Idempotente. */
1663
+ _close() {
1664
+ if (this.endTimestamp) return;
1665
+ this.endTimestamp = nowISO();
1666
+ this.transaction.emit(
1667
+ "step",
1668
+ "finish",
1669
+ stepLabel(this.op, this.description),
1670
+ this.startTimestamp,
1671
+ this.endTimestamp,
1672
+ this.status,
1673
+ this.reason()
1674
+ );
1675
+ }
1676
+ toJSON() {
1677
+ return {
1678
+ step_id: this.stepId,
1679
+ ...this.parentStepId ? { parent_step_id: this.parentStepId } : {},
1680
+ ...this.op ? { op: clip(this.op, MAX_STEP_OP_CHARS) } : {},
1681
+ ...this.description ? { description: clip(this.description, MAX_STEP_DESCRIPTION_CHARS) } : {},
1682
+ status: this.status,
1683
+ start_timestamp: this.startTimestamp,
1684
+ timestamp: this.endTimestamp ?? nowISO(),
1685
+ ...this._data ? { data: this._data } : {}
1686
+ };
1687
+ }
1688
+ };
1689
+ var Transaction = class {
1690
+ constructor(client, name, op, runId, options = {}) {
1691
+ this.client = client;
1692
+ this.name = name;
1693
+ this.op = op;
1694
+ this.runId = runId;
1695
+ this.echo = options.echo ?? false;
1696
+ this.emit("transaction", "start", op ? `${name} (${op})` : name);
1697
+ }
1698
+ client;
1699
+ name;
1700
+ op;
1701
+ runId;
1702
+ eventId = uuid4();
1703
+ stepId = stepId();
1704
+ startTimestamp = nowISO();
1705
+ status = "ok";
1706
+ steps = [];
1707
+ finished = false;
1708
+ lifecycleSink;
1709
+ attachmentSink;
1710
+ warnedNoAttachmentSink = false;
1711
+ // instalado pelo RunReporter: abrir uma etapa é um ponto de parada cooperativo (lança RunCanceled)
1712
+ stepGuard;
1713
+ // modo local (sem client): o FINISH de cada etapa sai no terminal, como `[hobots:item]`
1714
+ echo;
1715
+ // eventos emitidos antes do sink existir (o START da transaction sai no construtor,
1716
+ // antes do RunReporter conectar) — replay no setLifecycleSink
1717
+ lifecycleBacklog = [];
1718
+ // ── Lifecycle / eco ───────────────────────────────────────────────────────
1719
+ /**
1720
+ * Registra o início/fim de uma etapa ou da transaction: espelha a linha no sink de
1721
+ * lifecycle, se conectado (é assim que os eventos de performance aparecem nos logs da
1722
+ * run no app), e ecoa no terminal com `debug: true` — ou, no modo local, só o FINISH das
1723
+ * etapas (`[hobots:step] FINISH …`), para o Studio e o dev verem.
1724
+ */
1725
+ emit(kind, phase, label, startTs, endTs, status, reason) {
1726
+ const durationMs = startTs && endTs ? new Date(endTs).getTime() - new Date(startTs).getTime() : void 0;
1727
+ const suffix = (durationMs != null ? ` (${durationMs}ms)` : "") + (reason ? `: ${reason}` : "");
1728
+ const localFinish = this.echo && kind === "step" && phase === "finish";
1729
+ if (this.client?.debug || localFinish) {
1730
+ const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
1731
+ console.log(`${tag} ${label}${suffix}`);
1726
1732
  }
1727
- if (error instanceof TasksApiError && error.status === 404) {
1728
- return new TasksApiError(`arquivo "${ref.name}" n\xE3o encontrado nesta execu\xE7\xE3o`, 404);
1733
+ const level = phase === "finish" ? levelForStatus(status ?? "ok") : "info";
1734
+ const message = `[${kind}] ${phase.toUpperCase()} ${label}${suffix}`;
1735
+ if (this.lifecycleSink) this.lifecycleSink(level, message);
1736
+ else if (this.lifecycleBacklog.length < MAX_BACKLOG) this.lifecycleBacklog.push([level, message]);
1737
+ }
1738
+ /** Conecta o espelho de lifecycle (RunReporter) e faz replay do backlog em ordem. */
1739
+ setLifecycleSink(sink) {
1740
+ this.lifecycleSink = sink;
1741
+ const backlog = this.lifecycleBacklog;
1742
+ this.lifecycleBacklog = [];
1743
+ for (const [level, message] of backlog) sink(level, message);
1744
+ }
1745
+ /** Conecta o pipeline de anexos da run — é o que dá imagem às etapas. */
1746
+ setAttachmentSink(sink) {
1747
+ this.attachmentSink = sink;
1748
+ }
1749
+ /** Imagem de uma etapa → id do anexo pela run; sem run, descarta com aviso único. */
1750
+ attachImage(image3) {
1751
+ if (this.attachmentSink) return this.attachmentSink(image3, nowISO());
1752
+ if (!this.warnedNoAttachmentSink) {
1753
+ this.warnedNoAttachmentSink = true;
1754
+ console.warn("[hobots] imagem de etapa descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
1729
1755
  }
1730
- return error;
1756
+ return void 0;
1731
1757
  }
1732
- };
1733
-
1734
- // src/retry.ts
1735
- var DEFAULT_RETRIES = 3;
1736
- var DEFAULT_DELAY_MS = 1e3;
1737
- function resolveDelay(delay, attempt, fallbackMs) {
1738
- const value = typeof delay === "function" ? delay(attempt) : delay ?? fallbackMs;
1739
- return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1740
- }
1741
- async function retryWithBackoff(fn, options = {}) {
1742
- const requested = options.retries ?? DEFAULT_RETRIES;
1743
- const retries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_RETRIES;
1744
- const wait = options.wait ?? sleep;
1745
- for (let attempt = 1; ; attempt++) {
1746
- if (options.signal?.aborted) throw runCanceledError();
1758
+ // ── Etapas ────────────────────────────────────────────────────────────────
1759
+ /**
1760
+ * Conecta a checagem de cancelamento chamada antes de cada etapa nova (inclusive antes de
1761
+ * cada nova tentativa) — é assim que `hobots.step()` para uma run cancelada.
1762
+ */
1763
+ setStepGuard(guard) {
1764
+ this.stepGuard = guard;
1765
+ }
1766
+ /** A etapa aberta por `step()` (ou `items.each`) no contexto async atual, se houver. */
1767
+ currentStep() {
1768
+ return currentStep(this);
1769
+ }
1770
+ /** Faz de `step` a etapa corrente durante `fn` (filhas aninham nela). Sem etapa, só roda `fn`. */
1771
+ scope(step, fn) {
1772
+ if (!step) return fn();
1773
+ return runWithStepScope({ transaction: this, step }, fn);
1774
+ }
1775
+ /** Abre uma etapa sob a corrente (ou direta da transaction). Interno: o bot usa `step()`. */
1776
+ start(op, description) {
1777
+ this.stepGuard?.();
1778
+ const parent = this.currentStep();
1779
+ const step = new Step(this, parent ? parent.stepId : this.stepId, op, description);
1780
+ this.steps.push(step);
1781
+ this.emit("step", "start", stepLabel(op, description));
1782
+ return step;
1783
+ }
1784
+ /**
1785
+ * Registra a etapa `name` executando `fn(etapa)` até dar certo ou esgotar `retries`
1786
+ * tentativas extras (default 1), esperando `retryDelayMs` entre elas (default 5 000).
1787
+ * Resolve com o retorno de `fn`.
1788
+ *
1789
+ * Uma etapa só no waterfall, cobrindo todas as tentativas: `data.attempts` quando houve
1790
+ * mais de uma; `warning` com o motivo quando a última deu certo; `error` (e a exceção
1791
+ * sobe) quando esgotou; `cancelled` em cancelamento/timeout, que nunca repetem.
1792
+ */
1793
+ async step(name, fn, options = {}) {
1794
+ const requested = options.retries ?? DEFAULT_STEP_RETRIES;
1795
+ const totalRetries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_STEP_RETRIES;
1796
+ const step = this.start(void 0, safeString(name));
1797
+ let attempts = 0;
1798
+ let lastFailure;
1747
1799
  try {
1748
- return await fn(attempt);
1800
+ const result = await retryWithBackoff(
1801
+ (attempt) => {
1802
+ attempts = attempt;
1803
+ return this.scope(step, async () => fn(step));
1804
+ },
1805
+ {
1806
+ retries: totalRetries,
1807
+ delayMs: (attempt) => resolveDelay(options.retryDelayMs, attempt, DEFAULT_STEP_RETRY_DELAY_MS),
1808
+ signal: options.signal,
1809
+ wait: options.wait,
1810
+ onRetry: (error, attempt, delayMs) => {
1811
+ lastFailure = { error };
1812
+ options.warn?.(
1813
+ `etapa "${step.description}": tentativa ${attempt}/${totalRetries + 1} falhou: ${truncate(errorReason(error), 500)} \u2014 nova tentativa em ${delayMs}ms`
1814
+ );
1815
+ }
1816
+ }
1817
+ );
1818
+ this.noteAttempts(step, attempts);
1819
+ if (lastFailure && step.status === "ok") {
1820
+ step._setStatus("warning", `tentativa ${attempts - 1}/${totalRetries + 1} falhou: ${errorReason(lastFailure.error)}`);
1821
+ }
1822
+ step._close();
1823
+ return result;
1749
1824
  } catch (error) {
1750
- const exhausted = attempt > retries;
1751
- const retriable = !isTerminalError(error) && !options.signal?.aborted && (options.shouldRetry?.(error) ?? true);
1752
- if (exhausted || !retriable) throw error;
1753
- const delay = resolveDelay(options.delayMs, attempt, DEFAULT_DELAY_MS);
1754
- options.onRetry?.(error, attempt, delay);
1755
- await wait(delay);
1825
+ this.noteAttempts(step, attempts);
1826
+ if (isTerminalError(error)) step._setStatus("cancelled")._close();
1827
+ else step._failed(error);
1828
+ throw error;
1756
1829
  }
1757
1830
  }
1758
- }
1759
-
1760
- // src/tasks/attachments.ts
1761
- var MAX_ATTACHMENT_BYTES = WIRE_LIMITS.MAX_ATTACHMENT_BYTES;
1762
- var MAX_ATTACHMENTS_PER_RUN = WIRE_LIMITS.MAX_ATTACHMENTS_PER_RUN;
1763
- var MAX_ATTACHMENTS_PER_LOG_ENTRY = WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY;
1764
- var MAX_CONCURRENT_UPLOADS = 3;
1765
- var MAX_CAPTION_CHARS = WIRE_LIMITS.MAX_CAPTION_CHARS;
1766
- var ATTACHMENT_DRAIN_MS = 15e3;
1767
- var IMAGE_BRAND = /* @__PURE__ */ Symbol.for("hobots.image");
1768
- var ImageAttachment = class {
1769
- constructor(input, caption, contentType) {
1770
- this.input = input;
1771
- this.caption = caption;
1772
- this.contentType = contentType;
1831
+ noteAttempts(step, attempts) {
1832
+ if (attempts > 1) step._setData("attempts", attempts);
1773
1833
  }
1774
- input;
1775
- caption;
1776
- contentType;
1777
- [IMAGE_BRAND] = true;
1778
- };
1779
- function image(input, options = {}) {
1780
- return new ImageAttachment(input, options.caption, options.contentType);
1781
- }
1782
- function isImageAttachment(value) {
1783
- return typeof value === "object" && value !== null && value[IMAGE_BRAND] === true;
1784
- }
1785
- function toImageAttachment(value) {
1786
- return isImageAttachment(value) ? value : new ImageAttachment(value);
1787
- }
1788
- async function readImageFile(path) {
1789
- if (!isNode()) {
1790
- throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
1791
- }
1792
- const { readFile } = await import("fs/promises");
1793
- return new Uint8Array(await readFile(path));
1794
- }
1795
- function startsWith(bytes, signature, offset = 0) {
1796
- if (bytes.length < offset + signature.length) return false;
1797
- return signature.every((byte, index) => bytes[offset + index] === byte);
1798
- }
1799
- var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
1800
- var MAX_PNG_DIMENSION = WIRE_LIMITS.MAX_IMAGE_DIMENSION_PX;
1801
- var JPEG_MAGIC = [255, 216, 255];
1802
- var RIFF_MAGIC = [82, 73, 70, 70];
1803
- var WEBP_MAGIC = [87, 69, 66, 80];
1804
- function sniffContentType(bytes) {
1805
- if (startsWith(bytes, PNG_MAGIC)) return "image/png";
1806
- if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
1807
- if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
1808
- return void 0;
1809
- }
1810
- function readPngDimensions(bytes) {
1811
- if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
1812
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1813
- const width = view.getUint32(16);
1814
- const height = view.getUint32(20);
1815
- if (width === 0 || height === 0) return void 0;
1816
- if (width > MAX_PNG_DIMENSION || height > MAX_PNG_DIMENSION) return void 0;
1817
- return { width, height };
1818
- }
1819
- async function toBytes(input) {
1820
- if (typeof input === "string") return readImageFile(input);
1821
- if (input instanceof Uint8Array) return input;
1822
- return new Uint8Array(input);
1823
- }
1824
-
1825
- // src/tasks/context.ts
1826
- var LOG_LEVEL_COLOR = {
1827
- debug: "gray",
1828
- info: "cyan",
1829
- warning: "yellow",
1830
- error: "red",
1831
- success: "green"
1832
- };
1833
- function joinLogArgs(message, rest) {
1834
- if (rest.length === 0) return safeString(message);
1835
- return [message, ...rest].map(safeString).join(" ");
1836
- }
1837
- function splitLogArgs(message, rest) {
1838
- const args = [message, ...rest];
1839
- const last = args[args.length - 1];
1840
- if (!isImageAttachment(last)) return { text: joinLogArgs(message, rest) };
1841
- const text = args.slice(0, -1);
1842
- if (text.length === 0) return { text: last.caption !== void 0 ? safeString(last.caption) : "captura de tela", image: last };
1843
- return { text: joinLogArgs(text[0], text.slice(1)), image: last };
1844
- }
1845
- function printRunLog(level, message) {
1846
- const line = colorize(`[hobots] ${level.toUpperCase()}`, LOG_LEVEL_COLOR[level]);
1847
- const method2 = level === "warning" ? "warn" : level === "success" ? "info" : level;
1848
- console[method2](line, message);
1849
- }
1850
- var PROGRESS_LINE_PREFIX = "[hobots:progress]";
1851
- var PROGRESS_PRINT_THROTTLE_MS = 250;
1852
- function printRunProgress(current, total) {
1853
- console.log(`${PROGRESS_LINE_PREFIX} ${current}/${total}`);
1854
- }
1855
- function cancelableSleep(ms, signal) {
1856
- return new Promise((resolve2, reject) => {
1857
- if (signal.aborted) {
1858
- reject(runCanceledError());
1859
- return;
1860
- }
1861
- const onAbort = () => {
1862
- clearTimeout(timer);
1863
- reject(runCanceledError());
1864
- };
1865
- const timer = setTimeout(() => {
1866
- signal.removeEventListener("abort", onAbort);
1867
- resolve2();
1868
- }, ms);
1869
- signal.addEventListener("abort", onAbort, { once: true });
1870
- });
1871
- }
1872
- var DEFAULT_LOG_FLUSH_INTERVAL_MS = 2e3;
1873
- var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
1874
- var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
1875
- var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
1876
- var MAX_LOG_MESSAGE_CHARS = WIRE_LIMITS.MAX_LOG_MESSAGE_CHARS;
1877
- var MAX_LOGS_PER_PATCH = WIRE_LIMITS.MAX_LOGS_PER_PATCH;
1878
- var FLUSH_AT_BUFFERED = 20;
1879
- var MAX_ITEMS_PER_REQUEST = WIRE_LIMITS.MAX_ITEMS_PER_REQUEST;
1880
- var MAX_ITEM_MESSAGE_CHARS = WIRE_LIMITS.MAX_ITEM_MESSAGE_CHARS;
1881
- var MAX_ITEM_PAYLOAD_BYTES = WIRE_LIMITS.MAX_ITEM_PAYLOAD_BYTES;
1882
- var ITEMS_FLUSH_AT_BUFFERED = 100;
1883
- var ITEMS_DRAIN_MS = 15e3;
1884
- var PROGRESS_FLUSH_MS = 1e3;
1885
- function scrubPayloadValue(value, depth) {
1886
- if (typeof value === "string") return scrubSecrets(value);
1887
- if (depth >= 4 || value === null || typeof value !== "object") return value;
1888
- if (Array.isArray(value)) return value.map((entry) => scrubPayloadValue(entry, depth + 1));
1889
- const out = {};
1890
- for (const [key, entry] of Object.entries(value)) {
1891
- out[key] = scrubPayloadValue(entry, depth + 1);
1892
- }
1893
- return out;
1894
- }
1895
- var RunReporter = class {
1896
- constructor(api, runId, logger, tx, flushIntervalMs, cancelCheckIntervalMs, params = {}) {
1897
- this.api = api;
1898
- this.runId = runId;
1899
- this.logger = logger;
1900
- this.tx = tx;
1901
- this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
1902
- this.cancelCheckIntervalMs = Math.min(
1903
- MAX_CANCEL_CHECK_INTERVAL_MS,
1904
- Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
1905
- );
1906
- const logAt = (level) => (message, ...rest) => {
1907
- const { text, image: image3 } = splitLogArgs(message, rest);
1908
- this.log(level, text, image3);
1909
- };
1910
- const log = {
1911
- debug: logAt("debug"),
1912
- info: logAt("info"),
1913
- warn: logAt("warning"),
1914
- error: logAt("error"),
1915
- success: logAt("success")
1916
- };
1917
- tx.setLifecycleSink((level, message) => this.push(level, message));
1918
- tx.setAttachmentSink((image3, ts) => this.queueAttachment(image3, ts));
1919
- const checkCanceled = () => this.checkCanceled();
1920
- tx.setStepGuard(checkCanceled);
1921
- const eachSink = {
1922
- report: (status, message, payload, id, image3) => this.addItem(status, message, payload, id, image3),
1923
- startStep: (op, description) => tx.start(op, description),
1924
- stepScope: (step, fn) => tx.scope(step, fn),
1925
- declareTotal: (total) => {
1926
- if (this.progressTotal == null) this.setProgress(this.progressCurrent, total);
1927
- },
1928
- advance: () => this.setProgress(this.progressCurrent + 1, this.progressTotal),
1929
- checkCanceled,
1930
- isAborted: () => this.abortController.signal.aborted,
1931
- logError: (message) => this.log("error", message),
1932
- flush: () => this.flush()
1933
- };
1934
- const items = {
1935
- add: (item) => {
1936
- checkCanceled();
1937
- this.addItem(item.status, item.message, item.payload, item.id, item.image);
1938
- },
1939
- succeeded: (payload, options) => {
1940
- checkCanceled();
1941
- this.addItem("succeeded", void 0, payload, options?.id, options?.image);
1942
- },
1943
- failed: (message, payload, options) => {
1944
- checkCanceled();
1945
- this.addItem("failed", message, payload, options?.id, options?.image);
1946
- },
1947
- occurrence: (message, payload, options) => {
1948
- checkCanceled();
1949
- this.addItem("occurrence", message, payload, options?.id, options?.image);
1950
- },
1951
- each: (list, handler, options = {}) => eachItem(list, handler, options, eachSink)
1952
- };
1953
- const progress = Object.defineProperties(
1954
- {
1955
- total: (total) => {
1956
- checkCanceled();
1957
- this.setProgress(this.progressCurrent, total);
1958
- },
1959
- advance: (count = 1) => {
1960
- checkCanceled();
1961
- this.setProgress(this.progressCurrent + count, this.progressTotal);
1962
- },
1963
- set: (current, total) => {
1964
- checkCanceled();
1965
- this.setProgress(current, total ?? this.progressTotal);
1966
- }
1967
- },
1968
- {
1969
- current: { enumerable: true, get: () => this.progressCurrent },
1970
- snapshot: {
1971
- enumerable: true,
1972
- get: () => this.progressTotal == null ? null : { current: this.progressCurrent, total: this.progressTotal }
1973
- }
1974
- }
1975
- // defineProperties não tipa os getters adicionados — a interface é a fonte de verdade
1976
- );
1977
- const signal = this.abortController.signal;
1978
- const sleep2 = (ms) => cancelableSleep(ms, signal);
1979
- this.fileStore = new RunFileStore({ api, runId, params, signal, warn: (message) => this.log("warning", message) });
1980
- this.context = {
1981
- runId,
1982
- params,
1983
- log,
1984
- items,
1985
- progress,
1986
- files: this.fileStore.files,
1987
- step: async (name, fn, options) => {
1988
- const root = tx.currentStep() === void 0;
1989
- try {
1990
- return await tx.step(name, fn, { ...options, signal, wait: sleep2, warn: (message) => this.log("warning", message) });
1991
- } finally {
1992
- if (options?.flush !== false && root && !signal.aborted) await this.flush();
1993
- }
1994
- },
1995
- flush: (timeoutMs) => this.flush(timeoutMs),
1996
- signal,
1997
- throwIfCanceled: checkCanceled,
1998
- sleep: sleep2,
1999
- retry: (fn, options) => retryWithBackoff(fn, { ...options, signal, wait: sleep2 })
2000
- };
2001
- }
2002
- api;
2003
- runId;
2004
- logger;
2005
- tx;
2006
- context;
2007
- buffer = [];
2008
- dead = false;
2009
- chain = Promise.resolve();
2010
- pendingUpdates = 0;
2011
- timer;
2012
- watchTimer;
2013
- abortController = new AbortController();
2014
- flushIntervalMs;
2015
- cancelCheckIntervalMs;
2016
- // Itens de execução vivem FORA de `this.chain` (status/liveness não pode ficar
2017
- // atrás de milhares de itens), mas têm cadeia própria serializada — ordem de
2018
- // envio preservada e sem lotes sobrepostos.
2019
- itemsBuffer = [];
2020
- itemsChain = Promise.resolve();
2021
- itemsTimer;
2022
- itemSeq = 0;
2023
- /** Servidor sem o recurso (404) — para de tentar (e de avisar) nesta run. */
2024
- itemsUnsupported = false;
2025
- /**
2026
- * Cota de itens por run do servidor (429, legado — hoje a api não tem teto de itens) —
2027
- * degrada com um aviso só.
2028
- */
2029
- itemsQuotaExceeded = false;
2030
- /**
2031
- * Armazenamento do plano cheio (402 `storage_limit`): o lote é descartado e o aviso sai
2032
- * uma vez por episódio. Os envios continuam — num agente `start()` o espaço pode ser
2033
- * liberado (ou o plano trocado) sem reiniciar; um lote aceito rearma o aviso.
2034
- */
2035
- itemsStorageFull = false;
2036
- /**
2037
- * Só o kill() do exit path fecha os itens — `dead` (run cancelada/finalizada no
2038
- * servidor) NÃO fecha: o servidor aceita itens de run terminal (trabalho já feito).
2039
- */
2040
- itemsClosed = false;
2041
- // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
2042
- // status/logs e não pode ficar bloqueada esperando um binário subir.
2043
- pendingAttachments = /* @__PURE__ */ new Set();
2044
- attachmentCount = 0;
2045
- uploadSlots = MAX_CONCURRENT_UPLOADS;
2046
- uploadQueue = [];
2047
- /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
2048
- attachmentsUnsupported = false;
2049
- // Progresso (ctx.progress): snapshot absoluto, enviado com throttle via piggyback
2050
- // em `enqueue()` — nunca gera tráfego próprio enquanto logs/heartbeat já fluem.
2051
- progressCurrent = 0;
2052
- progressTotal = null;
2053
- /** Snapshot ainda não entregue ao servidor. */
2054
- progressDirty = false;
2055
- progressTimer;
2056
- progressWarned = false;
2057
- /** Relógio do último eco `[hobots:progress]` no terminal (throttle do eco, não do wire). */
2058
- progressLastPrint = 0;
2059
- /** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
2060
- fileStore;
2061
- /** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
2062
- cleanupFiles() {
2063
- return this.fileStore.cleanup();
2064
- }
2065
- get isDead() {
2066
- return this.dead;
2067
- }
2068
- /** Lança `RunCanceled` se o signal desta run já disparou. */
2069
- checkCanceled() {
2070
- if (this.abortController.signal.aborted) throw runCanceledError();
2071
- }
2072
- /** Aborta o ctx.signal sem matar o reporter (timeout/shutdown ainda reportam o terminal). */
2073
- abort() {
2074
- this.stopCancelWatch();
2075
- if (!this.abortController.signal.aborted) this.abortController.abort();
1834
+ // ── Metadados ─────────────────────────────────────────────────────────────
1835
+ setStatus(status) {
1836
+ this.status = status;
1837
+ return this;
2076
1838
  }
1839
+ // ── Encerramento ──────────────────────────────────────────────────────────
2077
1840
  /**
2078
- * Mata o reporter de vez: aborta o signal e suprime todo reporte futuro.
2079
- * Usado no exit path — depois que o agente reportou AgentTerminated por fora
2080
- * da fila, um handler zumbi não pode reenviar status por cima.
1841
+ * Envia já as etapas raiz finalizadas (e todas as descendentes, fechando as que
1842
+ * ficaram abertas) como transactions parciais da mesma run (`op` hobots.run.flush,
1843
+ * `event_id` novo, até 200 etapas cada) e as tira da memória. Etapas raiz ainda abertas
1844
+ * ficam para o próximo flush ou para o `finish()`. É assim que um agente sempre
1845
+ * ligado (`start()`), cuja run não termina, entrega as etapas enquanto processa.
1846
+ * Devolve quantas etapas foram enviadas (0 sem client, sem nada terminado ou depois do `finish()`).
2081
1847
  */
2082
- kill() {
2083
- this.abort();
2084
- this.dead = true;
2085
- this.clearFlushTimer();
2086
- this.clearProgressTimer();
2087
- this.buffer = [];
2088
- this.itemsClosed = true;
2089
- this.clearItemsTimer();
2090
- if (this.itemsBuffer.length > 0) {
2091
- this.logger.warn(`${this.itemsBuffer.length} item(ns) de execu\xE7\xE3o descartados (processo encerrando)`);
2092
- this.itemsBuffer = [];
1848
+ flush() {
1849
+ if (!this.client || this.finished) return 0;
1850
+ const ready = /* @__PURE__ */ new Set();
1851
+ for (const step of this.steps) {
1852
+ if (step.parentStepId === this.stepId && step.endTimestamp) ready.add(step.stepId);
2093
1853
  }
2094
- const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
2095
- for (const resume of waiting) resume();
2096
- }
2097
- /**
2098
- * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
2099
- * que descarta o buffer). Capado em um chunk — o exit path não tem retry nem fila,
2100
- * então preserva os logs mais recentes, os mais valiosos na morte do processo.
2101
- */
2102
- drainLogs() {
2103
- this.clearFlushTimer();
2104
- return this.takeLogs().slice(-MAX_LOGS_PER_PATCH);
2105
- }
2106
- /**
2107
- * Linha de log da run com eco no terminal. `image` (opcional) vira o anexo da linha:
2108
- * o id entra na entrada ANTES de qualquer I/O — é isso que mantém a ordem dos logs
2109
- * intacta e o handler livre da latência do upload. Devolve o id do anexo (quando houve
2110
- * imagem e ela não foi descartada) — o `captureException` o leva ao evento da issue.
2111
- */
2112
- log(level, message, image3) {
2113
- if (this.dead) return void 0;
2114
- const scrubbed = scrubSecrets(safeString(message));
2115
- const ts = nowISO();
2116
- const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2117
- printRunLog(level, attachmentId ? `${scrubbed} [imagem]` : scrubbed);
2118
- this.push(level, scrubbed, true, attachmentId ? [attachmentId] : void 0, ts);
2119
- return attachmentId;
2120
- }
2121
- /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
2122
- push(level, message, alreadyScrubbed = false, attachments, ts = nowISO()) {
2123
- if (this.dead) return ts;
2124
- const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
2125
- this.buffer.push({
2126
- ts,
2127
- level,
2128
- message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
2129
- ...attachments && attachments.length > 0 ? { attachments: attachments.slice(0, WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY) } : {}
2130
- });
2131
- if (this.buffer.length >= FLUSH_AT_BUFFERED) {
2132
- this.flushLogs();
2133
- } else {
2134
- this.scheduleFlush();
1854
+ if (ready.size === 0) return 0;
1855
+ const batch = [];
1856
+ const remaining = [];
1857
+ for (const step of this.steps) {
1858
+ if (ready.has(step.stepId) || ready.has(step.parentStepId)) {
1859
+ ready.add(step.stepId);
1860
+ batch.push(step);
1861
+ } else {
1862
+ remaining.push(step);
1863
+ }
2135
1864
  }
2136
- return ts;
1865
+ for (const step of batch) step._close();
1866
+ this.steps.splice(0, this.steps.length, ...remaining);
1867
+ for (let i = 0; i < batch.length; i += MAX_STEPS) {
1868
+ const chunk = batch.slice(i, i + MAX_STEPS);
1869
+ const startTimestamp = chunk.reduce((min, step) => step.startTimestamp < min ? step.startTimestamp : min, chunk[0].startTimestamp);
1870
+ const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
1871
+ this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, startTimestamp, nowISO(), chunk));
1872
+ }
1873
+ return batch.length;
2137
1874
  }
2138
- scheduleFlush() {
2139
- if (this.timer) return;
2140
- this.timer = setTimeout(() => {
2141
- this.timer = void 0;
2142
- this.flushLogs();
2143
- }, this.flushIntervalMs);
2144
- unrefTimer(this.timer);
1875
+ /** Entrega já (com teto) o que está na fila do transport — as transactions parciais inclusive. */
1876
+ async drain(timeoutMs) {
1877
+ await this.client?.flush(timeoutMs);
1878
+ }
1879
+ payload(eventId, op, status, startTimestamp, timestamp, steps) {
1880
+ return {
1881
+ event_id: eventId,
1882
+ name: this.name,
1883
+ op,
1884
+ status,
1885
+ start_timestamp: startTimestamp,
1886
+ timestamp,
1887
+ steps: steps.map((step) => step.toJSON()),
1888
+ // sem tags próprias: o client mescla as do processo
1889
+ tags: void 0,
1890
+ run_id: this.runId
1891
+ };
2145
1892
  }
2146
- clearFlushTimer() {
2147
- if (this.timer) {
2148
- clearTimeout(this.timer);
2149
- this.timer = void 0;
1893
+ finish() {
1894
+ if (this.finished) return;
1895
+ this.finished = true;
1896
+ for (const step of this.steps) step._close();
1897
+ const endTimestamp = nowISO();
1898
+ this.emit(
1899
+ "transaction",
1900
+ "finish",
1901
+ this.op ? `${this.name} (${this.op})` : this.name,
1902
+ this.startTimestamp,
1903
+ endTimestamp,
1904
+ this.status
1905
+ );
1906
+ if (!this.client) return;
1907
+ const steps = this.steps;
1908
+ let first = 0;
1909
+ while (steps.length - first > MAX_STEPS) {
1910
+ const chunk = steps.slice(first, first + MAX_STEPS);
1911
+ const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
1912
+ this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, chunk[0].startTimestamp, endTimestamp, chunk));
1913
+ first += MAX_STEPS;
2150
1914
  }
1915
+ this.client.sendTransaction(this.payload(this.eventId, this.op, this.status, this.startTimestamp, endTimestamp, steps.slice(first)));
2151
1916
  }
2152
- takeLogs() {
2153
- return this.buffer.splice(0, this.buffer.length);
1917
+ };
1918
+
1919
+ // src/tasks/each.ts
1920
+ var DEFAULT_ITEM_ID_FIELD = "id";
1921
+ var DEFAULT_EACH_RETRIES = 1;
1922
+ var ID_SEPARATOR = "|";
1923
+ function thrownMessage(error) {
1924
+ if (error instanceof Error) return error.message || error.name;
1925
+ return safeString(error);
1926
+ }
1927
+ var ItemHandle = class {
1928
+ constructor(index, id, step, basePayload, sink) {
1929
+ this.index = index;
1930
+ this.step = step;
1931
+ this.basePayload = basePayload;
1932
+ this.sink = sink;
1933
+ this.id = id ?? uuid4();
2154
1934
  }
2155
- /** Flush incremental: envia o buffer como status 'running' (em chunks). */
2156
- flushLogs() {
2157
- this.clearFlushTimer();
2158
- if (this.dead || this.buffer.length === 0) return;
2159
- const logs = this.buffer.splice(0, this.buffer.length);
2160
- for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
2161
- this.enqueue({ status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) });
2162
- }
1935
+ index;
1936
+ step;
1937
+ basePayload;
1938
+ sink;
1939
+ /** A identidade resolvida por `options.id` ou, sem ela, uma gerada — a mesma em todas as tentativas. */
1940
+ id;
1941
+ reported = false;
1942
+ status;
1943
+ attempt = 1;
1944
+ succeeded(payload, options) {
1945
+ this.record("succeeded", void 0, payload, options?.image);
2163
1946
  }
2164
- /**
2165
- * `hobots.flush()`: o bot decide quando entregar o que já fez — etapas raiz finalizadas
2166
- * viram transactions parciais da run, e itens, logs e progresso pendentes saem já, sem
2167
- * esperar o throttle. Aguarda a entrega com teto. Nunca lança (pode ir num `finally`).
2168
- */
2169
- async flush(timeoutMs = ITEMS_DRAIN_MS) {
2170
- this.tx.flush();
2171
- this.flushItems();
2172
- if (this.progressDirty) this.flushProgress();
2173
- else this.flushLogs();
2174
- await Promise.race([
2175
- Promise.all([this.itemsChain, this.chain, this.tx.drain(timeoutMs)]).catch(() => void 0),
2176
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2177
- ]);
1947
+ failed(message, payload, options) {
1948
+ this.record("failed", message, payload, options?.image);
1949
+ if (this.step && this.step.status !== "error") this.step._setStatus("error", message);
2178
1950
  }
2179
- /** Primeiro PATCH (claimed → running). Retorna false se a run já morreu (cancelada). */
2180
- async reportRunning() {
2181
- this.enqueue({ status: "running" });
2182
- await this.chain;
2183
- if (!this.dead) this.startCancelWatch();
2184
- return !this.dead;
1951
+ occurrence(message, payload, options) {
1952
+ this.record("occurrence", message, payload, options?.image);
1953
+ if (this.step && this.step.status === "ok") this.step._setStatus("warning", message);
2185
1954
  }
2186
- reportSuccess() {
2187
- this.stopCancelWatch();
2188
- this.clearFlushTimer();
2189
- this.forceFinalProgress();
2190
- this.enqueue({ status: "succeeded", logs: this.takeLogsForTerminal() });
1955
+ /** Nova tentativa: o desfecho da anterior não vale (o id é o mesmo, o último reporte vence). */
1956
+ _nextAttempt(attempt) {
1957
+ this.attempt = attempt;
1958
+ this.reported = false;
1959
+ this.status = void 0;
1960
+ if (this.step && (this.step.status === "error" || this.step.status === "warning")) {
1961
+ this.step._setStatus("ok");
1962
+ this.step._deleteData("message");
1963
+ }
2191
1964
  }
2192
- reportFailure(error) {
2193
- this.stopCancelWatch();
2194
- this.clearFlushTimer();
2195
- this.forceFinalProgress();
2196
- this.enqueue({ status: "failed", error, logs: this.takeLogsForTerminal() });
1965
+ record(status, message, payload, image3) {
1966
+ this.reported = true;
1967
+ this.status = status;
1968
+ const merged = { ...this.basePayload ?? {}, ...payload ?? {} };
1969
+ this.sink.report(status, message, Object.keys(merged).length > 0 ? merged : void 0, this.id, image3);
2197
1970
  }
2198
- /**
2199
- * Esvazia o buffer para o PATCH terminal respeitando o teto de 100 logs por PATCH:
2200
- * o excedente sai antes, em PATCHes `running` na mesma cadeia (a ordem se mantém).
2201
- */
2202
- takeLogsForTerminal() {
2203
- let logs = this.takeLogs();
2204
- while (logs.length > MAX_LOGS_PER_PATCH) {
2205
- this.enqueue({ status: "running", logs: logs.slice(0, MAX_LOGS_PER_PATCH) });
2206
- logs = logs.slice(MAX_LOGS_PER_PATCH);
1971
+ };
1972
+ function field(element, name) {
1973
+ if (typeof element !== "object" || element === null) return { present: false, value: void 0 };
1974
+ const record = element;
1975
+ return { present: name in record, value: record[name] };
1976
+ }
1977
+ function resolveId(spec, element, index, explicit) {
1978
+ if (spec === null) return void 0;
1979
+ let raw;
1980
+ if (typeof spec === "function") {
1981
+ raw = spec(element, index);
1982
+ } else {
1983
+ const fields = spec === void 0 ? [DEFAULT_ITEM_ID_FIELD] : typeof spec === "string" ? [spec] : spec;
1984
+ const parts = [];
1985
+ for (const name of fields) {
1986
+ const { present, value } = field(element, name);
1987
+ if (!present || value === null || value === void 0) {
1988
+ if (explicit) {
1989
+ throw new Error(
1990
+ `items.each: elemento #${index + 1} n\xE3o tem o campo "${name}" informado em id \u2014 passe o nome certo, uma lista de campos ou uma fun\xE7\xE3o (elemento, \xEDndice) => id`
1991
+ );
1992
+ }
1993
+ return void 0;
1994
+ }
1995
+ parts.push(safeString(value));
2207
1996
  }
2208
- return logs;
1997
+ raw = parts.join(ID_SEPARATOR);
2209
1998
  }
2210
- // ── Progresso ────────────────────────────────────────────────────────────────
2211
- /** Valida, clampa e marca o snapshot como pendente; agenda flush só se nada mais vai sair. */
2212
- setProgress(current, total) {
2213
- if (this.dead) return;
2214
- if (total != null && (!Number.isInteger(total) || total < 1)) {
2215
- if (!this.progressWarned) {
2216
- this.progressWarned = true;
2217
- this.logger.warn(`ctx.progress: total inv\xE1lido (${String(total)}) \u2014 precisa ser inteiro >= 1; ignorado`);
2218
- }
2219
- return;
1999
+ if (raw === null || raw === void 0) return void 0;
2000
+ const text = safeString(raw);
2001
+ return text.length > 0 ? text : void 0;
2002
+ }
2003
+ function knownLength(list) {
2004
+ const length = list?.length;
2005
+ return typeof length === "number" && Number.isInteger(length) && length >= 1 ? length : void 0;
2006
+ }
2007
+ function skippedMessage(count) {
2008
+ if (count === 1) return "1 item pulado: j\xE1 conclu\xEDdo neste trabalho numa execu\xE7\xE3o anterior";
2009
+ return `${count} itens pulados: j\xE1 conclu\xEDdos neste trabalho numa execu\xE7\xE3o anterior`;
2010
+ }
2011
+ async function eachItem(list, handler, options, sink) {
2012
+ const result = { total: 0, succeeded: 0, failed: 0, occurrence: 0, skipped: 0 };
2013
+ const requested = options.retries ?? DEFAULT_EACH_RETRIES;
2014
+ const totalRetries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_EACH_RETRIES;
2015
+ const explicitId = options.id !== void 0;
2016
+ const length = options.progress === false ? void 0 : knownLength(list);
2017
+ if (length !== void 0) sink.declareTotal(length);
2018
+ const done = options.skipDone === false ? void 0 : await sink.doneItemIds();
2019
+ let index = 0;
2020
+ for await (const element of list) {
2021
+ sink.checkCanceled();
2022
+ const id = resolveId(options.id, element, index, explicitId);
2023
+ if (done?.size && id !== void 0 && done.has(id)) {
2024
+ result.total += 1;
2025
+ result.skipped += 1;
2026
+ if (options.progress !== false) sink.advance();
2027
+ index += 1;
2028
+ continue;
2220
2029
  }
2221
- const safeCurrent = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : this.progressCurrent;
2222
- const next = total == null ? safeCurrent : Math.min(safeCurrent, total);
2223
- const totalChanged = total !== this.progressTotal;
2224
- if (next === this.progressCurrent && !totalChanged) return;
2225
- this.progressCurrent = next;
2226
- this.progressTotal = total;
2227
- if (total == null) return;
2228
- const now = Date.now();
2229
- if (totalChanged || next >= total || now - this.progressLastPrint >= PROGRESS_PRINT_THROTTLE_MS) {
2230
- this.progressLastPrint = now;
2231
- printRunProgress(next, total);
2030
+ const description = options.description?.(element, index) ?? id ?? `#${index + 1}`;
2031
+ const step = sink.startStep(options.op ?? "item", description);
2032
+ const handle = new ItemHandle(index, id, step, options.payload?.(element, index), sink);
2033
+ const current = index;
2034
+ let attempts = 0;
2035
+ let lastFailure;
2036
+ try {
2037
+ await retryWithBackoff(
2038
+ (attempt) => {
2039
+ attempts = attempt;
2040
+ if (attempt > 1) handle._nextAttempt(attempt);
2041
+ return sink.stepScope(step, async () => handler(element, handle, current));
2042
+ },
2043
+ {
2044
+ retries: totalRetries,
2045
+ delayMs: (attempt) => resolveDelay(options.retryDelayMs, attempt, DEFAULT_STEP_RETRY_DELAY_MS),
2046
+ // sem nova tentativa: ocorrência (regra de negócio), desfecho já registrado pelo
2047
+ // handler (ele decidiu) ou run abortada (o erro é efeito do cancelamento)
2048
+ shouldRetry: (error) => !isOccurrence(error) && !handle.reported && !sink.isAborted(),
2049
+ wait: (ms) => sink.sleep(ms),
2050
+ onRetry: (error, attempt, delayMs) => {
2051
+ lastFailure = { error };
2052
+ sink.logWarning(
2053
+ `item ${description}: tentativa ${attempt}/${totalRetries + 1} falhou: ${truncate(errorReason(error), 500)} \u2014 nova tentativa em ${delayMs}ms`
2054
+ );
2055
+ }
2056
+ }
2057
+ );
2058
+ if (!handle.reported) handle.succeeded();
2059
+ if (step) {
2060
+ noteAttempts(step, attempts);
2061
+ if (lastFailure && step.status === "ok") {
2062
+ step._setStatus("warning", `tentativa ${attempts - 1}/${totalRetries + 1} falhou: ${errorReason(lastFailure.error)}`);
2063
+ }
2064
+ }
2065
+ } catch (error) {
2066
+ if (isTerminalError(error) || sink.isAborted()) {
2067
+ step?._setStatus("cancelled")._close();
2068
+ throw error;
2069
+ }
2070
+ if (step) noteAttempts(step, attempts);
2071
+ if (isOccurrence(error)) {
2072
+ const occurrence = error;
2073
+ if (!handle.reported) handle.occurrence(occurrence.message, occurrence.payload, { image: occurrence.image });
2074
+ } else {
2075
+ const message = thrownMessage(error);
2076
+ if (!handle.reported) handle.failed(message);
2077
+ step?._failed(error);
2078
+ sink.logError(`item ${description} falhou: ${message}`);
2079
+ }
2232
2080
  }
2233
- this.progressDirty = true;
2234
- if (this.progressTimer) return;
2235
- this.progressTimer = setTimeout(() => {
2236
- this.progressTimer = void 0;
2237
- this.flushProgress();
2238
- }, PROGRESS_FLUSH_MS);
2239
- unrefTimer(this.progressTimer);
2081
+ step?._close();
2082
+ if (options.progress !== false) sink.advance();
2083
+ result.total += 1;
2084
+ result[handle.status ?? "succeeded"] += 1;
2085
+ index += 1;
2086
+ if (options.flush !== false) await sink.flush();
2240
2087
  }
2241
- flushProgress() {
2242
- this.clearProgressTimer();
2243
- if (this.dead || !this.progressDirty) return;
2244
- if (this.buffer.length > 0) this.flushLogs();
2245
- else this.enqueue({ status: "running" });
2088
+ if (result.skipped > 0) sink.logInfo(skippedMessage(result.skipped));
2089
+ return result;
2090
+ }
2091
+ function noteAttempts(step, attempts) {
2092
+ if (attempts > 1) step._setData("attempts", attempts);
2093
+ }
2094
+
2095
+ // src/tasks/files.ts
2096
+ var LOCAL_FILES_DIR_ENV = "HOBOTS_LOCAL_FILES_DIR";
2097
+ var FILE_DOWNLOAD_IDLE_TIMEOUT_MS = 6e4;
2098
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2099
+ function isFileRef(value) {
2100
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2101
+ const ref = value;
2102
+ return typeof ref.id === "string" && typeof ref.name === "string" && typeof ref.size === "number" && typeof ref.contentType === "string" && (ref.storage === "s3" || ref.storage === "local");
2103
+ }
2104
+ function fileField(params, field2) {
2105
+ if (!Object.prototype.hasOwnProperty.call(params, field2)) return void 0;
2106
+ const value = params[field2];
2107
+ if (isFileRef(value)) return value;
2108
+ if (Array.isArray(value) && value.every(isFileRef)) return value;
2109
+ return void 0;
2110
+ }
2111
+ function collectFileRefs(params) {
2112
+ const refs = [];
2113
+ for (const field2 of Object.keys(params)) {
2114
+ const value = fileField(params, field2);
2115
+ if (value !== void 0) refs.push(...Array.isArray(value) ? value : [value]);
2246
2116
  }
2247
- clearProgressTimer() {
2248
- if (this.progressTimer) {
2249
- clearTimeout(this.progressTimer);
2250
- this.progressTimer = void 0;
2117
+ return refs;
2118
+ }
2119
+ function safeFileName(name, fallback) {
2120
+ const cleaned = name.replace(/^.*[\\/]/, "").replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
2121
+ return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
2122
+ }
2123
+ function isInsideDir(root, candidate, sep) {
2124
+ const base = root.endsWith(sep) ? root : root + sep;
2125
+ return candidate.startsWith(base) && candidate.length > base.length;
2126
+ }
2127
+ function unavailableLocal(ref) {
2128
+ return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
2129
+ }
2130
+ function localRunPath(ref) {
2131
+ if (!isLocalRun()) return void 0;
2132
+ const path = ref.path;
2133
+ return typeof path === "string" && path.trim() !== "" ? path : void 0;
2134
+ }
2135
+ function errorCode(error) {
2136
+ return error?.code;
2137
+ }
2138
+ var RunFileStore = class {
2139
+ constructor(deps) {
2140
+ this.deps = deps;
2141
+ this.files = {
2142
+ list: () => collectFileRefs(deps.params),
2143
+ get: (field2) => fileField(deps.params, field2),
2144
+ // async: erro de uso (campo sem arquivo, valor inválido) chega como Promise rejeitada, nunca síncrono
2145
+ download: async (refOrField, options) => this.download(this.single(refOrField), options?.dest),
2146
+ downloadAll: async (field2, options) => this.downloadAll(field2, options?.dest),
2147
+ read: async (refOrField) => this.read(this.single(refOrField))
2148
+ };
2149
+ }
2150
+ deps;
2151
+ files;
2152
+ tempDir;
2153
+ /** Apaga a pasta temporária da execução (cópias que o bot não levou para `dest`). Nunca lança. */
2154
+ async cleanup() {
2155
+ const pending = this.tempDir;
2156
+ this.tempDir = void 0;
2157
+ if (!pending) return;
2158
+ try {
2159
+ const { rm } = await import("fs/promises");
2160
+ await rm(await pending, { recursive: true, force: true });
2161
+ } catch {
2251
2162
  }
2252
2163
  }
2253
- /** O PATCH terminal sempre leva o snapshot final (o app mostra "87/100" numa run que falhou). */
2254
- forceFinalProgress() {
2255
- this.clearProgressTimer();
2256
- this.progressDirty = this.progressTotal != null;
2164
+ field(field2) {
2165
+ const value = fileField(this.deps.params, field2);
2166
+ if (value === void 0) throw new Error(`campo "${field2}" n\xE3o tem arquivo nos params`);
2167
+ return value;
2257
2168
  }
2258
- /** Consome o snapshot pendente para pegar carona no próximo PATCH. */
2259
- takeProgress() {
2260
- if (!this.progressDirty || this.progressTotal == null) return void 0;
2261
- this.progressDirty = false;
2262
- return { current: this.progressCurrent, total: this.progressTotal };
2169
+ single(refOrField) {
2170
+ if (typeof refOrField === "string") {
2171
+ const value = this.field(refOrField);
2172
+ if (Array.isArray(value)) {
2173
+ throw new Error(`campo "${refOrField}" tem v\xE1rios arquivos \u2014 use downloadAll("${refOrField}") ou passe um item da lista`);
2174
+ }
2175
+ return value;
2176
+ }
2177
+ if (!isFileRef(refOrField)) throw new TypeError("esperado um FileRef ou o nome de um campo de arquivo");
2178
+ return refOrField;
2263
2179
  }
2264
- /**
2265
- * Heartbeat de cancelamento: enquanto o handler roda, um PATCH 'running' vazio
2266
- * periódico faz o servidor responder 409 se a run foi cancelada no app — o que
2267
- * mata o reporter e dispara ctx.signal, mesmo em handlers que nunca logam.
2268
- */
2269
- startCancelWatch() {
2270
- if (this.watchTimer) return;
2271
- this.watchTimer = setInterval(() => {
2272
- if (this.dead || this.pendingUpdates > 0 || this.buffer.length > 0) return;
2273
- this.enqueue({ status: "running" });
2274
- }, this.cancelCheckIntervalMs);
2275
- unrefTimer(this.watchTimer);
2180
+ async downloadAll(field2, dest) {
2181
+ const value = this.field(field2);
2182
+ const refs = Array.isArray(value) ? value : [value];
2183
+ const paths = [];
2184
+ for (const ref of refs) paths.push(await this.download(ref, dest, true));
2185
+ return paths;
2276
2186
  }
2277
- stopCancelWatch() {
2278
- if (this.watchTimer) {
2279
- clearInterval(this.watchTimer);
2280
- this.watchTimer = void 0;
2187
+ async read(ref) {
2188
+ const { readFile, rm } = await import("fs/promises");
2189
+ const { dirname } = await import("path");
2190
+ const path = await this.download(ref, void 0);
2191
+ try {
2192
+ return await readFile(path);
2193
+ } finally {
2194
+ await rm(dirname(path), { recursive: true, force: true });
2281
2195
  }
2282
2196
  }
2283
- /** Aguarda a fila de PATCHes esvaziar (chame depois do terminal). */
2284
- finalize() {
2285
- return this.chain;
2197
+ async download(ref, dest, destIsDir = false) {
2198
+ this.checkCanceled();
2199
+ const target = await this.resolveTarget(ref, dest, destIsDir);
2200
+ if (ref.storage === "local") await this.copyLocal(ref, target);
2201
+ else await this.downloadS3(ref, target);
2202
+ return target;
2286
2203
  }
2287
- // ── Itens de execução ────────────────────────────────────────────────────────
2288
- /**
2289
- * Bufferiza um item (síncrono — a chamada nunca espera rede). Diferente do PATCH
2290
- * de status, o servidor aceita itens mesmo com a run cancelada (trabalho já feito),
2291
- * então um reporter morto não descarta: só um kill() de exit path descarta.
2292
- */
2293
- addItem(status, message, payload, id, image3) {
2294
- if (this.itemsClosed || this.itemsUnsupported) return;
2295
- let scrubbedPayload;
2296
- if (payload) {
2297
- scrubbedPayload = scrubPayloadValue(payload, 0);
2298
- let serializedLength;
2299
- try {
2300
- serializedLength = JSON.stringify(scrubbedPayload).length;
2301
- } catch {
2302
- }
2303
- if (serializedLength === void 0 || serializedLength > MAX_ITEM_PAYLOAD_BYTES) {
2304
- this.logger.warn(
2305
- `payload do item descartado (${serializedLength === void 0 ? "n\xE3o serializ\xE1vel" : `${serializedLength} bytes > ${MAX_ITEM_PAYLOAD_BYTES}`}) \u2014 o item foi mantido`
2306
- );
2307
- scrubbedPayload = void 0;
2308
- }
2309
- }
2310
- const ts = nowISO();
2311
- const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2312
- this.itemsBuffer.push({
2313
- id: id ? truncate(String(id), 255) : uuid4(),
2314
- seq: this.itemSeq++,
2315
- status,
2316
- ...message ? { message: truncate(scrubSecrets(safeString(message)), MAX_ITEM_MESSAGE_CHARS) } : {},
2317
- ...scrubbedPayload ? { payload: scrubbedPayload } : {},
2318
- ts,
2319
- ...attachmentId ? { attachments: [attachmentId] } : {}
2320
- });
2321
- if (this.itemsBuffer.length >= ITEMS_FLUSH_AT_BUFFERED) {
2322
- this.flushItems();
2323
- } else {
2324
- this.scheduleItemsFlush();
2325
- }
2204
+ checkCanceled() {
2205
+ if (this.deps.signal.aborted) throw runCanceledError();
2326
2206
  }
2327
- scheduleItemsFlush() {
2328
- if (this.itemsTimer) return;
2329
- this.itemsTimer = setTimeout(() => {
2330
- this.itemsTimer = void 0;
2331
- this.flushItems();
2332
- }, this.flushIntervalMs);
2333
- unrefTimer(this.itemsTimer);
2207
+ runTempDir() {
2208
+ this.tempDir ??= (async () => {
2209
+ const { mkdtemp } = await import("fs/promises");
2210
+ const { tmpdir } = await import("os");
2211
+ const { join } = await import("path");
2212
+ return mkdtemp(join(tmpdir(), "hobots-files-"));
2213
+ })();
2214
+ return this.tempDir;
2334
2215
  }
2335
- clearItemsTimer() {
2336
- if (this.itemsTimer) {
2337
- clearTimeout(this.itemsTimer);
2338
- this.itemsTimer = void 0;
2216
+ async resolveTarget(ref, dest, destIsDir) {
2217
+ const { mkdir, mkdtemp, stat } = await import("fs/promises");
2218
+ const { dirname, join, resolve: resolve2 } = await import("path");
2219
+ const name = safeFileName(ref.name, "arquivo");
2220
+ if (dest === void 0) {
2221
+ return join(await mkdtemp(join(await this.runTempDir(), "f-")), name);
2339
2222
  }
2340
- }
2341
- /** Envia o buffer de itens em lotes serializados na cadeia própria. */
2342
- flushItems() {
2343
- this.clearItemsTimer();
2344
- if (this.itemsBuffer.length === 0 || this.itemsUnsupported) return;
2345
- const items = this.itemsBuffer.splice(0, this.itemsBuffer.length);
2346
- for (let i = 0; i < items.length; i += MAX_ITEMS_PER_REQUEST) {
2347
- const batch = items.slice(i, i + MAX_ITEMS_PER_REQUEST);
2348
- this.itemsChain = this.itemsChain.then(() => this.sendItems(batch));
2223
+ const isDir = destIsDir || dest.endsWith("/") || dest.endsWith("\\") || (await stat(dest).catch(() => void 0))?.isDirectory() === true;
2224
+ if (isDir) {
2225
+ await mkdir(dest, { recursive: true });
2226
+ return resolve2(dest, name);
2349
2227
  }
2228
+ await mkdir(dirname(resolve2(dest)), { recursive: true });
2229
+ return resolve2(dest);
2350
2230
  }
2351
- async sendItems(batch) {
2352
- if (this.itemsUnsupported) return;
2231
+ /** storage 's3': URL pré-assinada → GET em streaming → confere o tamanho → só então DELETE. */
2232
+ async downloadS3(ref, target) {
2233
+ const { api, runId, signal } = this.deps;
2234
+ let expected;
2235
+ let url;
2353
2236
  try {
2354
- await this.api.createItems(this.runId, { items: batch });
2355
- this.itemsStorageFull = false;
2237
+ const info = await api.requestFileDownload(runId, ref.id, signal);
2238
+ expected = info.size >= 0 ? info.size : ref.size;
2239
+ url = info.url;
2356
2240
  } catch (error) {
2357
- if (error instanceof TasksApiError && error.status === 404) {
2358
- this.itemsUnsupported = true;
2359
- this.logger.warn("itens de execu\xE7\xE3o n\xE3o s\xE3o suportados por este servidor Hobots \u2014 descartados");
2360
- return;
2361
- }
2362
- if (error instanceof TasksApiError && error.status === 429) {
2363
- if (!this.itemsQuotaExceeded) {
2364
- this.itemsQuotaExceeded = true;
2365
- this.log("warning", "itens descartados: limite de itens por execu\xE7\xE3o atingido no servidor");
2241
+ throw this.translate(error, ref);
2242
+ }
2243
+ const { open, rename, rm } = await import("fs/promises");
2244
+ const part = `${target}.part`;
2245
+ const idleTimeoutMs = this.deps.idleTimeoutMs ?? FILE_DOWNLOAD_IDLE_TIMEOUT_MS;
2246
+ const controller = new AbortController();
2247
+ let timedOut = false;
2248
+ let timer;
2249
+ const arm = () => {
2250
+ clearTimeout(timer);
2251
+ timer = setTimeout(() => {
2252
+ timedOut = true;
2253
+ controller.abort();
2254
+ }, idleTimeoutMs);
2255
+ };
2256
+ const onCancel = () => controller.abort();
2257
+ signal.addEventListener("abort", onCancel, { once: true });
2258
+ try {
2259
+ arm();
2260
+ const response = await api.fetchFile(url, controller.signal);
2261
+ const handle = await open(part, "w");
2262
+ let written = 0;
2263
+ try {
2264
+ const reader = response.body?.getReader();
2265
+ const stopReading = () => void reader?.cancel().catch(() => void 0);
2266
+ controller.signal.addEventListener("abort", stopReading, { once: true });
2267
+ for (; ; ) {
2268
+ const chunk = reader ? await reader.read() : { done: true, value: void 0 };
2269
+ if (chunk.done) break;
2270
+ arm();
2271
+ written += chunk.value.byteLength;
2272
+ if (written > expected) {
2273
+ stopReading();
2274
+ break;
2275
+ }
2276
+ await handle.write(chunk.value);
2366
2277
  }
2367
- return;
2278
+ } finally {
2279
+ await handle.close();
2368
2280
  }
2369
- if (error instanceof TasksApiError && error.status === 402) {
2370
- if (!this.itemsStorageFull) {
2371
- this.itemsStorageFull = true;
2372
- this.log("warning", "itens descartados: armazenamento do plano cheio \u2014 libere espa\xE7o ou fa\xE7a upgrade em Plano e cobran\xE7a");
2373
- }
2374
- return;
2281
+ if (written !== expected) {
2282
+ throw new Error(`tamanho divergente no download de "${ref.name}": recebidos ${written} bytes, esperados ${expected}`);
2375
2283
  }
2376
- if (error instanceof TasksApiError) {
2377
- this.logger.error(`lote de ${batch.length} item(ns) rejeitado pelo servidor:`, error);
2378
- this.log("warning", `${batch.length} item(ns) descartado(s): envio rejeitado pelo servidor (HTTP ${error.status})`);
2379
- return;
2284
+ await rename(part, target);
2285
+ } catch (error) {
2286
+ await rm(part, { force: true });
2287
+ if (signal.aborted) throw runCanceledError();
2288
+ if (timedOut) {
2289
+ throw new Error(`download de "${ref.name}" parado h\xE1 ${idleTimeoutMs / 1e3}s \u2014 tente de novo`);
2380
2290
  }
2381
- this.itemsBuffer.unshift(...batch);
2382
- this.scheduleItemsFlush();
2383
- this.logger.error(`lote de ${batch.length} item(ns) n\xE3o enviado (devolvido ao buffer):`, error);
2291
+ throw this.translate(error, ref);
2292
+ } finally {
2293
+ clearTimeout(timer);
2294
+ signal.removeEventListener("abort", onCancel);
2384
2295
  }
2385
- }
2386
- /**
2387
- * Dreno final de itens antes do PATCH terminal (com teto): garante que os
2388
- * totalizadores estejam completos quando a run vira succeeded/failed. Como o
2389
- * servidor aceita itens tardios, perder a corrida aqui não perde dados.
2390
- */
2391
- async finalizeItems(timeoutMs = ITEMS_DRAIN_MS) {
2392
- this.flushItems();
2393
- await Promise.race([
2394
- this.itemsChain,
2395
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2396
- ]);
2397
- if (this.itemsBuffer.length > 0 && !this.itemsUnsupported && !this.itemsQuotaExceeded) {
2398
- this.flushItems();
2399
- await Promise.race([
2400
- this.itemsChain,
2401
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2402
- ]);
2296
+ try {
2297
+ await api.confirmFileDownload(runId, ref.id);
2298
+ } catch (error) {
2299
+ this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
2403
2300
  }
2404
2301
  }
2405
- // ── Anexos ───────────────────────────────────────────────────────────────────
2406
2302
  /**
2407
- * Pendura uma imagem num evento da run (log, item ou etapa): faz as checagens que não
2408
- * dependem de I/O (reporter morto, instalação sem anexos, cota), reserva o id e dispara
2409
- * o upload em segundo plano. Devolve o id — já referenciável pelo evento — ou undefined
2410
- * quando a imagem foi descartada (sempre com aviso no log da run). Nunca lança: todo
2411
- * caminho de erro do upload vira aviso. O hint `attachment` de `hobots.captureException`
2412
- * também cai aqui via `log()`.
2303
+ * storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de
2304
+ * origem. No modo local, um FileRef com `path` copia direto desse caminho, sem apagar a origem.
2413
2305
  */
2414
- queueAttachment(imageIn, ts) {
2415
- if (this.dead) return void 0;
2416
- const image3 = toImageAttachment(imageIn);
2417
- if (this.attachmentsUnsupported) return void 0;
2418
- if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
2419
- if (this.attachmentCount === MAX_ATTACHMENTS_PER_RUN) {
2420
- this.attachmentCount++;
2421
- this.log("warning", `imagem descartada: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
2306
+ async copyLocal(ref, target) {
2307
+ const direct = localRunPath(ref);
2308
+ if (direct !== void 0) {
2309
+ await this.copyFromPath(ref, direct, target);
2310
+ return;
2311
+ }
2312
+ const base = process.env[LOCAL_FILES_DIR_ENV];
2313
+ if (!base) throw unavailableLocal(ref);
2314
+ const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
2315
+ const { join, resolve: resolve2, sep } = await import("path");
2316
+ const root = resolve2(base);
2317
+ const folder = resolve2(root, ref.id);
2318
+ if (!isInsideDir(root, folder, sep) || !UUID_RE.test(ref.id)) {
2319
+ throw new Error(`id de arquivo local inv\xE1lido: ${JSON.stringify(ref.id)}`);
2320
+ }
2321
+ let names;
2322
+ try {
2323
+ names = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
2324
+ } catch (error) {
2325
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") throw unavailableLocal(ref);
2326
+ throw error;
2327
+ }
2328
+ if (names.length === 0) throw unavailableLocal(ref);
2329
+ if (names.length > 1) throw new Error(`pasta do arquivo "${ref.name}" tem ${names.length} arquivos \u2014 esperado 1`);
2330
+ this.checkCanceled();
2331
+ const part = `${target}.part`;
2332
+ try {
2333
+ await copyFile(join(folder, names[0]), part);
2334
+ const { size } = await stat(part);
2335
+ if (size !== ref.size) {
2336
+ throw new Error(`tamanho divergente na c\xF3pia de "${ref.name}": copiados ${size} bytes, esperados ${ref.size}`);
2422
2337
  }
2423
- return void 0;
2338
+ await rename(part, target);
2339
+ } catch (error) {
2340
+ await rm(part, { force: true });
2341
+ throw error;
2424
2342
  }
2425
- this.attachmentCount++;
2426
- const id = uuid4();
2427
- const rawCaption = image3.caption !== void 0 ? safeString(image3.caption) : void 0;
2428
- const caption = rawCaption ? truncate(scrubSecrets(rawCaption), MAX_CAPTION_CHARS) : void 0;
2429
- const work = this.uploadAttachment(id, image3.input, { caption, logTs: ts, contentType: image3.contentType }).then(() => void 0).catch(() => void 0);
2430
- this.pendingAttachments.add(work);
2431
- void work.finally(() => this.pendingAttachments.delete(work));
2432
- return id;
2343
+ await rm(folder, { recursive: true, force: true });
2433
2344
  }
2434
- /** Retorna o motivo da falha, ou undefined em sucesso. */
2435
- async uploadAttachment(id, image3, meta) {
2436
- let bytes;
2345
+ /**
2346
+ * Modo local: cópia do arquivo apontado por `path` (teste roda várias vezes — a fonte fica).
2347
+ * Sem conferência de tamanho: o `size` do FileRef é informativo aqui.
2348
+ */
2349
+ async copyFromPath(ref, path, target) {
2350
+ const { copyFile, rename, rm, stat } = await import("fs/promises");
2351
+ const { resolve: resolve2 } = await import("path");
2352
+ const source = resolve2(path);
2353
+ const info = await stat(source).catch(() => void 0);
2354
+ if (!info?.isFile()) throw new Error(`arquivo "${ref.name}" n\xE3o encontrado em ${path}`);
2355
+ this.checkCanceled();
2356
+ const part = `${target}.part`;
2437
2357
  try {
2438
- bytes = await toBytes(image3);
2358
+ await copyFile(source, part);
2359
+ await rename(part, target);
2439
2360
  } catch (error) {
2440
- this.log("warning", `imagem n\xE3o p\xF4de ser lida: ${String(error)}`);
2441
- return "read-failed";
2361
+ await rm(part, { force: true });
2362
+ throw error;
2442
2363
  }
2443
- const contentType = sniffContentType(bytes) ?? meta.contentType;
2444
- if (!contentType) {
2445
- this.log("warning", "imagem descartada: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
2446
- return "unsupported-format";
2364
+ }
2365
+ translate(error, ref) {
2366
+ if (this.deps.signal.aborted) return runCanceledError();
2367
+ if (error instanceof TasksApiError && error.status === 410) {
2368
+ return new TasksApiError(`arquivo "${ref.name}" j\xE1 baixado ou expirado`, 410);
2447
2369
  }
2448
- if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
2449
- this.log(
2450
- "warning",
2451
- `imagem descartada: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
2452
- );
2453
- return "too-large";
2370
+ if (error instanceof TasksApiError && error.status === 404) {
2371
+ return new TasksApiError(`arquivo "${ref.name}" n\xE3o encontrado nesta execu\xE7\xE3o`, 404);
2454
2372
  }
2455
- const dimensions = readPngDimensions(bytes);
2456
- const release = await this.acquireUploadSlot();
2457
- try {
2458
- if (this.abortController.signal.aborted || this.dead) return "canceled";
2459
- const created = await this.api.createAttachment(
2460
- this.runId,
2461
- {
2462
- id,
2463
- content_type: contentType,
2464
- size_bytes: bytes.byteLength,
2465
- log_ts: meta.logTs,
2466
- ...meta.caption ? { caption: meta.caption } : {},
2467
- ...dimensions ?? {}
2373
+ return error;
2374
+ }
2375
+ };
2376
+
2377
+ // src/tasks/context.ts
2378
+ var LOG_LEVEL_COLOR = {
2379
+ debug: "gray",
2380
+ info: "cyan",
2381
+ warning: "yellow",
2382
+ error: "red",
2383
+ success: "green"
2384
+ };
2385
+ function joinLogArgs(message, rest) {
2386
+ if (rest.length === 0) return safeString(message);
2387
+ return [message, ...rest].map(safeString).join(" ");
2388
+ }
2389
+ function splitLogArgs(message, rest) {
2390
+ const args = [message, ...rest];
2391
+ const last = args[args.length - 1];
2392
+ if (!isImageAttachment(last)) return { text: joinLogArgs(message, rest) };
2393
+ const text = args.slice(0, -1);
2394
+ if (text.length === 0) return { text: last.caption !== void 0 ? safeString(last.caption) : "captura de tela", image: last };
2395
+ return { text: joinLogArgs(text[0], text.slice(1)), image: last };
2396
+ }
2397
+ function printRunLog(level, message) {
2398
+ const line = colorize(`[hobots] ${level.toUpperCase()}`, LOG_LEVEL_COLOR[level]);
2399
+ const method2 = level === "warning" ? "warn" : level === "success" ? "info" : level;
2400
+ console[method2](line, message);
2401
+ }
2402
+ var PROGRESS_LINE_PREFIX = "[hobots:progress]";
2403
+ var PROGRESS_PRINT_THROTTLE_MS = 250;
2404
+ function printRunProgress(current, total) {
2405
+ console.log(`${PROGRESS_LINE_PREFIX} ${current}/${total}`);
2406
+ }
2407
+ function cancelableSleep(ms, signal) {
2408
+ return new Promise((resolve2, reject) => {
2409
+ if (signal.aborted) {
2410
+ reject(runCanceledError());
2411
+ return;
2412
+ }
2413
+ const onAbort = () => {
2414
+ clearTimeout(timer);
2415
+ reject(runCanceledError());
2416
+ };
2417
+ const timer = setTimeout(() => {
2418
+ signal.removeEventListener("abort", onAbort);
2419
+ resolve2();
2420
+ }, ms);
2421
+ signal.addEventListener("abort", onAbort, { once: true });
2422
+ });
2423
+ }
2424
+ var DEFAULT_LOG_FLUSH_INTERVAL_MS = 2e3;
2425
+ var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
2426
+ var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
2427
+ var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
2428
+ var MAX_LOG_MESSAGE_CHARS = WIRE_LIMITS.MAX_LOG_MESSAGE_CHARS;
2429
+ var MAX_LOGS_PER_PATCH = WIRE_LIMITS.MAX_LOGS_PER_PATCH;
2430
+ var FLUSH_AT_BUFFERED = 20;
2431
+ var MAX_ITEMS_PER_REQUEST = WIRE_LIMITS.MAX_ITEMS_PER_REQUEST;
2432
+ var MAX_ITEMS_DONE_PER_PAGE = WIRE_LIMITS.MAX_ITEMS_DONE_PER_PAGE;
2433
+ var MAX_ITEM_MESSAGE_CHARS = WIRE_LIMITS.MAX_ITEM_MESSAGE_CHARS;
2434
+ var MAX_ITEM_PAYLOAD_BYTES = WIRE_LIMITS.MAX_ITEM_PAYLOAD_BYTES;
2435
+ var ITEMS_FLUSH_AT_BUFFERED = 100;
2436
+ var ITEMS_DRAIN_MS = 15e3;
2437
+ var PROGRESS_FLUSH_MS = 1e3;
2438
+ function scrubPayloadValue(value, depth) {
2439
+ if (typeof value === "string") return scrubSecrets(value);
2440
+ if (depth >= 4 || value === null || typeof value !== "object") return value;
2441
+ if (Array.isArray(value)) return value.map((entry) => scrubPayloadValue(entry, depth + 1));
2442
+ const out = {};
2443
+ for (const [key, entry] of Object.entries(value)) {
2444
+ out[key] = scrubPayloadValue(entry, depth + 1);
2445
+ }
2446
+ return out;
2447
+ }
2448
+ var RunReporter = class {
2449
+ constructor(api, runId, logger, tx, flushIntervalMs, cancelCheckIntervalMs, params = {}) {
2450
+ this.api = api;
2451
+ this.runId = runId;
2452
+ this.logger = logger;
2453
+ this.tx = tx;
2454
+ this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
2455
+ this.cancelCheckIntervalMs = Math.min(
2456
+ MAX_CANCEL_CHECK_INTERVAL_MS,
2457
+ Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
2458
+ );
2459
+ const logAt = (level) => (message, ...rest) => {
2460
+ const { text, image: image3 } = splitLogArgs(message, rest);
2461
+ this.log(level, text, image3);
2462
+ };
2463
+ const log = {
2464
+ debug: logAt("debug"),
2465
+ info: logAt("info"),
2466
+ warn: logAt("warning"),
2467
+ error: logAt("error"),
2468
+ success: logAt("success")
2469
+ };
2470
+ tx.setLifecycleSink((level, message) => this.push(level, message));
2471
+ tx.setAttachmentSink((image3, ts) => this.queueAttachment(image3, ts));
2472
+ const checkCanceled = () => this.checkCanceled();
2473
+ tx.setStepGuard(checkCanceled);
2474
+ const eachSink = {
2475
+ report: (status, message, payload, id, image3) => this.addItem(status, message, payload, id, image3),
2476
+ startStep: (op, description) => tx.start(op, description),
2477
+ stepScope: (step, fn) => tx.scope(step, fn),
2478
+ declareTotal: (total) => {
2479
+ if (this.progressTotal == null) this.setProgress(this.progressCurrent, total);
2480
+ },
2481
+ advance: () => this.setProgress(this.progressCurrent + 1, this.progressTotal),
2482
+ checkCanceled,
2483
+ isAborted: () => this.abortController.signal.aborted,
2484
+ logInfo: (message) => this.log("info", message),
2485
+ logWarning: (message) => this.log("warning", message),
2486
+ logError: (message) => this.log("error", message),
2487
+ sleep: (ms) => cancelableSleep(ms, this.abortController.signal),
2488
+ doneItemIds: () => this.listDoneItemIds(),
2489
+ flush: () => this.flush()
2490
+ };
2491
+ const items = {
2492
+ add: (item) => {
2493
+ checkCanceled();
2494
+ this.addItem(item.status, item.message, item.payload, item.id, item.image);
2495
+ },
2496
+ succeeded: (payload, options) => {
2497
+ checkCanceled();
2498
+ this.addItem("succeeded", void 0, payload, options?.id, options?.image);
2499
+ },
2500
+ failed: (message, payload, options) => {
2501
+ checkCanceled();
2502
+ this.addItem("failed", message, payload, options?.id, options?.image);
2503
+ },
2504
+ occurrence: (message, payload, options) => {
2505
+ checkCanceled();
2506
+ this.addItem("occurrence", message, payload, options?.id, options?.image);
2507
+ },
2508
+ each: (list, handler, options = {}) => eachItem(list, handler, options, eachSink)
2509
+ };
2510
+ const progress = Object.defineProperties(
2511
+ {
2512
+ total: (total) => {
2513
+ checkCanceled();
2514
+ this.setProgress(this.progressCurrent, total);
2515
+ },
2516
+ advance: (count = 1) => {
2517
+ checkCanceled();
2518
+ this.setProgress(this.progressCurrent + count, this.progressTotal);
2468
2519
  },
2469
- this.abortController.signal
2470
- );
2471
- await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
2472
- await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
2473
- });
2474
- return void 0;
2475
- } catch (error) {
2476
- if (this.isUnsupported(error)) {
2477
- this.attachmentsUnsupported = true;
2478
- this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
2479
- return "unsupported";
2480
- }
2481
- if (error instanceof TasksApiError && error.status === 429) {
2482
- this.log("warning", "imagem descartada: limite de anexos por execu\xE7\xE3o atingido no servidor");
2483
- return "quota";
2520
+ set: (current, total) => {
2521
+ checkCanceled();
2522
+ this.setProgress(current, total ?? this.progressTotal);
2523
+ }
2524
+ },
2525
+ {
2526
+ current: { enumerable: true, get: () => this.progressCurrent },
2527
+ snapshot: {
2528
+ enumerable: true,
2529
+ get: () => this.progressTotal == null ? null : { current: this.progressCurrent, total: this.progressTotal }
2530
+ }
2484
2531
  }
2485
- if (this.abortController.signal.aborted) return "canceled";
2486
- this.log("warning", `falha ao enviar a imagem: ${String(error)}`);
2487
- void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
2488
- });
2489
- return "upload-failed";
2490
- } finally {
2491
- release();
2492
- }
2493
- }
2494
- /** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
2495
- isUnsupported(error) {
2496
- return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
2497
- }
2498
- /** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
2499
- acquireUploadSlot() {
2500
- const release = () => {
2501
- const next = this.uploadQueue.shift();
2502
- if (next) next();
2503
- else this.uploadSlots++;
2532
+ // defineProperties não tipa os getters adicionados — a interface é a fonte de verdade
2533
+ );
2534
+ const signal = this.abortController.signal;
2535
+ const sleep2 = (ms) => cancelableSleep(ms, signal);
2536
+ this.fileStore = new RunFileStore({ api, runId, params, signal, warn: (message) => this.log("warning", message) });
2537
+ this.context = {
2538
+ runId,
2539
+ params,
2540
+ log,
2541
+ items,
2542
+ progress,
2543
+ files: this.fileStore.files,
2544
+ step: async (name, fn, options) => {
2545
+ const root = tx.currentStep() === void 0;
2546
+ try {
2547
+ return await tx.step(name, fn, { ...options, signal, wait: sleep2, warn: (message) => this.log("warning", message) });
2548
+ } finally {
2549
+ if (options?.flush !== false && root && !signal.aborted) await this.flush();
2550
+ }
2551
+ },
2552
+ flush: (timeoutMs) => this.flush(timeoutMs),
2553
+ signal,
2554
+ throwIfCanceled: checkCanceled,
2555
+ sleep: sleep2,
2556
+ retry: (fn, options) => retryWithBackoff(fn, { ...options, signal, wait: sleep2 })
2504
2557
  };
2505
- if (this.uploadSlots > 0) {
2506
- this.uploadSlots--;
2507
- return Promise.resolve(release);
2508
- }
2509
- return new Promise((resolve2) => {
2510
- this.uploadQueue.push(() => resolve2(release));
2511
- });
2512
2558
  }
2559
+ api;
2560
+ runId;
2561
+ logger;
2562
+ tx;
2563
+ context;
2564
+ buffer = [];
2565
+ dead = false;
2566
+ chain = Promise.resolve();
2567
+ pendingUpdates = 0;
2568
+ timer;
2569
+ watchTimer;
2570
+ abortController = new AbortController();
2571
+ flushIntervalMs;
2572
+ cancelCheckIntervalMs;
2573
+ // Itens de execução vivem FORA de `this.chain` (status/liveness não pode ficar
2574
+ // atrás de milhares de itens), mas têm cadeia própria serializada — ordem de
2575
+ // envio preservada e sem lotes sobrepostos.
2576
+ itemsBuffer = [];
2577
+ itemsChain = Promise.resolve();
2578
+ itemsTimer;
2579
+ itemSeq = 0;
2580
+ /** Servidor sem o recurso (404) — para de tentar (e de avisar) nesta run. */
2581
+ itemsUnsupported = false;
2513
2582
  /**
2514
- * Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
2515
- * exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
2583
+ * Cota de itens por run do servidor (429, legado — hoje a api não tem teto de itens) —
2584
+ * degrada com um aviso só.
2516
2585
  */
2517
- async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
2518
- if (this.pendingAttachments.size === 0) return;
2519
- await Promise.race([
2520
- Promise.allSettled([...this.pendingAttachments]),
2521
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2522
- ]);
2523
- }
2524
- enqueue(updateIn) {
2525
- if (this.dead) {
2526
- this.salvageLogs(updateIn.logs);
2527
- return;
2528
- }
2529
- this.clearProgressTimer();
2530
- const progress = this.takeProgress();
2531
- const update = progress ? { ...updateIn, progress } : updateIn;
2532
- this.pendingUpdates++;
2533
- this.chain = this.chain.then(async () => {
2534
- if (this.dead) {
2535
- this.salvageLogs(update.logs);
2536
- return;
2537
- }
2538
- const payload = update.logs && update.logs.length === 0 ? { ...update, logs: void 0 } : update;
2539
- try {
2540
- await this.api.updateRun(this.runId, payload);
2541
- } catch (error) {
2542
- if (error instanceof TasksApiError && (error.status === 409 || error.status === 404)) {
2543
- this.dead = true;
2544
- this.stopCancelWatch();
2545
- this.abort();
2546
- const pending = this.buffer.splice(0, this.buffer.length);
2547
- this.salvageLogs(pending);
2548
- this.logger.warn(
2549
- `run ${this.runId} cancelada/finalizada no servidor \u2014 status suprimido` + (pending.length > 0 ? `, ${pending.length} log(s) enviados como log tardio` : "")
2550
- );
2551
- return;
2552
- }
2553
- if (update.logs && update.logs.length > 0) {
2554
- this.buffer.unshift(...update.logs);
2555
- this.scheduleFlush();
2556
- }
2557
- if (update.progress && this.progressTotal != null) this.progressDirty = true;
2558
- this.logger.error(`update da run ${this.runId} perdido:`, error);
2559
- }
2560
- }).finally(() => {
2561
- this.pendingUpdates--;
2562
- });
2563
- }
2586
+ itemsQuotaExceeded = false;
2564
2587
  /**
2565
- * Best-effort de log tardio: envia logs para uma run já terminal, fora da fila.
2566
- * O servidor anexa os logs mesmo respondendo 409; erros são engolidos — este é
2567
- * o último recurso, não um canal confiável.
2588
+ * Armazenamento do plano cheio (402 `storage_limit`): o lote é descartado e o aviso sai
2589
+ * uma vez por episódio. Os envios continuam — num agente `start()` o espaço pode ser
2590
+ * liberado (ou o plano trocado) sem reiniciar; um lote aceito rearma o aviso.
2568
2591
  */
2569
- salvageLogs(logs) {
2570
- if (!logs || logs.length === 0) return;
2571
- for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
2572
- void this.api.updateRunOnce(this.runId, { status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) }).catch(() => {
2573
- });
2574
- }
2592
+ itemsStorageFull = false;
2593
+ /**
2594
+ * Só o kill() do exit path fecha os itens — `dead` (run cancelada/finalizada no
2595
+ * servidor) NÃO fecha: o servidor aceita itens de run terminal (trabalho já feito).
2596
+ */
2597
+ itemsClosed = false;
2598
+ // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
2599
+ // status/logs e não pode ficar bloqueada esperando um binário subir.
2600
+ pendingAttachments = /* @__PURE__ */ new Set();
2601
+ attachmentCount = 0;
2602
+ uploadSlots = MAX_CONCURRENT_UPLOADS;
2603
+ uploadQueue = [];
2604
+ /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
2605
+ attachmentsUnsupported = false;
2606
+ // Progresso (ctx.progress): snapshot absoluto, enviado com throttle via piggyback
2607
+ // em `enqueue()` — nunca gera tráfego próprio enquanto logs/heartbeat já fluem.
2608
+ progressCurrent = 0;
2609
+ progressTotal = null;
2610
+ /** Snapshot ainda não entregue ao servidor. */
2611
+ progressDirty = false;
2612
+ progressTimer;
2613
+ progressWarned = false;
2614
+ /** Relógio do último eco `[hobots:progress]` no terminal (throttle do eco, não do wire). */
2615
+ progressLastPrint = 0;
2616
+ /** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
2617
+ fileStore;
2618
+ /** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
2619
+ cleanupFiles() {
2620
+ return this.fileStore.cleanup();
2575
2621
  }
2576
- };
2577
-
2578
- // src/tasks/run-context-store.ts
2579
- var storage;
2580
- var stepStorage;
2581
- var initPromise;
2582
- async function ensureStorage() {
2583
- if (storage || !isNode()) return;
2584
- initPromise ??= import("async_hooks").then(({ AsyncLocalStorage: ALS }) => {
2585
- storage = new ALS();
2586
- stepStorage = new ALS();
2587
- });
2588
- await initPromise;
2589
- }
2590
- async function runWithRunContext(store, fn) {
2591
- await ensureStorage();
2592
- return storage ? storage.run(store, fn) : fn();
2593
- }
2594
- function currentRunReporter() {
2595
- return storage?.getStore()?.reporter;
2596
- }
2597
- async function runWithStepScope(scope, fn) {
2598
- await ensureStorage();
2599
- return stepStorage ? stepStorage.run(scope, fn) : fn();
2600
- }
2601
- function currentStep(transaction) {
2602
- const scope = stepStorage?.getStore();
2603
- return scope?.transaction === transaction ? scope.step : void 0;
2604
- }
2605
-
2606
- // src/transaction.ts
2607
- var FLUSH_TRANSACTION_OP = "hobots.run.flush";
2608
- var DEFAULT_STEP_RETRIES = 1;
2609
- var DEFAULT_STEP_RETRY_DELAY_MS = 5e3;
2610
- var MAX_STEP_MESSAGE_CHARS = 2e3;
2611
- var MAX_BACKLOG = 100;
2612
- function stepMessage(text) {
2613
- return clip(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
2614
- }
2615
- function stepId() {
2616
- return uuid4().slice(0, 16);
2617
- }
2618
- function stepLabel(op, description) {
2619
- return [op, description].filter(Boolean).join(" \u2014 ") || "(sem nome)";
2620
- }
2621
- function errorText(error) {
2622
- if (error instanceof Error) return error.message || error.name;
2623
- return safeString(error);
2624
- }
2625
- function errorReason(error) {
2626
- return error instanceof Error ? `${error.name}: ${errorText(error)}` : errorText(error);
2627
- }
2628
- function levelForStatus(status) {
2629
- switch (status) {
2630
- case "warning":
2631
- return "warning";
2632
- case "error":
2633
- return "error";
2634
- case "cancelled":
2635
- return "warning";
2636
- default:
2637
- return "success";
2622
+ get isDead() {
2623
+ return this.dead;
2638
2624
  }
2639
- }
2640
- function colorForStatus(status) {
2641
- switch (status) {
2642
- case "warning":
2643
- return "yellow";
2644
- case "error":
2645
- return "red";
2646
- case "cancelled":
2647
- return "gray";
2648
- default:
2649
- return "green";
2625
+ /** Lança `RunCanceled` se o signal desta run já disparou. */
2626
+ checkCanceled() {
2627
+ if (this.abortController.signal.aborted) throw runCanceledError();
2650
2628
  }
2651
- }
2652
- var Step = class {
2653
- constructor(transaction, parentStepId, op, description) {
2654
- this.transaction = transaction;
2655
- this.parentStepId = parentStepId;
2656
- this.op = op;
2657
- this.description = description;
2629
+ /** Aborta o ctx.signal sem matar o reporter (timeout/shutdown ainda reportam o terminal). */
2630
+ abort() {
2631
+ this.stopCancelWatch();
2632
+ if (!this.abortController.signal.aborted) this.abortController.abort();
2658
2633
  }
2659
- transaction;
2660
- parentStepId;
2661
- op;
2662
- description;
2663
- stepId = stepId();
2664
- startTimestamp = nowISO();
2665
- status = "ok";
2666
- endTimestamp;
2667
- _data;
2668
- // ── API do bot ────────────────────────────────────────────────────────────
2669
- warning(message, options) {
2670
- if (this.status !== "error") this._setStatus("warning", message);
2671
- if (options?.image !== void 0) this.attachImage(options.image);
2672
- return this;
2634
+ /**
2635
+ * Mata o reporter de vez: aborta o signal e suprime todo reporte futuro.
2636
+ * Usado no exit path — depois que o agente reportou AgentTerminated por fora
2637
+ * da fila, um handler zumbi não pode reenviar status por cima.
2638
+ */
2639
+ kill() {
2640
+ this.abort();
2641
+ this.dead = true;
2642
+ this.clearFlushTimer();
2643
+ this.clearProgressTimer();
2644
+ this.buffer = [];
2645
+ this.itemsClosed = true;
2646
+ this.clearItemsTimer();
2647
+ if (this.itemsBuffer.length > 0) {
2648
+ this.logger.warn(`${this.itemsBuffer.length} item(ns) de execu\xE7\xE3o descartados (processo encerrando)`);
2649
+ this.itemsBuffer = [];
2650
+ }
2651
+ const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
2652
+ for (const resume of waiting) resume();
2673
2653
  }
2674
- image(image3) {
2675
- this.attachImage(image3);
2676
- return this;
2654
+ /**
2655
+ * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
2656
+ * que descarta o buffer). Capado em um chunk — o exit path não tem retry nem fila,
2657
+ * então preserva os logs mais recentes, os mais valiosos na morte do processo.
2658
+ */
2659
+ drainLogs() {
2660
+ this.clearFlushTimer();
2661
+ return this.takeLogs().slice(-MAX_LOGS_PER_PATCH);
2677
2662
  }
2678
- data(key, value) {
2679
- if (key === "attachments") return this;
2680
- this._setData(key, value);
2681
- return this;
2663
+ /**
2664
+ * Linha de log da run com eco no terminal. `image` (opcional) vira o anexo da linha:
2665
+ * o id entra na entrada ANTES de qualquer I/O — é isso que mantém a ordem dos logs
2666
+ * intacta e o handler livre da latência do upload. Devolve o id do anexo (quando houve
2667
+ * imagem e ela não foi descartada) — o `captureException` o leva ao evento da issue.
2668
+ */
2669
+ log(level, message, image3) {
2670
+ if (this.dead) return void 0;
2671
+ const scrubbed = scrubSecrets(safeString(message));
2672
+ const ts = nowISO();
2673
+ const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2674
+ printRunLog(level, attachmentId ? `${scrubbed} [imagem]` : scrubbed);
2675
+ this.push(level, scrubbed, true, attachmentId ? [attachmentId] : void 0, ts);
2676
+ return attachmentId;
2682
2677
  }
2683
- // ── Internos (chamados pelo SDK) ──────────────────────────────────────────
2684
- /** `data` da etapa como vai no wire (undefined quando vazio). */
2685
- get payload() {
2686
- return this._data;
2678
+ /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
2679
+ push(level, message, alreadyScrubbed = false, attachments, ts = nowISO()) {
2680
+ if (this.dead) return ts;
2681
+ const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
2682
+ this.buffer.push({
2683
+ ts,
2684
+ level,
2685
+ message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
2686
+ ...attachments && attachments.length > 0 ? { attachments: attachments.slice(0, WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY) } : {}
2687
+ });
2688
+ if (this.buffer.length >= FLUSH_AT_BUFFERED) {
2689
+ this.flushLogs();
2690
+ } else {
2691
+ this.scheduleFlush();
2692
+ }
2693
+ return ts;
2687
2694
  }
2688
- _setStatus(status, message) {
2689
- this.status = status;
2690
- if (message) this._setData("message", stepMessage(safeString(message)));
2691
- return this;
2695
+ scheduleFlush() {
2696
+ if (this.timer) return;
2697
+ this.timer = setTimeout(() => {
2698
+ this.timer = void 0;
2699
+ this.flushLogs();
2700
+ }, this.flushIntervalMs);
2701
+ unrefTimer(this.timer);
2702
+ }
2703
+ clearFlushTimer() {
2704
+ if (this.timer) {
2705
+ clearTimeout(this.timer);
2706
+ this.timer = void 0;
2707
+ }
2708
+ }
2709
+ takeLogs() {
2710
+ return this.buffer.splice(0, this.buffer.length);
2711
+ }
2712
+ /** Flush incremental: envia o buffer como status 'running' (em chunks). */
2713
+ flushLogs() {
2714
+ this.clearFlushTimer();
2715
+ if (this.dead || this.buffer.length === 0) return;
2716
+ const logs = this.buffer.splice(0, this.buffer.length);
2717
+ for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
2718
+ this.enqueue({ status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) });
2719
+ }
2692
2720
  }
2693
2721
  /**
2694
- * Marca `error` guardando `{ type, message }` em `data.error` e fecha a etapa. Aceita qualquer
2695
- * valor lançado; `message` sobrescreve a mensagem do erro. Numa etapa já fechada não faz nada.
2722
+ * `hobots.flush()`: o bot decide quando entregar o que já fez — etapas raiz finalizadas
2723
+ * viram transactions parciais da run, e itens, logs e progresso pendentes saem já, sem
2724
+ * esperar o throttle. Aguarda a entrega com teto. Nunca lança (pode ir num `finally`).
2696
2725
  */
2697
- _failed(error, message) {
2698
- if (this.endTimestamp) return this;
2699
- this.status = "error";
2700
- const type = error instanceof Error ? error.name : typeof error === "object" && error !== null ? "Error" : void 0;
2701
- const raw = message ?? errorText(error);
2702
- this._setData("error", { ...type ? { type } : {}, message: stepMessage(safeString(raw)) });
2703
- this._close();
2704
- return this;
2726
+ async flush(timeoutMs = ITEMS_DRAIN_MS) {
2727
+ this.tx.flush();
2728
+ this.flushItems();
2729
+ if (this.progressDirty) this.flushProgress();
2730
+ else this.flushLogs();
2731
+ await Promise.race([
2732
+ Promise.all([this.itemsChain, this.chain, this.tx.drain(timeoutMs)]).catch(() => void 0),
2733
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2734
+ ]);
2705
2735
  }
2706
- _setData(key, value) {
2707
- this._data = { ...this._data, [key]: value };
2736
+ /** Primeiro PATCH (claimed → running). Retorna false se a run já morreu (cancelada). */
2737
+ async reportRunning() {
2738
+ this.enqueue({ status: "running" });
2739
+ await this.chain;
2740
+ if (!this.dead) this.startCancelWatch();
2741
+ return !this.dead;
2742
+ }
2743
+ reportSuccess() {
2744
+ this.stopCancelWatch();
2745
+ this.clearFlushTimer();
2746
+ this.forceFinalProgress();
2747
+ this.enqueue({ status: "succeeded", logs: this.takeLogsForTerminal() });
2748
+ }
2749
+ reportFailure(error) {
2750
+ this.stopCancelWatch();
2751
+ this.clearFlushTimer();
2752
+ this.forceFinalProgress();
2753
+ this.enqueue({ status: "failed", error, logs: this.takeLogsForTerminal() });
2708
2754
  }
2709
2755
  /**
2710
- * Dispara o upload pela run (sink do RunReporter) e referencia o anexo em
2711
- * `data.attachments` (chave reservada; teto igual ao das linhas de log). Fora de uma
2712
- * run não há onde pendurar a imagem — descarta com aviso único.
2756
+ * Esvazia o buffer para o PATCH terminal respeitando o teto de 100 logs por PATCH:
2757
+ * o excedente sai antes, em PATCHes `running` na mesma cadeia (a ordem se mantém).
2713
2758
  */
2714
- attachImage(image3) {
2715
- const current = Array.isArray(this._data?.attachments) ? this._data.attachments : [];
2716
- if (current.length >= MAX_ATTACHMENTS_PER_LOG_ENTRY) return;
2717
- const id = this.transaction.attachImage(image3);
2718
- if (!id) return;
2719
- this._setData("attachments", [...current, id]);
2759
+ takeLogsForTerminal() {
2760
+ let logs = this.takeLogs();
2761
+ while (logs.length > MAX_LOGS_PER_PATCH) {
2762
+ this.enqueue({ status: "running", logs: logs.slice(0, MAX_LOGS_PER_PATCH) });
2763
+ logs = logs.slice(MAX_LOGS_PER_PATCH);
2764
+ }
2765
+ return logs;
2720
2766
  }
2721
- /** Motivo registrado (warning/erro) — usado no log de FINISH. */
2722
- reason() {
2723
- const error = this._data?.error;
2724
- const text = typeof error?.message === "string" ? error.message : this._data?.message;
2725
- return typeof text === "string" && text ? text : void 0;
2767
+ // ── Progresso ────────────────────────────────────────────────────────────────
2768
+ /** Valida, clampa e marca o snapshot como pendente; agenda flush só se nada mais vai sair. */
2769
+ setProgress(current, total) {
2770
+ if (this.dead) return;
2771
+ if (total != null && (!Number.isInteger(total) || total < 1)) {
2772
+ if (!this.progressWarned) {
2773
+ this.progressWarned = true;
2774
+ this.logger.warn(`ctx.progress: total inv\xE1lido (${String(total)}) \u2014 precisa ser inteiro >= 1; ignorado`);
2775
+ }
2776
+ return;
2777
+ }
2778
+ const safeCurrent = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : this.progressCurrent;
2779
+ const next = total == null ? safeCurrent : Math.min(safeCurrent, total);
2780
+ const totalChanged = total !== this.progressTotal;
2781
+ if (next === this.progressCurrent && !totalChanged) return;
2782
+ this.progressCurrent = next;
2783
+ this.progressTotal = total;
2784
+ if (total == null) return;
2785
+ const now = Date.now();
2786
+ if (totalChanged || next >= total || now - this.progressLastPrint >= PROGRESS_PRINT_THROTTLE_MS) {
2787
+ this.progressLastPrint = now;
2788
+ printRunProgress(next, total);
2789
+ }
2790
+ this.progressDirty = true;
2791
+ if (this.progressTimer) return;
2792
+ this.progressTimer = setTimeout(() => {
2793
+ this.progressTimer = void 0;
2794
+ this.flushProgress();
2795
+ }, PROGRESS_FLUSH_MS);
2796
+ unrefTimer(this.progressTimer);
2726
2797
  }
2727
- /** Fecha a etapa mantendo o status que estiver. Idempotente. */
2728
- _close() {
2729
- if (this.endTimestamp) return;
2730
- this.endTimestamp = nowISO();
2731
- this.transaction.emit(
2732
- "step",
2733
- "finish",
2734
- stepLabel(this.op, this.description),
2735
- this.startTimestamp,
2736
- this.endTimestamp,
2737
- this.status,
2738
- this.reason()
2739
- );
2798
+ flushProgress() {
2799
+ this.clearProgressTimer();
2800
+ if (this.dead || !this.progressDirty) return;
2801
+ if (this.buffer.length > 0) this.flushLogs();
2802
+ else this.enqueue({ status: "running" });
2803
+ }
2804
+ clearProgressTimer() {
2805
+ if (this.progressTimer) {
2806
+ clearTimeout(this.progressTimer);
2807
+ this.progressTimer = void 0;
2808
+ }
2740
2809
  }
2741
- toJSON() {
2742
- return {
2743
- step_id: this.stepId,
2744
- ...this.parentStepId ? { parent_step_id: this.parentStepId } : {},
2745
- ...this.op ? { op: clip(this.op, MAX_STEP_OP_CHARS) } : {},
2746
- ...this.description ? { description: clip(this.description, MAX_STEP_DESCRIPTION_CHARS) } : {},
2747
- status: this.status,
2748
- start_timestamp: this.startTimestamp,
2749
- timestamp: this.endTimestamp ?? nowISO(),
2750
- ...this._data ? { data: this._data } : {}
2751
- };
2810
+ /** O PATCH terminal sempre leva o snapshot final (o app mostra "87/100" numa run que falhou). */
2811
+ forceFinalProgress() {
2812
+ this.clearProgressTimer();
2813
+ this.progressDirty = this.progressTotal != null;
2752
2814
  }
2753
- };
2754
- var Transaction = class {
2755
- constructor(client, name, op, runId, options = {}) {
2756
- this.client = client;
2757
- this.name = name;
2758
- this.op = op;
2759
- this.runId = runId;
2760
- this.echo = options.echo ?? false;
2761
- this.emit("transaction", "start", op ? `${name} (${op})` : name);
2815
+ /** Consome o snapshot pendente para pegar carona no próximo PATCH. */
2816
+ takeProgress() {
2817
+ if (!this.progressDirty || this.progressTotal == null) return void 0;
2818
+ this.progressDirty = false;
2819
+ return { current: this.progressCurrent, total: this.progressTotal };
2762
2820
  }
2763
- client;
2764
- name;
2765
- op;
2766
- runId;
2767
- eventId = uuid4();
2768
- stepId = stepId();
2769
- startTimestamp = nowISO();
2770
- status = "ok";
2771
- steps = [];
2772
- finished = false;
2773
- lifecycleSink;
2774
- attachmentSink;
2775
- warnedNoAttachmentSink = false;
2776
- // instalado pelo RunReporter: abrir uma etapa é um ponto de parada cooperativo (lança RunCanceled)
2777
- stepGuard;
2778
- // modo local (sem client): o FINISH de cada etapa sai no terminal, como `[hobots:item]`
2779
- echo;
2780
- // eventos emitidos antes do sink existir (o START da transaction sai no construtor,
2781
- // antes do RunReporter conectar) — replay no setLifecycleSink
2782
- lifecycleBacklog = [];
2783
- // ── Lifecycle / eco ───────────────────────────────────────────────────────
2784
2821
  /**
2785
- * Registra o início/fim de uma etapa ou da transaction: espelha a linha no sink de
2786
- * lifecycle, se conectado (é assim que os eventos de performance aparecem nos logs da
2787
- * run no app), e ecoa no terminal com `debug: true` — ou, no modo local, só o FINISH das
2788
- * etapas (`[hobots:step] FINISH …`), para o Studio e o dev verem.
2822
+ * Heartbeat de cancelamento: enquanto o handler roda, um PATCH 'running' vazio
2823
+ * periódico faz o servidor responder 409 se a run foi cancelada no app — o que
2824
+ * mata o reporter e dispara ctx.signal, mesmo em handlers que nunca logam.
2789
2825
  */
2790
- emit(kind, phase, label, startTs, endTs, status, reason) {
2791
- const durationMs = startTs && endTs ? new Date(endTs).getTime() - new Date(startTs).getTime() : void 0;
2792
- const suffix = (durationMs != null ? ` (${durationMs}ms)` : "") + (reason ? `: ${reason}` : "");
2793
- const localFinish = this.echo && kind === "step" && phase === "finish";
2794
- if (this.client?.debug || localFinish) {
2795
- const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
2796
- console.log(`${tag} ${label}${suffix}`);
2797
- }
2798
- const level = phase === "finish" ? levelForStatus(status ?? "ok") : "info";
2799
- const message = `[${kind}] ${phase.toUpperCase()} ${label}${suffix}`;
2800
- if (this.lifecycleSink) this.lifecycleSink(level, message);
2801
- else if (this.lifecycleBacklog.length < MAX_BACKLOG) this.lifecycleBacklog.push([level, message]);
2826
+ startCancelWatch() {
2827
+ if (this.watchTimer) return;
2828
+ this.watchTimer = setInterval(() => {
2829
+ if (this.dead || this.pendingUpdates > 0 || this.buffer.length > 0) return;
2830
+ this.enqueue({ status: "running" });
2831
+ }, this.cancelCheckIntervalMs);
2832
+ unrefTimer(this.watchTimer);
2802
2833
  }
2803
- /** Conecta o espelho de lifecycle (RunReporter) e faz replay do backlog em ordem. */
2804
- setLifecycleSink(sink) {
2805
- this.lifecycleSink = sink;
2806
- const backlog = this.lifecycleBacklog;
2807
- this.lifecycleBacklog = [];
2808
- for (const [level, message] of backlog) sink(level, message);
2834
+ stopCancelWatch() {
2835
+ if (this.watchTimer) {
2836
+ clearInterval(this.watchTimer);
2837
+ this.watchTimer = void 0;
2838
+ }
2809
2839
  }
2810
- /** Conecta o pipeline de anexos da run — é o que dá imagem às etapas. */
2811
- setAttachmentSink(sink) {
2812
- this.attachmentSink = sink;
2840
+ /** Aguarda a fila de PATCHes esvaziar (chame depois do terminal). */
2841
+ finalize() {
2842
+ return this.chain;
2813
2843
  }
2814
- /** Imagem de uma etapa → id do anexo pela run; sem run, descarta com aviso único. */
2815
- attachImage(image3) {
2816
- if (this.attachmentSink) return this.attachmentSink(image3, nowISO());
2817
- if (!this.warnedNoAttachmentSink) {
2818
- this.warnedNoAttachmentSink = true;
2819
- console.warn("[hobots] imagem de etapa descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
2844
+ // ── Itens de execução ────────────────────────────────────────────────────────
2845
+ /**
2846
+ * Bufferiza um item (síncrono — a chamada nunca espera rede). Diferente do PATCH
2847
+ * de status, o servidor aceita itens mesmo com a run cancelada (trabalho já feito),
2848
+ * então um reporter morto não descarta: só um kill() de exit path descarta.
2849
+ */
2850
+ addItem(status, message, payload, id, image3) {
2851
+ if (this.itemsClosed || this.itemsUnsupported) return;
2852
+ let scrubbedPayload;
2853
+ if (payload) {
2854
+ scrubbedPayload = scrubPayloadValue(payload, 0);
2855
+ let serializedLength;
2856
+ try {
2857
+ serializedLength = JSON.stringify(scrubbedPayload).length;
2858
+ } catch {
2859
+ }
2860
+ if (serializedLength === void 0 || serializedLength > MAX_ITEM_PAYLOAD_BYTES) {
2861
+ this.logger.warn(
2862
+ `payload do item descartado (${serializedLength === void 0 ? "n\xE3o serializ\xE1vel" : `${serializedLength} bytes > ${MAX_ITEM_PAYLOAD_BYTES}`}) \u2014 o item foi mantido`
2863
+ );
2864
+ scrubbedPayload = void 0;
2865
+ }
2866
+ }
2867
+ const ts = nowISO();
2868
+ const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2869
+ this.itemsBuffer.push({
2870
+ id: id ? truncate(String(id), 255) : uuid4(),
2871
+ seq: this.itemSeq++,
2872
+ status,
2873
+ ...message ? { message: truncate(scrubSecrets(safeString(message)), MAX_ITEM_MESSAGE_CHARS) } : {},
2874
+ ...scrubbedPayload ? { payload: scrubbedPayload } : {},
2875
+ ts,
2876
+ ...attachmentId ? { attachments: [attachmentId] } : {}
2877
+ });
2878
+ if (this.itemsBuffer.length >= ITEMS_FLUSH_AT_BUFFERED) {
2879
+ this.flushItems();
2880
+ } else {
2881
+ this.scheduleItemsFlush();
2820
2882
  }
2821
- return void 0;
2822
2883
  }
2823
- // ── Etapas ────────────────────────────────────────────────────────────────
2824
2884
  /**
2825
- * Conecta a checagem de cancelamento chamada antes de cada etapa nova (inclusive antes de
2826
- * cada nova tentativa) — é assim que `hobots.step()` para uma run cancelada.
2885
+ * Ids já concluídos (`succeeded`/`occurrence`) neste trabalho — a run e suas reexecuções.
2886
+ * Pagina até o fim. undefined quando a consulta falha (servidor antigo, rede): o `each`
2887
+ * avisa e processa tudo, nunca trava a run por isso.
2827
2888
  */
2828
- setStepGuard(guard) {
2829
- this.stepGuard = guard;
2889
+ async listDoneItemIds() {
2890
+ const ids = /* @__PURE__ */ new Set();
2891
+ let cursor = null;
2892
+ try {
2893
+ do {
2894
+ const page = await this.api.listDoneItems(this.runId, {
2895
+ limit: MAX_ITEMS_DONE_PER_PAGE,
2896
+ ...cursor ? { cursor } : {}
2897
+ });
2898
+ for (const id of page.ids) ids.add(id);
2899
+ cursor = page.next;
2900
+ } while (cursor);
2901
+ return ids;
2902
+ } catch (error) {
2903
+ this.log("warning", `n\xE3o foi poss\xEDvel consultar os itens j\xE1 conclu\xEDdos deste trabalho \u2014 todos ser\xE3o processados (${thrownMessage(error)})`);
2904
+ return void 0;
2905
+ }
2830
2906
  }
2831
- /** A etapa aberta por `step()` (ou `items.each`) no contexto async atual, se houver. */
2832
- currentStep() {
2833
- return currentStep(this);
2907
+ scheduleItemsFlush() {
2908
+ if (this.itemsTimer) return;
2909
+ this.itemsTimer = setTimeout(() => {
2910
+ this.itemsTimer = void 0;
2911
+ this.flushItems();
2912
+ }, this.flushIntervalMs);
2913
+ unrefTimer(this.itemsTimer);
2834
2914
  }
2835
- /** Faz de `step` a etapa corrente durante `fn` (filhas aninham nela). Sem etapa, só roda `fn`. */
2836
- scope(step, fn) {
2837
- if (!step) return fn();
2838
- return runWithStepScope({ transaction: this, step }, fn);
2915
+ clearItemsTimer() {
2916
+ if (this.itemsTimer) {
2917
+ clearTimeout(this.itemsTimer);
2918
+ this.itemsTimer = void 0;
2919
+ }
2839
2920
  }
2840
- /** Abre uma etapa sob a corrente (ou direta da transaction). Interno: o bot usa `step()`. */
2841
- start(op, description) {
2842
- this.stepGuard?.();
2843
- const parent = this.currentStep();
2844
- const step = new Step(this, parent ? parent.stepId : this.stepId, op, description);
2845
- this.steps.push(step);
2846
- this.emit("step", "start", stepLabel(op, description));
2847
- return step;
2921
+ /** Envia o buffer de itens em lotes serializados na cadeia própria. */
2922
+ flushItems() {
2923
+ this.clearItemsTimer();
2924
+ if (this.itemsBuffer.length === 0 || this.itemsUnsupported) return;
2925
+ const items = this.itemsBuffer.splice(0, this.itemsBuffer.length);
2926
+ for (let i = 0; i < items.length; i += MAX_ITEMS_PER_REQUEST) {
2927
+ const batch = items.slice(i, i + MAX_ITEMS_PER_REQUEST);
2928
+ this.itemsChain = this.itemsChain.then(() => this.sendItems(batch));
2929
+ }
2930
+ }
2931
+ async sendItems(batch) {
2932
+ if (this.itemsUnsupported) return;
2933
+ try {
2934
+ await this.api.createItems(this.runId, { items: batch });
2935
+ this.itemsStorageFull = false;
2936
+ } catch (error) {
2937
+ if (error instanceof TasksApiError && error.status === 404) {
2938
+ this.itemsUnsupported = true;
2939
+ this.logger.warn("itens de execu\xE7\xE3o n\xE3o s\xE3o suportados por este servidor Hobots \u2014 descartados");
2940
+ return;
2941
+ }
2942
+ if (error instanceof TasksApiError && error.status === 429) {
2943
+ if (!this.itemsQuotaExceeded) {
2944
+ this.itemsQuotaExceeded = true;
2945
+ this.log("warning", "itens descartados: limite de itens por execu\xE7\xE3o atingido no servidor");
2946
+ }
2947
+ return;
2948
+ }
2949
+ if (error instanceof TasksApiError && error.status === 402) {
2950
+ if (!this.itemsStorageFull) {
2951
+ this.itemsStorageFull = true;
2952
+ this.log("warning", "itens descartados: armazenamento do plano cheio \u2014 libere espa\xE7o ou fa\xE7a upgrade em Plano e cobran\xE7a");
2953
+ }
2954
+ return;
2955
+ }
2956
+ if (error instanceof TasksApiError) {
2957
+ this.logger.error(`lote de ${batch.length} item(ns) rejeitado pelo servidor:`, error);
2958
+ this.log("warning", `${batch.length} item(ns) descartado(s): envio rejeitado pelo servidor (HTTP ${error.status})`);
2959
+ return;
2960
+ }
2961
+ this.itemsBuffer.unshift(...batch);
2962
+ this.scheduleItemsFlush();
2963
+ this.logger.error(`lote de ${batch.length} item(ns) n\xE3o enviado (devolvido ao buffer):`, error);
2964
+ }
2965
+ }
2966
+ /**
2967
+ * Dreno final de itens antes do PATCH terminal (com teto): garante que os
2968
+ * totalizadores estejam completos quando a run vira succeeded/failed. Como o
2969
+ * servidor aceita itens tardios, perder a corrida aqui não perde dados.
2970
+ */
2971
+ async finalizeItems(timeoutMs = ITEMS_DRAIN_MS) {
2972
+ this.flushItems();
2973
+ await Promise.race([
2974
+ this.itemsChain,
2975
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2976
+ ]);
2977
+ if (this.itemsBuffer.length > 0 && !this.itemsUnsupported && !this.itemsQuotaExceeded) {
2978
+ this.flushItems();
2979
+ await Promise.race([
2980
+ this.itemsChain,
2981
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2982
+ ]);
2983
+ }
2984
+ }
2985
+ // ── Anexos ───────────────────────────────────────────────────────────────────
2986
+ /**
2987
+ * Pendura uma imagem num evento da run (log, item ou etapa): faz as checagens que não
2988
+ * dependem de I/O (reporter morto, instalação sem anexos, cota), reserva o id e dispara
2989
+ * o upload em segundo plano. Devolve o id — já referenciável pelo evento — ou undefined
2990
+ * quando a imagem foi descartada (sempre com aviso no log da run). Nunca lança: todo
2991
+ * caminho de erro do upload vira aviso. O hint `attachment` de `hobots.captureException`
2992
+ * também cai aqui via `log()`.
2993
+ */
2994
+ queueAttachment(imageIn, ts) {
2995
+ if (this.dead) return void 0;
2996
+ const image3 = toImageAttachment(imageIn);
2997
+ if (this.attachmentsUnsupported) return void 0;
2998
+ if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
2999
+ if (this.attachmentCount === MAX_ATTACHMENTS_PER_RUN) {
3000
+ this.attachmentCount++;
3001
+ this.log("warning", `imagem descartada: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
3002
+ }
3003
+ return void 0;
3004
+ }
3005
+ this.attachmentCount++;
3006
+ const id = uuid4();
3007
+ const rawCaption = image3.caption !== void 0 ? safeString(image3.caption) : void 0;
3008
+ const caption = rawCaption ? truncate(scrubSecrets(rawCaption), MAX_CAPTION_CHARS) : void 0;
3009
+ const work = this.uploadAttachment(id, image3.input, { caption, logTs: ts, contentType: image3.contentType }).then(() => void 0).catch(() => void 0);
3010
+ this.pendingAttachments.add(work);
3011
+ void work.finally(() => this.pendingAttachments.delete(work));
3012
+ return id;
2848
3013
  }
2849
- /**
2850
- * Registra a etapa `name` executando `fn(etapa)` até dar certo ou esgotar `retries`
2851
- * tentativas extras (default 1), esperando `retryDelayMs` entre elas (default 5 000).
2852
- * Resolve com o retorno de `fn`.
2853
- *
2854
- * Uma etapa só no waterfall, cobrindo todas as tentativas: `data.attempts` quando houve
2855
- * mais de uma; `warning` com o motivo quando a última deu certo; `error` (e a exceção
2856
- * sobe) quando esgotou; `cancelled` em cancelamento/timeout, que nunca repetem.
2857
- */
2858
- async step(name, fn, options = {}) {
2859
- const requested = options.retries ?? DEFAULT_STEP_RETRIES;
2860
- const totalRetries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_STEP_RETRIES;
2861
- const step = this.start(void 0, safeString(name));
2862
- let attempts = 0;
2863
- let lastFailure;
3014
+ /** Retorna o motivo da falha, ou undefined em sucesso. */
3015
+ async uploadAttachment(id, image3, meta) {
3016
+ let bytes;
2864
3017
  try {
2865
- const result = await retryWithBackoff(
2866
- (attempt) => {
2867
- attempts = attempt;
2868
- return this.scope(step, async () => fn(step));
2869
- },
3018
+ bytes = await toBytes(image3);
3019
+ } catch (error) {
3020
+ this.log("warning", `imagem n\xE3o p\xF4de ser lida: ${String(error)}`);
3021
+ return "read-failed";
3022
+ }
3023
+ const contentType = sniffContentType(bytes) ?? meta.contentType;
3024
+ if (!contentType) {
3025
+ this.log("warning", "imagem descartada: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
3026
+ return "unsupported-format";
3027
+ }
3028
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
3029
+ this.log(
3030
+ "warning",
3031
+ `imagem descartada: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
3032
+ );
3033
+ return "too-large";
3034
+ }
3035
+ const dimensions = readPngDimensions(bytes);
3036
+ const release = await this.acquireUploadSlot();
3037
+ try {
3038
+ if (this.abortController.signal.aborted || this.dead) return "canceled";
3039
+ const created = await this.api.createAttachment(
3040
+ this.runId,
2870
3041
  {
2871
- retries: totalRetries,
2872
- delayMs: (attempt) => resolveDelay(options.retryDelayMs, attempt, DEFAULT_STEP_RETRY_DELAY_MS),
2873
- signal: options.signal,
2874
- wait: options.wait,
2875
- onRetry: (error, attempt, delayMs) => {
2876
- lastFailure = { error };
2877
- options.warn?.(
2878
- `etapa "${step.description}": tentativa ${attempt}/${totalRetries + 1} falhou: ${truncate(errorReason(error), 500)} \u2014 nova tentativa em ${delayMs}ms`
2879
- );
2880
- }
2881
- }
3042
+ id,
3043
+ content_type: contentType,
3044
+ size_bytes: bytes.byteLength,
3045
+ log_ts: meta.logTs,
3046
+ ...meta.caption ? { caption: meta.caption } : {},
3047
+ ...dimensions ?? {}
3048
+ },
3049
+ this.abortController.signal
2882
3050
  );
2883
- this.noteAttempts(step, attempts);
2884
- if (lastFailure && step.status === "ok") {
2885
- step._setStatus("warning", `tentativa ${attempts - 1}/${totalRetries + 1} falhou: ${errorReason(lastFailure.error)}`);
2886
- }
2887
- step._close();
2888
- return result;
3051
+ await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
3052
+ await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
3053
+ });
3054
+ return void 0;
2889
3055
  } catch (error) {
2890
- this.noteAttempts(step, attempts);
2891
- if (isTerminalError(error)) step._setStatus("cancelled")._close();
2892
- else step._failed(error);
2893
- throw error;
3056
+ if (this.isUnsupported(error)) {
3057
+ this.attachmentsUnsupported = true;
3058
+ this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
3059
+ return "unsupported";
3060
+ }
3061
+ if (error instanceof TasksApiError && error.status === 429) {
3062
+ this.log("warning", "imagem descartada: limite de anexos por execu\xE7\xE3o atingido no servidor");
3063
+ return "quota";
3064
+ }
3065
+ if (this.abortController.signal.aborted) return "canceled";
3066
+ this.log("warning", `falha ao enviar a imagem: ${String(error)}`);
3067
+ void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
3068
+ });
3069
+ return "upload-failed";
3070
+ } finally {
3071
+ release();
2894
3072
  }
2895
3073
  }
2896
- noteAttempts(step, attempts) {
2897
- if (attempts > 1) step._setData("attempts", attempts);
3074
+ /** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
3075
+ isUnsupported(error) {
3076
+ return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
2898
3077
  }
2899
- // ── Metadados ─────────────────────────────────────────────────────────────
2900
- setStatus(status) {
2901
- this.status = status;
2902
- return this;
3078
+ /** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
3079
+ acquireUploadSlot() {
3080
+ const release = () => {
3081
+ const next = this.uploadQueue.shift();
3082
+ if (next) next();
3083
+ else this.uploadSlots++;
3084
+ };
3085
+ if (this.uploadSlots > 0) {
3086
+ this.uploadSlots--;
3087
+ return Promise.resolve(release);
3088
+ }
3089
+ return new Promise((resolve2) => {
3090
+ this.uploadQueue.push(() => resolve2(release));
3091
+ });
2903
3092
  }
2904
- // ── Encerramento ──────────────────────────────────────────────────────────
2905
3093
  /**
2906
- * Envia já as etapas raiz finalizadas (e todas as descendentes, fechando as que
2907
- * ficaram abertas) como transactions parciais da mesma run (`op` hobots.run.flush,
2908
- * `event_id` novo, até 200 etapas cada) e as tira da memória. Etapas raiz ainda abertas
2909
- * ficam para o próximo flush ou para o `finish()`. É assim que um agente sempre
2910
- * ligado (`start()`), cuja run não termina, entrega as etapas enquanto processa.
2911
- * Devolve quantas etapas foram enviadas (0 sem client, sem nada terminado ou depois do `finish()`).
3094
+ * Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
3095
+ * exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
2912
3096
  */
2913
- flush() {
2914
- if (!this.client || this.finished) return 0;
2915
- const ready = /* @__PURE__ */ new Set();
2916
- for (const step of this.steps) {
2917
- if (step.parentStepId === this.stepId && step.endTimestamp) ready.add(step.stepId);
3097
+ async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
3098
+ if (this.pendingAttachments.size === 0) return;
3099
+ await Promise.race([
3100
+ Promise.allSettled([...this.pendingAttachments]),
3101
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
3102
+ ]);
3103
+ }
3104
+ enqueue(updateIn) {
3105
+ if (this.dead) {
3106
+ this.salvageLogs(updateIn.logs);
3107
+ return;
2918
3108
  }
2919
- if (ready.size === 0) return 0;
2920
- const batch = [];
2921
- const remaining = [];
2922
- for (const step of this.steps) {
2923
- if (ready.has(step.stepId) || ready.has(step.parentStepId)) {
2924
- ready.add(step.stepId);
2925
- batch.push(step);
2926
- } else {
2927
- remaining.push(step);
3109
+ this.clearProgressTimer();
3110
+ const progress = this.takeProgress();
3111
+ const update = progress ? { ...updateIn, progress } : updateIn;
3112
+ this.pendingUpdates++;
3113
+ this.chain = this.chain.then(async () => {
3114
+ if (this.dead) {
3115
+ this.salvageLogs(update.logs);
3116
+ return;
2928
3117
  }
2929
- }
2930
- for (const step of batch) step._close();
2931
- this.steps.splice(0, this.steps.length, ...remaining);
2932
- for (let i = 0; i < batch.length; i += MAX_STEPS) {
2933
- const chunk = batch.slice(i, i + MAX_STEPS);
2934
- const startTimestamp = chunk.reduce((min, step) => step.startTimestamp < min ? step.startTimestamp : min, chunk[0].startTimestamp);
2935
- const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2936
- this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, startTimestamp, nowISO(), chunk));
2937
- }
2938
- return batch.length;
2939
- }
2940
- /** Entrega já (com teto) o que está na fila do transport — as transactions parciais inclusive. */
2941
- async drain(timeoutMs) {
2942
- await this.client?.flush(timeoutMs);
2943
- }
2944
- payload(eventId, op, status, startTimestamp, timestamp, steps) {
2945
- return {
2946
- event_id: eventId,
2947
- name: this.name,
2948
- op,
2949
- status,
2950
- start_timestamp: startTimestamp,
2951
- timestamp,
2952
- steps: steps.map((step) => step.toJSON()),
2953
- // sem tags próprias: o client mescla as do processo
2954
- tags: void 0,
2955
- run_id: this.runId
2956
- };
3118
+ const payload = update.logs && update.logs.length === 0 ? { ...update, logs: void 0 } : update;
3119
+ try {
3120
+ await this.api.updateRun(this.runId, payload);
3121
+ } catch (error) {
3122
+ if (error instanceof TasksApiError && (error.status === 409 || error.status === 404)) {
3123
+ this.dead = true;
3124
+ this.stopCancelWatch();
3125
+ this.abort();
3126
+ const pending = this.buffer.splice(0, this.buffer.length);
3127
+ this.salvageLogs(pending);
3128
+ this.logger.warn(
3129
+ `run ${this.runId} cancelada/finalizada no servidor \u2014 status suprimido` + (pending.length > 0 ? `, ${pending.length} log(s) enviados como log tardio` : "")
3130
+ );
3131
+ return;
3132
+ }
3133
+ if (update.logs && update.logs.length > 0) {
3134
+ this.buffer.unshift(...update.logs);
3135
+ this.scheduleFlush();
3136
+ }
3137
+ if (update.progress && this.progressTotal != null) this.progressDirty = true;
3138
+ this.logger.error(`update da run ${this.runId} perdido:`, error);
3139
+ }
3140
+ }).finally(() => {
3141
+ this.pendingUpdates--;
3142
+ });
2957
3143
  }
2958
- finish() {
2959
- if (this.finished) return;
2960
- this.finished = true;
2961
- for (const step of this.steps) step._close();
2962
- const endTimestamp = nowISO();
2963
- this.emit(
2964
- "transaction",
2965
- "finish",
2966
- this.op ? `${this.name} (${this.op})` : this.name,
2967
- this.startTimestamp,
2968
- endTimestamp,
2969
- this.status
2970
- );
2971
- if (!this.client) return;
2972
- const steps = this.steps;
2973
- let first = 0;
2974
- while (steps.length - first > MAX_STEPS) {
2975
- const chunk = steps.slice(first, first + MAX_STEPS);
2976
- const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2977
- this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, chunk[0].startTimestamp, endTimestamp, chunk));
2978
- first += MAX_STEPS;
3144
+ /**
3145
+ * Best-effort de log tardio: envia logs para uma run já terminal, fora da fila.
3146
+ * O servidor anexa os logs mesmo respondendo 409; erros são engolidos — este é
3147
+ * o último recurso, não um canal confiável.
3148
+ */
3149
+ salvageLogs(logs) {
3150
+ if (!logs || logs.length === 0) return;
3151
+ for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
3152
+ void this.api.updateRunOnce(this.runId, { status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) }).catch(() => {
3153
+ });
2979
3154
  }
2980
- this.client.sendTransaction(this.payload(this.eventId, this.op, this.status, this.startTimestamp, endTimestamp, steps.slice(first)));
2981
3155
  }
2982
3156
  };
2983
3157
 
@@ -3415,6 +3589,10 @@ var LocalRunTransport = class {
3415
3589
  }
3416
3590
  return { accepted: request.items.length, duplicates: 0 };
3417
3591
  }
3592
+ // modo local não tem trabalho anterior: nada a pular
3593
+ async listDoneItems() {
3594
+ return { ids: [], next: null };
3595
+ }
3418
3596
  /** Totais por status — chame uma vez, no fim da run, antes da linha de conclusão. */
3419
3597
  printSummary() {
3420
3598
  const { succeeded, occurrence, failed } = this.counts;
@@ -3936,7 +4114,13 @@ var fallbackEachSink = {
3936
4114
  advance: noop,
3937
4115
  checkCanceled: noop,
3938
4116
  isAborted: () => false,
4117
+ /* v8 ignore start -- fora de uma run não há consulta de concluídos, logo nunca há item pulado para anunciar */
4118
+ logInfo: (message) => printRunLog("info", message),
4119
+ /* v8 ignore stop */
4120
+ logWarning: (message) => printRunLog("warning", message),
3939
4121
  logError: (message) => printRunLog("error", message),
4122
+ sleep,
4123
+ doneItemIds: () => Promise.resolve(void 0),
3940
4124
  flush: () => Promise.resolve()
3941
4125
  };
3942
4126
  var fallbackItems = {