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