hobots 4.5.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -44,7 +44,7 @@ module.exports = __toCommonJS(src_exports);
44
44
 
45
45
  // src/utils.ts
46
46
  var SDK_NAME = "hobots";
47
- var SDK_VERSION = true ? "4.5.0" : "0.0.0-dev";
47
+ var SDK_VERSION = true ? "5.0.0" : "0.0.0-dev";
48
48
  function isNode() {
49
49
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
50
50
  }
@@ -97,8 +97,13 @@ function abortableSleep(ms, signal) {
97
97
  function jitter(baseMs) {
98
98
  return Math.floor(baseMs / 2 + Math.random() * (baseMs / 2));
99
99
  }
100
+ var TRUNCATION_SUFFIX = "\u2026 [truncado]";
100
101
  function truncate(value, max) {
101
- return value.length > max ? `${value.slice(0, max)}\u2026 [truncado]` : value;
102
+ return value.length > max ? `${value.slice(0, max)}${TRUNCATION_SUFFIX}` : value;
103
+ }
104
+ function clip(value, max) {
105
+ if (value.length <= max) return value;
106
+ return `${value.slice(0, Math.max(0, max - TRUNCATION_SUFFIX.length))}${TRUNCATION_SUFFIX}`;
102
107
  }
103
108
  function safeString(value) {
104
109
  if (typeof value === "string") return value;
@@ -196,6 +201,8 @@ function colorize(text, color) {
196
201
  var INSTANCE_ID_ENV = "HOBOTS_INSTANCE_ID";
197
202
  var ENVIRONMENT_ENV = "HOBOTS_ENVIRONMENT";
198
203
  var RUN_ID_ENV = "HOBOTS_RUN_ID";
204
+ var LOCAL_RUN_ENV = "HOBOTS_LOCAL_RUN";
205
+ var RUN_PARAMS_ENV = "HOBOTS_RUN_PARAMS";
199
206
  var dotenvLoaded = false;
200
207
  function loadDotenv() {
201
208
  if (dotenvLoaded) return;
@@ -221,6 +228,24 @@ function resolveRunId(option) {
221
228
  if (typeof process === "undefined" || !process.env) return void 0;
222
229
  return process.env[RUN_ID_ENV]?.trim() || void 0;
223
230
  }
231
+ function isLocalRun() {
232
+ if (typeof process === "undefined" || !process.env) return false;
233
+ return process.env[LOCAL_RUN_ENV]?.trim() === "1";
234
+ }
235
+ function resolveLocalRunParams() {
236
+ const raw = process.env[RUN_PARAMS_ENV]?.trim();
237
+ if (!raw) return {};
238
+ let parsed;
239
+ try {
240
+ parsed = JSON.parse(raw);
241
+ } catch {
242
+ throw new Error(`${RUN_PARAMS_ENV} inv\xE1lido: n\xE3o \xE9 JSON v\xE1lido`);
243
+ }
244
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
245
+ throw new Error(`${RUN_PARAMS_ENV} inv\xE1lido: esperado um objeto JSON`);
246
+ }
247
+ return parsed;
248
+ }
224
249
  function resolveEnvironmentOption(option) {
225
250
  const fromOption = option?.trim();
226
251
  if (fromOption) return fromOption;
@@ -460,9 +485,18 @@ var MAX_FINGERPRINT_ITEMS = 10;
460
485
  var MAX_FINGERPRINT_ITEM_CHARS = 255;
461
486
  var MAX_TRANSACTION_NAME_CHARS = 500;
462
487
  var MAX_STEPS = 200;
488
+ var MAX_STEP_DESCRIPTION_CHARS = 2048;
489
+ var MAX_STEP_OP_CHARS = 255;
463
490
  function clipTags(tags) {
464
491
  return Object.fromEntries(Object.entries(tags).map(([key, value]) => [key, truncate(String(value), MAX_TAG_VALUE_CHARS)]));
465
492
  }
493
+ function clipStep(step) {
494
+ return {
495
+ ...step,
496
+ ...step.op ? { op: clip(step.op, MAX_STEP_OP_CHARS) } : {},
497
+ ...step.description ? { description: clip(step.description, MAX_STEP_DESCRIPTION_CHARS) } : {}
498
+ };
499
+ }
466
500
  function clipFingerprint(fingerprint) {
467
501
  if (fingerprint === void 0) return void 0;
468
502
  return fingerprint.slice(0, MAX_FINGERPRINT_ITEMS).map((item) => truncate(String(item), MAX_FINGERPRINT_ITEM_CHARS));
@@ -521,12 +555,13 @@ var Client = class {
521
555
  [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
522
556
  return `Client { baseUrl: '${this.#config.baseUrl}' }`;
523
557
  }
524
- captureException(error, hint) {
558
+ /** `contexts` é interno ao SDK (ex.: `contexts.run` com o print do captureException) — o evento ainda pode sobrescrever. */
559
+ captureException(error, hint, contexts) {
525
560
  if (typeof error === "object" && error !== null) captured.add(error);
526
- return this.captureEvent(eventFromException(error, hint?.type ?? "error"), hint);
561
+ return this.captureEvent(eventFromException(error, hint?.type ?? "error"), hint, contexts);
527
562
  }
528
- captureEvent(event, hint) {
529
- const final = this.prepareEvent(event, hint);
563
+ captureEvent(event, hint, contexts) {
564
+ const final = this.prepareEvent(contexts ? { ...event, contexts: { ...contexts, ...event.contexts } } : event, hint);
530
565
  this.transport.enqueue({ type: "event", payload: final });
531
566
  return final.event_id;
532
567
  }
@@ -549,14 +584,15 @@ var Client = class {
549
584
  sendTransaction(payload) {
550
585
  let steps = payload.steps;
551
586
  if (steps.length > MAX_STEPS) {
552
- this.logger.warn(`transaction com ${steps.length} steps \u2014 enviando as primeiras ${MAX_STEPS}`);
587
+ this.logger.warn(`transaction com ${steps.length} etapas \u2014 enviando as primeiras ${MAX_STEPS}`);
553
588
  steps = steps.slice(0, MAX_STEPS);
554
589
  }
590
+ steps = steps.map(clipStep);
555
591
  this.transport.enqueue({
556
592
  type: "transaction",
557
593
  payload: {
558
594
  ...payload,
559
- name: truncate(payload.name || "(sem nome)", MAX_TRANSACTION_NAME_CHARS),
595
+ name: clip(payload.name || "(sem nome)", MAX_TRANSACTION_NAME_CHARS),
560
596
  steps,
561
597
  release: payload.release ?? this.options.release,
562
598
  environment: this.resolveEnvironment(payload.environment),
@@ -1356,11 +1392,11 @@ var ItemHandle = class {
1356
1392
  }
1357
1393
  failed(message, payload, options) {
1358
1394
  this.record("failed", message, payload, options?.image);
1359
- if (this.step && this.step.status !== "error") this.step.setStatus("error", message);
1395
+ if (this.step && this.step.status !== "error") this.step._setStatus("error", message);
1360
1396
  }
1361
1397
  occurrence(message, payload, options) {
1362
1398
  this.record("occurrence", message, payload, options?.image);
1363
- if (this.step && this.step.status === "ok") this.step.setStatus("warning", message);
1399
+ if (this.step && this.step.status === "ok") this.step._setStatus("warning", message);
1364
1400
  }
1365
1401
  record(status, message, payload, image3) {
1366
1402
  this.reported = true;
@@ -1391,23 +1427,24 @@ async function eachItem(list, handler, options, sink) {
1391
1427
  const step = sink.startStep(options.op ?? "item", description);
1392
1428
  const handle = new ItemHandle(index, id, step, options.payload?.(element, index), sink);
1393
1429
  try {
1394
- await handler(element, handle, index);
1430
+ await sink.stepScope(step, async () => handler(element, handle, index));
1395
1431
  if (!handle.reported) handle.succeeded();
1396
1432
  } catch (error) {
1397
1433
  if (isTerminalError(error) || sink.isAborted()) {
1398
- step?.setStatus("cancelled").succeeded();
1434
+ step?._setStatus("cancelled")._close();
1399
1435
  throw error;
1400
1436
  }
1401
1437
  const message = thrownMessage(error);
1402
1438
  if (!handle.reported) handle.failed(message);
1403
- step?.failed(error);
1439
+ step?._failed(error);
1404
1440
  sink.logError(`item ${description} falhou: ${message}`);
1405
1441
  }
1406
- step?.succeeded();
1442
+ step?._close();
1407
1443
  if (options.progress !== false) sink.advance();
1408
1444
  result.total += 1;
1409
1445
  result[handle.status ?? "succeeded"] += 1;
1410
1446
  index += 1;
1447
+ if (options.flush !== false) await sink.flush();
1411
1448
  }
1412
1449
  return result;
1413
1450
  }
@@ -1447,6 +1484,11 @@ function isInsideDir(root, candidate, sep) {
1447
1484
  function unavailableLocal(ref) {
1448
1485
  return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
1449
1486
  }
1487
+ function localRunPath(ref) {
1488
+ if (!isLocalRun()) return void 0;
1489
+ const path = ref.path;
1490
+ return typeof path === "string" && path.trim() !== "" ? path : void 0;
1491
+ }
1450
1492
  function errorCode(error) {
1451
1493
  return error?.code;
1452
1494
  }
@@ -1614,8 +1656,16 @@ var RunFileStore = class {
1614
1656
  this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
1615
1657
  }
1616
1658
  }
1617
- /** storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de origem. */
1659
+ /**
1660
+ * storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de
1661
+ * origem. No modo local, um FileRef com `path` copia direto desse caminho, sem apagar a origem.
1662
+ */
1618
1663
  async copyLocal(ref, target) {
1664
+ const direct = localRunPath(ref);
1665
+ if (direct !== void 0) {
1666
+ await this.copyFromPath(ref, direct, target);
1667
+ return;
1668
+ }
1619
1669
  const base = process.env[LOCAL_FILES_DIR_ENV];
1620
1670
  if (!base) throw unavailableLocal(ref);
1621
1671
  const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
@@ -1649,6 +1699,26 @@ var RunFileStore = class {
1649
1699
  }
1650
1700
  await rm(folder, { recursive: true, force: true });
1651
1701
  }
1702
+ /**
1703
+ * Modo local: cópia do arquivo apontado por `path` (teste roda várias vezes — a fonte fica).
1704
+ * Sem conferência de tamanho: o `size` do FileRef é informativo aqui.
1705
+ */
1706
+ async copyFromPath(ref, path, target) {
1707
+ const { copyFile, rename, rm, stat } = await import("fs/promises");
1708
+ const { resolve: resolve2 } = await import("path");
1709
+ const source = resolve2(path);
1710
+ const info = await stat(source).catch(() => void 0);
1711
+ if (!info?.isFile()) throw new Error(`arquivo "${ref.name}" n\xE3o encontrado em ${path}`);
1712
+ this.checkCanceled();
1713
+ const part = `${target}.part`;
1714
+ try {
1715
+ await copyFile(source, part);
1716
+ await rename(part, target);
1717
+ } catch (error) {
1718
+ await rm(part, { force: true });
1719
+ throw error;
1720
+ }
1721
+ }
1652
1722
  translate(error, ref) {
1653
1723
  if (this.deps.signal.aborted) return runCanceledError();
1654
1724
  if (error instanceof TasksApiError && error.status === 410) {
@@ -1777,6 +1847,11 @@ function printRunLog(level, message) {
1777
1847
  const method2 = level === "warning" ? "warn" : level === "success" ? "info" : level;
1778
1848
  console[method2](line, message);
1779
1849
  }
1850
+ var PROGRESS_LINE_PREFIX = "[hobots:progress]";
1851
+ var PROGRESS_PRINT_THROTTLE_MS = 250;
1852
+ function printRunProgress(current, total) {
1853
+ console.log(`${PROGRESS_LINE_PREFIX} ${current}/${total}`);
1854
+ }
1780
1855
  function cancelableSleep(ms, signal) {
1781
1856
  return new Promise((resolve2, reject) => {
1782
1857
  if (signal.aborted) {
@@ -1846,13 +1921,15 @@ var RunReporter = class {
1846
1921
  const eachSink = {
1847
1922
  report: (status, message, payload, id, image3) => this.addItem(status, message, payload, id, image3),
1848
1923
  startStep: (op, description) => tx.start(op, description),
1924
+ stepScope: (step, fn) => tx.scope(step, fn),
1849
1925
  declareTotal: (total) => {
1850
1926
  if (this.progressTotal == null) this.setProgress(this.progressCurrent, total);
1851
1927
  },
1852
1928
  advance: () => this.setProgress(this.progressCurrent + 1, this.progressTotal),
1853
1929
  checkCanceled,
1854
1930
  isAborted: () => this.abortController.signal.aborted,
1855
- logError: (message) => this.log("error", message)
1931
+ logError: (message) => this.log("error", message),
1932
+ flush: () => this.flush()
1856
1933
  };
1857
1934
  const items = {
1858
1935
  add: (item) => {
@@ -1907,7 +1984,14 @@ var RunReporter = class {
1907
1984
  items,
1908
1985
  progress,
1909
1986
  files: this.fileStore.files,
1910
- transaction: tx,
1987
+ step: async (name, fn, options) => {
1988
+ const root = tx.currentStep() === void 0;
1989
+ try {
1990
+ return await tx.step(name, fn, { ...options, signal, wait: sleep2, warn: (message) => this.log("warning", message) });
1991
+ } finally {
1992
+ if (options?.flush !== false && root && !signal.aborted) await this.flush();
1993
+ }
1994
+ },
1911
1995
  flush: (timeoutMs) => this.flush(timeoutMs),
1912
1996
  signal,
1913
1997
  throwIfCanceled: checkCanceled,
@@ -1970,6 +2054,8 @@ var RunReporter = class {
1970
2054
  progressDirty = false;
1971
2055
  progressTimer;
1972
2056
  progressWarned = false;
2057
+ /** Relógio do último eco `[hobots:progress]` no terminal (throttle do eco, não do wire). */
2058
+ progressLastPrint = 0;
1973
2059
  /** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
1974
2060
  fileStore;
1975
2061
  /** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
@@ -2020,15 +2106,17 @@ var RunReporter = class {
2020
2106
  /**
2021
2107
  * Linha de log da run com eco no terminal. `image` (opcional) vira o anexo da linha:
2022
2108
  * o id entra na entrada ANTES de qualquer I/O — é isso que mantém a ordem dos logs
2023
- * intacta e o handler livre da latência do upload.
2109
+ * intacta e o handler livre da latência do upload. Devolve o id do anexo (quando houve
2110
+ * imagem e ela não foi descartada) — o `captureException` o leva ao evento da issue.
2024
2111
  */
2025
2112
  log(level, message, image3) {
2026
- if (this.dead) return;
2113
+ if (this.dead) return void 0;
2027
2114
  const scrubbed = scrubSecrets(safeString(message));
2028
2115
  const ts = nowISO();
2029
2116
  const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2030
2117
  printRunLog(level, attachmentId ? `${scrubbed} [imagem]` : scrubbed);
2031
2118
  this.push(level, scrubbed, true, attachmentId ? [attachmentId] : void 0, ts);
2119
+ return attachmentId;
2032
2120
  }
2033
2121
  /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
2034
2122
  push(level, message, alreadyScrubbed = false, attachments, ts = nowISO()) {
@@ -2074,7 +2162,7 @@ var RunReporter = class {
2074
2162
  }
2075
2163
  }
2076
2164
  /**
2077
- * `hobots.flush()`: o bot decide quando entregar o que já fez — steps raiz finalizadas
2165
+ * `hobots.flush()`: o bot decide quando entregar o que já fez — etapas raiz finalizadas
2078
2166
  * viram transactions parciais da run, e itens, logs e progresso pendentes saem já, sem
2079
2167
  * esperar o throttle. Aguarda a entrega com teto. Nunca lança (pode ir num `finally`).
2080
2168
  */
@@ -2132,10 +2220,16 @@ var RunReporter = class {
2132
2220
  }
2133
2221
  const safeCurrent = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : this.progressCurrent;
2134
2222
  const next = total == null ? safeCurrent : Math.min(safeCurrent, total);
2135
- if (next === this.progressCurrent && total === this.progressTotal) return;
2223
+ const totalChanged = total !== this.progressTotal;
2224
+ if (next === this.progressCurrent && !totalChanged) return;
2136
2225
  this.progressCurrent = next;
2137
2226
  this.progressTotal = total;
2138
2227
  if (total == null) return;
2228
+ const now = Date.now();
2229
+ if (totalChanged || next >= total || now - this.progressLastPrint >= PROGRESS_PRINT_THROTTLE_MS) {
2230
+ this.progressLastPrint = now;
2231
+ printRunProgress(next, total);
2232
+ }
2139
2233
  this.progressDirty = true;
2140
2234
  if (this.progressTimer) return;
2141
2235
  this.progressTimer = setTimeout(() => {
@@ -2310,7 +2404,7 @@ var RunReporter = class {
2310
2404
  }
2311
2405
  // ── Anexos ───────────────────────────────────────────────────────────────────
2312
2406
  /**
2313
- * Pendura uma imagem num evento da run (log, item ou step): faz as checagens que não
2407
+ * Pendura uma imagem num evento da run (log, item ou etapa): faz as checagens que não
2314
2408
  * dependem de I/O (reporter morto, instalação sem anexos, cota), reserva o id e dispara
2315
2409
  * o upload em segundo plano. Devolve o id — já referenciável pelo evento — ou undefined
2316
2410
  * quando a imagem foi descartada (sempre com aviso no log da run). Nunca lança: todo
@@ -2483,11 +2577,13 @@ var RunReporter = class {
2483
2577
 
2484
2578
  // src/tasks/run-context-store.ts
2485
2579
  var storage;
2580
+ var stepStorage;
2486
2581
  var initPromise;
2487
2582
  async function ensureStorage() {
2488
2583
  if (storage || !isNode()) return;
2489
2584
  initPromise ??= import("async_hooks").then(({ AsyncLocalStorage: ALS }) => {
2490
2585
  storage = new ALS();
2586
+ stepStorage = new ALS();
2491
2587
  });
2492
2588
  await initPromise;
2493
2589
  }
@@ -2498,12 +2594,23 @@ async function runWithRunContext(store, fn) {
2498
2594
  function currentRunReporter() {
2499
2595
  return storage?.getStore()?.reporter;
2500
2596
  }
2597
+ async function runWithStepScope(scope, fn) {
2598
+ await ensureStorage();
2599
+ return stepStorage ? stepStorage.run(scope, fn) : fn();
2600
+ }
2601
+ function currentStep(transaction) {
2602
+ const scope = stepStorage?.getStore();
2603
+ return scope?.transaction === transaction ? scope.step : void 0;
2604
+ }
2501
2605
 
2502
2606
  // src/transaction.ts
2503
2607
  var FLUSH_TRANSACTION_OP = "hobots.run.flush";
2608
+ var DEFAULT_STEP_RETRIES = 1;
2609
+ var DEFAULT_STEP_RETRY_DELAY_MS = 5e3;
2504
2610
  var MAX_STEP_MESSAGE_CHARS = 2e3;
2611
+ var MAX_BACKLOG = 100;
2505
2612
  function stepMessage(text) {
2506
- return truncate(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
2613
+ return clip(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
2507
2614
  }
2508
2615
  function stepId() {
2509
2616
  return uuid4().slice(0, 16);
@@ -2511,6 +2618,13 @@ function stepId() {
2511
2618
  function stepLabel(op, description) {
2512
2619
  return [op, description].filter(Boolean).join(" \u2014 ") || "(sem nome)";
2513
2620
  }
2621
+ function errorText(error) {
2622
+ if (error instanceof Error) return error.message || error.name;
2623
+ return safeString(error);
2624
+ }
2625
+ function errorReason(error) {
2626
+ return error instanceof Error ? `${error.name}: ${errorText(error)}` : errorText(error);
2627
+ }
2514
2628
  function levelForStatus(status) {
2515
2629
  switch (status) {
2516
2630
  case "warning":
@@ -2550,69 +2664,68 @@ var Step = class {
2550
2664
  startTimestamp = nowISO();
2551
2665
  status = "ok";
2552
2666
  endTimestamp;
2553
- data;
2554
- /**
2555
- * Desfecho da step. `message` (opcional) é o motivo legível — vai em `data.message`
2556
- * e aparece na Central (performance e banner da execução) e no log de FINISH.
2557
- * `options.image` pendura um print do estado da tela em `data.attachments`.
2558
- */
2559
- setStatus(status, message, options) {
2560
- this.status = status;
2561
- if (message) this.setData("message", stepMessage(message));
2667
+ _data;
2668
+ // ── API do bot ────────────────────────────────────────────────────────────
2669
+ warning(message, options) {
2670
+ if (this.status !== "error") this._setStatus("warning", message);
2562
2671
  if (options?.image !== void 0) this.attachImage(options.image);
2563
2672
  return this;
2564
2673
  }
2674
+ image(image3) {
2675
+ this.attachImage(image3);
2676
+ return this;
2677
+ }
2678
+ data(key, value) {
2679
+ if (key === "attachments") return this;
2680
+ this._setData(key, value);
2681
+ return this;
2682
+ }
2683
+ // ── Internos (chamados pelo SDK) ──────────────────────────────────────────
2684
+ /** `data` da etapa como vai no wire (undefined quando vazio). */
2685
+ get payload() {
2686
+ return this._data;
2687
+ }
2688
+ _setStatus(status, message) {
2689
+ this.status = status;
2690
+ if (message) this._setData("message", stepMessage(safeString(message)));
2691
+ return this;
2692
+ }
2565
2693
  /**
2566
- * Marca a step como `error` guardando o motivo em `data.error` ({ type, message }) e a
2567
- * fecha. Aceita qualquer valor lançado; `message` sobrescreve a mensagem do erro.
2568
- * `options.image` pendura um print do estado da tela em `data.attachments`.
2569
- * Numa step já fechada não faz nada.
2694
+ * Marca `error` guardando `{ type, message }` em `data.error` e fecha a etapa. Aceita qualquer
2695
+ * valor lançado; `message` sobrescreve a mensagem do erro. Numa etapa já fechada não faz nada.
2570
2696
  */
2571
- failed(error, message, options) {
2697
+ _failed(error, message) {
2572
2698
  if (this.endTimestamp) return this;
2573
2699
  this.status = "error";
2574
2700
  const type = error instanceof Error ? error.name : typeof error === "object" && error !== null ? "Error" : void 0;
2575
- const raw = message ?? (error instanceof Error ? error.message || error.name : String(error));
2576
- this.setData("error", { ...type ? { type } : {}, message: stepMessage(raw) });
2577
- if (options?.image !== void 0) this.attachImage(options.image);
2578
- this.close();
2701
+ const raw = message ?? errorText(error);
2702
+ this._setData("error", { ...type ? { type } : {}, message: stepMessage(safeString(raw)) });
2703
+ this._close();
2579
2704
  return this;
2580
2705
  }
2706
+ _setData(key, value) {
2707
+ this._data = { ...this._data, [key]: value };
2708
+ }
2581
2709
  /**
2582
2710
  * Dispara o upload pela run (sink do RunReporter) e referencia o anexo em
2583
2711
  * `data.attachments` (chave reservada; teto igual ao das linhas de log). Fora de uma
2584
2712
  * run não há onde pendurar a imagem — descarta com aviso único.
2585
2713
  */
2586
2714
  attachImage(image3) {
2587
- const current = Array.isArray(this.data?.attachments) ? this.data.attachments : [];
2715
+ const current = Array.isArray(this._data?.attachments) ? this._data.attachments : [];
2588
2716
  if (current.length >= MAX_ATTACHMENTS_PER_LOG_ENTRY) return;
2589
2717
  const id = this.transaction.attachImage(image3);
2590
2718
  if (!id) return;
2591
- this.setData("attachments", [...current, id]);
2719
+ this._setData("attachments", [...current, id]);
2592
2720
  }
2593
- /** Motivo registrado via setStatus(…, message) ou failed — usado no log de FINISH. */
2721
+ /** Motivo registrado (warning/erro) — usado no log de FINISH. */
2594
2722
  reason() {
2595
- const error = this.data?.error;
2596
- const text = typeof error?.message === "string" ? error.message : this.data?.message;
2723
+ const error = this._data?.error;
2724
+ const text = typeof error?.message === "string" ? error.message : this._data?.message;
2597
2725
  return typeof text === "string" && text ? text : void 0;
2598
2726
  }
2599
- setData(key, value) {
2600
- this.data = { ...this.data, [key]: value };
2601
- return this;
2602
- }
2603
- /** Step aninhada (filha desta step). */
2604
- start(op, description) {
2605
- return this.transaction.createStep(this.stepId, op, description);
2606
- }
2607
- /**
2608
- * Fecha a step. O status fica o que estiver (`ok` por padrão, ou o de `setStatus`).
2609
- * Idempotente; a transaction fecha no fim da run o que ficou aberto.
2610
- */
2611
- succeeded() {
2612
- this.close();
2613
- return this;
2614
- }
2615
- close() {
2727
+ /** Fecha a etapa mantendo o status que estiver. Idempotente. */
2728
+ _close() {
2616
2729
  if (this.endTimestamp) return;
2617
2730
  this.endTimestamp = nowISO();
2618
2731
  this.transaction.emit(
@@ -2628,22 +2741,23 @@ var Step = class {
2628
2741
  toJSON() {
2629
2742
  return {
2630
2743
  step_id: this.stepId,
2631
- parent_step_id: this.parentStepId,
2632
- op: this.op,
2633
- description: this.description,
2744
+ ...this.parentStepId ? { parent_step_id: this.parentStepId } : {},
2745
+ ...this.op ? { op: clip(this.op, MAX_STEP_OP_CHARS) } : {},
2746
+ ...this.description ? { description: clip(this.description, MAX_STEP_DESCRIPTION_CHARS) } : {},
2634
2747
  status: this.status,
2635
2748
  start_timestamp: this.startTimestamp,
2636
2749
  timestamp: this.endTimestamp ?? nowISO(),
2637
- data: this.data
2750
+ ...this._data ? { data: this._data } : {}
2638
2751
  };
2639
2752
  }
2640
2753
  };
2641
2754
  var Transaction = class {
2642
- constructor(client, name, op, runId) {
2755
+ constructor(client, name, op, runId, options = {}) {
2643
2756
  this.client = client;
2644
2757
  this.name = name;
2645
2758
  this.op = op;
2646
2759
  this.runId = runId;
2760
+ this.echo = options.echo ?? false;
2647
2761
  this.emit("transaction", "start", op ? `${name} (${op})` : name);
2648
2762
  }
2649
2763
  client;
@@ -2655,33 +2769,36 @@ var Transaction = class {
2655
2769
  startTimestamp = nowISO();
2656
2770
  status = "ok";
2657
2771
  steps = [];
2658
- tags = {};
2659
2772
  finished = false;
2660
2773
  lifecycleSink;
2661
2774
  attachmentSink;
2662
2775
  warnedNoAttachmentSink = false;
2663
- // instalado pelo RunReporter: abrir uma step é um ponto de parada cooperativo (lança RunCanceled)
2776
+ // instalado pelo RunReporter: abrir uma etapa é um ponto de parada cooperativo (lança RunCanceled)
2664
2777
  stepGuard;
2778
+ // modo local (sem client): o FINISH de cada etapa sai no terminal, como `[hobots:item]`
2779
+ echo;
2665
2780
  // eventos emitidos antes do sink existir (o START da transaction sai no construtor,
2666
2781
  // antes do RunReporter conectar) — replay no setLifecycleSink
2667
2782
  lifecycleBacklog = [];
2783
+ // ── Lifecycle / eco ───────────────────────────────────────────────────────
2668
2784
  /**
2669
- * Registra o início/fim de uma step ou transaction: espelha a linha no sink de
2670
- * lifecycle, se conectado (é assim que os eventos de performance aparecem nos
2671
- * logs da run no app), e ecoa no terminal apenas com `debug: true` — o SDK não
2672
- * escreve no stdout do processo do usuário sem permissão.
2785
+ * Registra o início/fim de uma etapa ou da transaction: espelha a linha no sink de
2786
+ * lifecycle, se conectado (é assim que os eventos de performance aparecem nos logs da
2787
+ * run no app), e ecoa no terminal com `debug: true` — ou, no modo local, só o FINISH das
2788
+ * etapas (`[hobots:step] FINISH …`), para o Studio e o dev verem.
2673
2789
  */
2674
2790
  emit(kind, phase, label, startTs, endTs, status, reason) {
2675
2791
  const durationMs = startTs && endTs ? new Date(endTs).getTime() - new Date(startTs).getTime() : void 0;
2676
2792
  const suffix = (durationMs != null ? ` (${durationMs}ms)` : "") + (reason ? `: ${reason}` : "");
2677
- if (this.client?.debug) {
2793
+ const localFinish = this.echo && kind === "step" && phase === "finish";
2794
+ if (this.client?.debug || localFinish) {
2678
2795
  const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
2679
2796
  console.log(`${tag} ${label}${suffix}`);
2680
2797
  }
2681
2798
  const level = phase === "finish" ? levelForStatus(status ?? "ok") : "info";
2682
2799
  const message = `[${kind}] ${phase.toUpperCase()} ${label}${suffix}`;
2683
2800
  if (this.lifecycleSink) this.lifecycleSink(level, message);
2684
- else if (this.lifecycleBacklog.length < 100) this.lifecycleBacklog.push([level, message]);
2801
+ else if (this.lifecycleBacklog.length < MAX_BACKLOG) this.lifecycleBacklog.push([level, message]);
2685
2802
  }
2686
2803
  /** Conecta o espelho de lifecycle (RunReporter) e faz replay do backlog em ordem. */
2687
2804
  setLifecycleSink(sink) {
@@ -2690,81 +2807,111 @@ var Transaction = class {
2690
2807
  this.lifecycleBacklog = [];
2691
2808
  for (const [level, message] of backlog) sink(level, message);
2692
2809
  }
2693
- /** Conecta o pipeline de anexos da run — é o que dá imagem às steps. */
2810
+ /** Conecta o pipeline de anexos da run — é o que dá imagem às etapas. */
2694
2811
  setAttachmentSink(sink) {
2695
2812
  this.attachmentSink = sink;
2696
2813
  }
2697
- /** Imagem de uma step → id do anexo pela run; sem run, descarta com aviso único. */
2814
+ /** Imagem de uma etapa → id do anexo pela run; sem run, descarta com aviso único. */
2698
2815
  attachImage(image3) {
2699
2816
  if (this.attachmentSink) return this.attachmentSink(image3, nowISO());
2700
2817
  if (!this.warnedNoAttachmentSink) {
2701
2818
  this.warnedNoAttachmentSink = true;
2702
- console.warn("[hobots] imagem de step descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
2819
+ console.warn("[hobots] imagem de etapa descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
2703
2820
  }
2704
2821
  return void 0;
2705
2822
  }
2823
+ // ── Etapas ────────────────────────────────────────────────────────────────
2706
2824
  /**
2707
- * Conecta a checagem de cancelamento chamada antes de cada step nova — é assim que
2708
- * `hobots.transaction.start()` (e `step.start()`) param uma run cancelada.
2825
+ * Conecta a checagem de cancelamento chamada antes de cada etapa nova (inclusive antes de
2826
+ * cada nova tentativa) — é assim que `hobots.step()` para uma run cancelada.
2709
2827
  */
2710
2828
  setStepGuard(guard) {
2711
2829
  this.stepGuard = guard;
2712
2830
  }
2713
- createStep(parentStepId, op, description) {
2831
+ /** A etapa aberta por `step()` (ou `items.each`) no contexto async atual, se houver. */
2832
+ currentStep() {
2833
+ return currentStep(this);
2834
+ }
2835
+ /** Faz de `step` a etapa corrente durante `fn` (filhas aninham nela). Sem etapa, só roda `fn`. */
2836
+ scope(step, fn) {
2837
+ if (!step) return fn();
2838
+ return runWithStepScope({ transaction: this, step }, fn);
2839
+ }
2840
+ /** Abre uma etapa sob a corrente (ou direta da transaction). Interno: o bot usa `step()`. */
2841
+ start(op, description) {
2714
2842
  this.stepGuard?.();
2715
- const step = new Step(this, parentStepId, op, description);
2843
+ const parent = this.currentStep();
2844
+ const step = new Step(this, parent ? parent.stepId : this.stepId, op, description);
2716
2845
  this.steps.push(step);
2717
2846
  this.emit("step", "start", stepLabel(op, description));
2718
2847
  return step;
2719
2848
  }
2720
- /** Step direta da transaction. `op` é o nome usado por `succeeded(name)`/`failed(error, name)`. */
2721
- start(op, description) {
2722
- return this.createStep(this.stepId, op, description);
2723
- }
2724
2849
  /**
2725
- * Fecha a step aberta mais recente — com `name`, a mais recente aberta com esse `op`.
2726
- * Sem step aberta (ou sem nenhuma com esse nome), não faz nada e devolve undefined.
2850
+ * Registra a etapa `name` executando `fn(etapa)` até dar certo ou esgotar `retries`
2851
+ * tentativas extras (default 1), esperando `retryDelayMs` entre elas (default 5 000).
2852
+ * Resolve com o retorno de `fn`.
2853
+ *
2854
+ * Uma etapa só no waterfall, cobrindo todas as tentativas: `data.attempts` quando houve
2855
+ * mais de uma; `warning` com o motivo quando a última deu certo; `error` (e a exceção
2856
+ * sobe) quando esgotou; `cancelled` em cancelamento/timeout, que nunca repetem.
2727
2857
  */
2728
- succeeded(name) {
2729
- return this.findOpenStep(name)?.succeeded();
2730
- }
2731
- /**
2732
- * Marca como `error` e fecha a step aberta mais recente — com `name`, a mais recente
2733
- * aberta com esse `op`. `options.message` sobrescreve a mensagem do erro e
2734
- * `options.image` pendura um print. Sem step aberta, não faz nada e devolve undefined.
2735
- */
2736
- failed(error, name, options) {
2737
- return this.findOpenStep(name)?.failed(error, options?.message, options);
2738
- }
2739
- findOpenStep(name) {
2740
- for (let i = this.steps.length - 1; i >= 0; i--) {
2741
- const step = this.steps[i];
2742
- if (!step.endTimestamp && (name === void 0 || step.op === name)) return step;
2858
+ async step(name, fn, options = {}) {
2859
+ const requested = options.retries ?? DEFAULT_STEP_RETRIES;
2860
+ const totalRetries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_STEP_RETRIES;
2861
+ const step = this.start(void 0, safeString(name));
2862
+ let attempts = 0;
2863
+ let lastFailure;
2864
+ try {
2865
+ const result = await retryWithBackoff(
2866
+ (attempt) => {
2867
+ attempts = attempt;
2868
+ return this.scope(step, async () => fn(step));
2869
+ },
2870
+ {
2871
+ retries: totalRetries,
2872
+ delayMs: (attempt) => resolveDelay(options.retryDelayMs, attempt, DEFAULT_STEP_RETRY_DELAY_MS),
2873
+ signal: options.signal,
2874
+ wait: options.wait,
2875
+ onRetry: (error, attempt, delayMs) => {
2876
+ lastFailure = { error };
2877
+ options.warn?.(
2878
+ `etapa "${step.description}": tentativa ${attempt}/${totalRetries + 1} falhou: ${truncate(errorReason(error), 500)} \u2014 nova tentativa em ${delayMs}ms`
2879
+ );
2880
+ }
2881
+ }
2882
+ );
2883
+ this.noteAttempts(step, attempts);
2884
+ if (lastFailure && step.status === "ok") {
2885
+ step._setStatus("warning", `tentativa ${attempts - 1}/${totalRetries + 1} falhou: ${errorReason(lastFailure.error)}`);
2886
+ }
2887
+ step._close();
2888
+ return result;
2889
+ } catch (error) {
2890
+ this.noteAttempts(step, attempts);
2891
+ if (isTerminalError(error)) step._setStatus("cancelled")._close();
2892
+ else step._failed(error);
2893
+ throw error;
2743
2894
  }
2744
- return void 0;
2745
2895
  }
2746
- setTag(key, value) {
2747
- this.tags[key] = value;
2748
- return this;
2896
+ noteAttempts(step, attempts) {
2897
+ if (attempts > 1) step._setData("attempts", attempts);
2749
2898
  }
2899
+ // ── Metadados ─────────────────────────────────────────────────────────────
2750
2900
  setStatus(status) {
2751
2901
  this.status = status;
2752
2902
  return this;
2753
2903
  }
2754
- setName(name) {
2755
- this.name = name;
2756
- return this;
2757
- }
2904
+ // ── Encerramento ──────────────────────────────────────────────────────────
2758
2905
  /**
2759
- * Envia já as steps raiz finalizadas (e todas as descendentes, fechando as que
2906
+ * Envia já as etapas raiz finalizadas (e todas as descendentes, fechando as que
2760
2907
  * ficaram abertas) como transactions parciais da mesma run (`op` hobots.run.flush,
2761
- * `event_id` novo, até 200 steps cada) e as tira da memória. Steps raiz ainda abertas
2908
+ * `event_id` novo, até 200 etapas cada) e as tira da memória. Etapas raiz ainda abertas
2762
2909
  * ficam para o próximo flush ou para o `finish()`. É assim que um agente sempre
2763
2910
  * ligado (`start()`), cuja run não termina, entrega as etapas enquanto processa.
2764
- * Devolve quantas steps foram enviadas.
2911
+ * Devolve quantas etapas foram enviadas (0 sem client, sem nada terminado ou depois do `finish()`).
2765
2912
  */
2766
2913
  flush() {
2767
- if (this.finished) return 0;
2914
+ if (!this.client || this.finished) return 0;
2768
2915
  const ready = /* @__PURE__ */ new Set();
2769
2916
  for (const step of this.steps) {
2770
2917
  if (step.parentStepId === this.stepId && step.endTimestamp) ready.add(step.stepId);
@@ -2780,15 +2927,13 @@ var Transaction = class {
2780
2927
  remaining.push(step);
2781
2928
  }
2782
2929
  }
2783
- for (const step of batch) step.succeeded();
2930
+ for (const step of batch) step._close();
2784
2931
  this.steps.splice(0, this.steps.length, ...remaining);
2785
- if (this.client) {
2786
- for (let i = 0; i < batch.length; i += MAX_STEPS) {
2787
- const chunk = batch.slice(i, i + MAX_STEPS);
2788
- const startTimestamp = chunk.reduce((min, step) => step.startTimestamp < min ? step.startTimestamp : min, chunk[0].startTimestamp);
2789
- const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2790
- this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, startTimestamp, nowISO(), chunk));
2791
- }
2932
+ for (let i = 0; i < batch.length; i += MAX_STEPS) {
2933
+ const chunk = batch.slice(i, i + MAX_STEPS);
2934
+ const startTimestamp = chunk.reduce((min, step) => step.startTimestamp < min ? step.startTimestamp : min, chunk[0].startTimestamp);
2935
+ const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2936
+ this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, startTimestamp, nowISO(), chunk));
2792
2937
  }
2793
2938
  return batch.length;
2794
2939
  }
@@ -2805,14 +2950,15 @@ var Transaction = class {
2805
2950
  start_timestamp: startTimestamp,
2806
2951
  timestamp,
2807
2952
  steps: steps.map((step) => step.toJSON()),
2808
- tags: Object.keys(this.tags).length > 0 ? { ...this.tags } : void 0,
2953
+ // sem tags próprias: o client mescla as do processo
2954
+ tags: void 0,
2809
2955
  run_id: this.runId
2810
2956
  };
2811
2957
  }
2812
2958
  finish() {
2813
2959
  if (this.finished) return;
2814
2960
  this.finished = true;
2815
- for (const step of this.steps) step.succeeded();
2961
+ for (const step of this.steps) step._close();
2816
2962
  const endTimestamp = nowISO();
2817
2963
  this.emit(
2818
2964
  "transaction",
@@ -2840,7 +2986,7 @@ var DEFAULT_RETRY_DELAY_MS = 5e3;
2840
2986
  async function executeRun(input) {
2841
2987
  const { run, handler, api, client, logger, tracker } = input;
2842
2988
  const options = handler.options;
2843
- const tx = new Transaction(client, "run", "hobots.run", run.id);
2989
+ const tx = new Transaction(client, "run", "hobots.run", run.id, { echo: !client });
2844
2990
  const reporter = new RunReporter(
2845
2991
  api,
2846
2992
  run.id,
@@ -3080,42 +3226,26 @@ var TaskAgent = class {
3080
3226
  }
3081
3227
  };
3082
3228
 
3083
- // src/tasks/attached.ts
3084
- var START_RETRY_DELAYS_MS = [1e3, 4e3, 16e3];
3085
- function isTransient(error) {
3086
- if (!(error instanceof TasksApiError)) return true;
3087
- return error.status >= 500 || error.status === 429;
3088
- }
3089
- function describeRejection(runId, error) {
3090
- if (error instanceof TasksApiError && error.status === 404) {
3091
- return `run ${runId} n\xE3o existe na plataforma (404) \u2014 nada a executar`;
3092
- }
3093
- if (error instanceof TasksApiError && error.status === 409) {
3094
- return `run ${runId} n\xE3o pode ser iniciada (409: cancelada, j\xE1 iniciada ou de outra inst\xE2ncia) \u2014 nada a executar`;
3095
- }
3096
- return `n\xE3o foi poss\xEDvel iniciar a run ${runId} (${String(error)}) \u2014 nada a executar`;
3097
- }
3098
- var AttachedRunner = class {
3099
- constructor(runId, handler, deps) {
3100
- this.runId = runId;
3229
+ // src/tasks/single-run.ts
3230
+ var SingleRunRunner = class {
3231
+ constructor(handler, deps) {
3101
3232
  this.handler = handler;
3102
3233
  this.deps = deps;
3103
3234
  this.done = new Promise((resolve2) => {
3104
3235
  this.resolveDone = resolve2;
3105
3236
  });
3106
3237
  }
3107
- runId;
3108
3238
  handler;
3109
3239
  deps;
3110
3240
  abort = new AbortController();
3111
- /** Resolve quando a run terminou (ou não pôde ser iniciada, ou o runner foi parado antes). */
3241
+ /** Resolve quando a run terminou (ou não pôde começar, ou o runner foi parado antes). */
3112
3242
  done;
3113
3243
  resolveDone;
3114
3244
  async run() {
3115
3245
  try {
3116
- const run = await this.startRun();
3246
+ const run = await this.openRun();
3117
3247
  if (!run) return;
3118
- this.deps.logger.debug(`run ${run.id} iniciada (HOBOTS_RUN_ID)`);
3248
+ this.deps.logger.debug(this.opened(run));
3119
3249
  await executeRun({
3120
3250
  run,
3121
3251
  handler: this.handler,
@@ -3130,11 +3260,37 @@ var AttachedRunner = class {
3130
3260
  this.resolveDone();
3131
3261
  }
3132
3262
  }
3133
- /** Desiste de iniciar a run (close() antes da plataforma responder). Uma run já iniciada segue pelo tracker. */
3263
+ /** Desiste de obter a run (close() antes dela existir). Uma run já em execução segue pelo tracker. */
3134
3264
  stop() {
3135
3265
  this.abort.abort();
3136
3266
  }
3137
- async startRun() {
3267
+ };
3268
+
3269
+ // src/tasks/attached.ts
3270
+ var START_RETRY_DELAYS_MS = [1e3, 4e3, 16e3];
3271
+ function isTransient(error) {
3272
+ if (!(error instanceof TasksApiError)) return true;
3273
+ return error.status >= 500 || error.status === 429;
3274
+ }
3275
+ function describeRejection(runId, error) {
3276
+ if (error instanceof TasksApiError && error.status === 404) {
3277
+ return `run ${runId} n\xE3o existe na plataforma (404) \u2014 nada a executar`;
3278
+ }
3279
+ if (error instanceof TasksApiError && error.status === 409) {
3280
+ return `run ${runId} n\xE3o pode ser iniciada (409: cancelada, j\xE1 iniciada ou de outra inst\xE2ncia) \u2014 nada a executar`;
3281
+ }
3282
+ return `n\xE3o foi poss\xEDvel iniciar a run ${runId} (${String(error)}) \u2014 nada a executar`;
3283
+ }
3284
+ var AttachedRunner = class extends SingleRunRunner {
3285
+ constructor(runId, handler, deps) {
3286
+ super(handler, deps);
3287
+ this.runId = runId;
3288
+ }
3289
+ runId;
3290
+ opened(run) {
3291
+ return `run ${run.id} iniciada (HOBOTS_RUN_ID)`;
3292
+ }
3293
+ async openRun() {
3138
3294
  for (let attempt = 0; ; attempt++) {
3139
3295
  if (this.abort.signal.aborted) return void 0;
3140
3296
  try {
@@ -3197,46 +3353,16 @@ function normalizeItemColumns(input, logger) {
3197
3353
  }
3198
3354
 
3199
3355
  // src/tasks/local.ts
3200
- var LocalRunner = class {
3356
+ var LocalRunner = class extends SingleRunRunner {
3357
+ backoff;
3201
3358
  constructor(handler, deps) {
3202
- this.handler = handler;
3203
- this.deps = deps;
3359
+ super(handler, deps);
3204
3360
  this.backoff = new Backoff(deps.logger, "cria\xE7\xE3o da run");
3205
- this.done = new Promise((resolve2) => {
3206
- this.resolveDone = resolve2;
3207
- });
3208
3361
  }
3209
- handler;
3210
- deps;
3211
- abort = new AbortController();
3212
- backoff;
3213
- /** Resolve quando a run terminou (ou o runner foi parado antes de abri-la). */
3214
- done;
3215
- resolveDone;
3216
- async run() {
3217
- try {
3218
- const run = await this.createRun();
3219
- if (!run) return;
3220
- this.deps.logger.debug(`run ${run.id} aberta (modo start)`);
3221
- await executeRun({
3222
- run,
3223
- handler: this.handler,
3224
- api: this.deps.api,
3225
- client: this.deps.client,
3226
- logger: this.deps.logger,
3227
- tracker: this.deps.tracker
3228
- });
3229
- } catch (error) {
3230
- this.deps.logger.error("erro inesperado na execu\xE7\xE3o do agente:", error);
3231
- } finally {
3232
- this.resolveDone();
3233
- }
3362
+ opened(run) {
3363
+ return `run ${run.id} aberta (modo start)`;
3234
3364
  }
3235
- /** Desiste de abrir a run (close() antes da plataforma responder). Uma run já aberta segue pelo tracker. */
3236
- stop() {
3237
- this.abort.abort();
3238
- }
3239
- async createRun() {
3365
+ async openRun() {
3240
3366
  for (; ; ) {
3241
3367
  if (this.abort.signal.aborted) return void 0;
3242
3368
  try {
@@ -3249,6 +3375,129 @@ var LocalRunner = class {
3249
3375
  }
3250
3376
  };
3251
3377
 
3378
+ // src/tasks/local-transport.ts
3379
+ var LOCAL_RUN_ID = "local";
3380
+ var ITEM_LINE_PREFIX = "[hobots:item]";
3381
+ var SUMMARY_LINE_PREFIX = "[hobots:summary]";
3382
+ var ATTACHMENTS_DISCARDED_WARNING = "imagens e anexos n\xE3o s\xE3o enviados no modo local (descartados)";
3383
+ function unavailable(name) {
3384
+ return new Error(`arquivo "${name}" enviado pela Central n\xE3o est\xE1 dispon\xEDvel no modo local`);
3385
+ }
3386
+ function cell(value) {
3387
+ return (value ?? "").replace(/\r\n|[\t\r\n]/g, " ");
3388
+ }
3389
+ var LocalRunTransport = class {
3390
+ constructor(params = {}, out = (line) => void process.stdout.write(`${line}
3391
+ `)) {
3392
+ this.params = params;
3393
+ this.out = out;
3394
+ }
3395
+ params;
3396
+ out;
3397
+ counts = { succeeded: 0, occurrence: 0, failed: 0 };
3398
+ warnedAttachments = false;
3399
+ /** Status terminal que o pipeline reportou — `undefined` enquanto a run corre (ou se ela nem reportou). */
3400
+ terminalStatus;
3401
+ async updateRun(_runId, update) {
3402
+ if (update.status === "running") return;
3403
+ this.terminalStatus = update.status;
3404
+ if (update.status === "failed" && update.error && update.error.type !== "RunCanceled") {
3405
+ printRunLog("error", `execu\xE7\xE3o falhou \u2014 ${update.error.type ?? "Error"}: ${update.error.message}`);
3406
+ if (update.error.stack) console.error(update.error.stack);
3407
+ }
3408
+ }
3409
+ async updateRunOnce() {
3410
+ }
3411
+ async createItems(_runId, request) {
3412
+ for (const item of request.items) {
3413
+ this.counts[item.status]++;
3414
+ this.out(`${ITEM_LINE_PREFIX} ${item.status} ${cell(item.id)} ${cell(item.message)}`);
3415
+ }
3416
+ return { accepted: request.items.length, duplicates: 0 };
3417
+ }
3418
+ /** Totais por status — chame uma vez, no fim da run, antes da linha de conclusão. */
3419
+ printSummary() {
3420
+ const { succeeded, occurrence, failed } = this.counts;
3421
+ this.out(
3422
+ `${SUMMARY_LINE_PREFIX} total=${succeeded + occurrence + failed} ok=${succeeded} avisos=${occurrence} falhas=${failed} pulados=0`
3423
+ );
3424
+ }
3425
+ async createAttachment(_runId, request) {
3426
+ if (!this.warnedAttachments) {
3427
+ this.warnedAttachments = true;
3428
+ printRunLog("warning", ATTACHMENTS_DISCARDED_WARNING);
3429
+ }
3430
+ return { id: request.id, upload_url: "", headers: {}, expires_in: 0 };
3431
+ }
3432
+ async uploadAttachment() {
3433
+ }
3434
+ async confirmAttachment() {
3435
+ }
3436
+ /** Nome do arquivo pelo id (nos params); sem ele, o próprio id. */
3437
+ fileName(fileId) {
3438
+ return collectFileRefs(this.params).find((ref) => ref.id === fileId)?.name ?? fileId;
3439
+ }
3440
+ async requestFileDownload(_runId, fileId) {
3441
+ throw unavailable(this.fileName(fileId));
3442
+ }
3443
+ async fetchFile(url) {
3444
+ throw unavailable(url);
3445
+ }
3446
+ async confirmFileDownload(_runId, fileId) {
3447
+ throw unavailable(this.fileName(fileId));
3448
+ }
3449
+ };
3450
+
3451
+ // src/tasks/local-run.ts
3452
+ var LOCAL_RUN_EXIT_CODES = { succeeded: 0, failed: 1, canceled: 130 };
3453
+ var LOCAL_RUN_CANCEL_GRACE_MS = 4e3;
3454
+ var LOCAL_RUN_BANNER = "modo local \u2014 execu\xE7\xE3o sem a Central; params de HOBOTS_RUN_PARAMS";
3455
+ var LocalRunRunner = class extends SingleRunRunner {
3456
+ constructor(params, handler, deps) {
3457
+ super(handler, deps);
3458
+ this.params = params;
3459
+ }
3460
+ params;
3461
+ canceled = false;
3462
+ startedAt = Date.now();
3463
+ finished = false;
3464
+ run() {
3465
+ this.startedAt = Date.now();
3466
+ return super.run();
3467
+ }
3468
+ opened() {
3469
+ return "run local iniciada (HOBOTS_LOCAL_RUN)";
3470
+ }
3471
+ async openRun() {
3472
+ if (this.abort.signal.aborted) return void 0;
3473
+ return { id: LOCAL_RUN_ID, params: this.params, form: null, source: "local", created_at: nowISO() };
3474
+ }
3475
+ /** SIGINT/SIGTERM: dispara o `ctx.signal` da run em voo — o handler para no próximo ponto de parada. */
3476
+ cancel() {
3477
+ this.canceled = true;
3478
+ this.deps.tracker.singleActive()?.abort();
3479
+ }
3480
+ /** Desfecho da run: cancelada pelo sinal, senão o status terminal reportado (sem reporte = falha). */
3481
+ get outcome() {
3482
+ if (this.canceled) return "canceled";
3483
+ return this.deps.api.terminalStatus ?? "failed";
3484
+ }
3485
+ /**
3486
+ * Fecha o relato no terminal — `[hobots:summary]` e a linha de conclusão — e devolve o
3487
+ * desfecho. Idempotente: o runtime chama ao fim da run ou quando a graça do cancelamento
3488
+ * esgota com o handler ainda rodando.
3489
+ */
3490
+ finish() {
3491
+ const outcome = this.outcome;
3492
+ if (this.finished) return outcome;
3493
+ this.finished = true;
3494
+ this.deps.api.printSummary();
3495
+ const seconds = Math.round((Date.now() - this.startedAt) / 1e3);
3496
+ printRunLog("info", `execu\xE7\xE3o local conclu\xEDda (${outcome}) em ${seconds}s`);
3497
+ return outcome;
3498
+ }
3499
+ };
3500
+
3252
3501
  // src/tasks/registry.ts
3253
3502
  var RunTracker = class {
3254
3503
  constructor(api) {
@@ -3364,26 +3613,10 @@ function hostname() {
3364
3613
  if (!isNode()) return void 0;
3365
3614
  return process.env.HOSTNAME || process.env.COMPUTERNAME || void 0;
3366
3615
  }
3367
- function createRuntime(options, handler) {
3368
- loadDotenv();
3369
- const config = resolveConfig({ clientSecret: options.clientSecret });
3370
- const logger = makeLogger(options.debug ?? false);
3371
- const environment = resolveEnvironmentOption(options.environment);
3372
- const client = new Client(
3373
- {
3374
- environment,
3375
- release: options.release,
3376
- tags: options.tags,
3377
- debug: options.debug,
3378
- captureRunErrors: options.captureUnhandled !== false
3379
- },
3380
- config
3381
- );
3382
- const api = new TasksApi({ baseUrl: config.runsBaseUrl, key: config.key, logger });
3383
- const rt = {
3616
+ function baseRuntime(options, handler, logger, api) {
3617
+ return {
3384
3618
  options: { ...options, clientSecret: void 0 },
3385
- config,
3386
- client,
3619
+ client: void 0,
3387
3620
  logger,
3388
3621
  api,
3389
3622
  handler,
@@ -3393,19 +3626,54 @@ function createRuntime(options, handler) {
3393
3626
  agent: void 0,
3394
3627
  local: void 0,
3395
3628
  attached: void 0,
3629
+ localRun: void 0,
3396
3630
  runId: void 0,
3397
3631
  instanceId: resolveInstanceId(options.instanceId),
3398
- environment,
3632
+ environment: void 0,
3399
3633
  pollIntervalMs: void 0,
3400
3634
  booted: false,
3401
3635
  bootScheduled: false,
3402
3636
  bootPromise: void 0,
3403
- uninstallGlobalHandlers: options.captureUnhandled !== false ? installGlobalHandlers(client) : void 0,
3637
+ uninstallGlobalHandlers: void 0,
3404
3638
  uninstallShutdown: void 0
3405
3639
  };
3640
+ }
3641
+ function createRuntime(options, handler, logger) {
3642
+ const config = resolveConfig({ clientSecret: options.clientSecret });
3643
+ const environment = resolveEnvironmentOption(options.environment);
3644
+ const client = new Client(
3645
+ {
3646
+ environment,
3647
+ release: options.release,
3648
+ tags: options.tags,
3649
+ debug: options.debug,
3650
+ captureRunErrors: options.captureUnhandled !== false
3651
+ },
3652
+ config
3653
+ );
3654
+ const api = new TasksApi({ baseUrl: config.runsBaseUrl, key: config.key, logger });
3655
+ const rt = baseRuntime(options, handler, logger, api);
3656
+ rt.client = client;
3657
+ rt.environment = environment;
3658
+ rt.uninstallGlobalHandlers = options.captureUnhandled !== false ? installGlobalHandlers(client) : void 0;
3406
3659
  logger.debug("inicializado", { endpoint: config.ingestEndpoint });
3407
3660
  return rt;
3408
3661
  }
3662
+ function createLocalRunRuntime(options, handler, logger) {
3663
+ let params;
3664
+ try {
3665
+ params = resolveLocalRunParams();
3666
+ } catch (error) {
3667
+ printRunLog("error", error instanceof Error ? error.message : String(error));
3668
+ process.exit(2);
3669
+ throw error;
3670
+ }
3671
+ const transport = new LocalRunTransport(params);
3672
+ const rt = baseRuntime(options, handler, logger, transport);
3673
+ rt.localRun = new LocalRunRunner(params, handler, { api: transport, client: void 0, logger, tracker: rt.tracker });
3674
+ printRunLog("info", LOCAL_RUN_BANNER);
3675
+ return rt;
3676
+ }
3409
3677
  function validateHandler(handler, fn) {
3410
3678
  if (typeof handler !== "function") {
3411
3679
  throw new Error(`[hobots] ${fn}(): o handler precisa ser uma fun\xE7\xE3o \u2014 ${fn}(async (params) => { ... })`);
@@ -3448,9 +3716,12 @@ function instanceInfo(rt) {
3448
3716
  machine: rt.sampler?.sample()
3449
3717
  };
3450
3718
  }
3719
+ function platformApi(rt) {
3720
+ return rt.api;
3721
+ }
3451
3722
  function ensureAgent(rt) {
3452
3723
  rt.agent ??= new TaskAgent({
3453
- api: rt.api,
3724
+ api: platformApi(rt),
3454
3725
  client: rt.client,
3455
3726
  logger: rt.logger,
3456
3727
  handler: rt.handler,
@@ -3462,7 +3733,7 @@ function ensureAgent(rt) {
3462
3733
  }
3463
3734
  function launchLocal(rt) {
3464
3735
  const runner = new LocalRunner(rt.handler, {
3465
- api: rt.api,
3736
+ api: platformApi(rt),
3466
3737
  client: rt.client,
3467
3738
  logger: rt.logger,
3468
3739
  tracker: rt.tracker,
@@ -3473,7 +3744,7 @@ function launchLocal(rt) {
3473
3744
  }
3474
3745
  function launchAttached(rt, runId) {
3475
3746
  const runner = new AttachedRunner(runId, rt.handler, {
3476
- api: rt.api,
3747
+ api: platformApi(rt),
3477
3748
  client: rt.client,
3478
3749
  logger: rt.logger,
3479
3750
  tracker: rt.tracker,
@@ -3485,7 +3756,43 @@ function launchAttached(rt, runId) {
3485
3756
  if (runtime === rt) void close();
3486
3757
  });
3487
3758
  }
3759
+ function launchLocalRun(rt, runner) {
3760
+ let finished = false;
3761
+ const finish = () => {
3762
+ if (finished) return;
3763
+ finished = true;
3764
+ rt.uninstallShutdown?.();
3765
+ rt.uninstallShutdown = void 0;
3766
+ const code = LOCAL_RUN_EXIT_CODES[runner.finish()];
3767
+ if (runtime === rt) void close(0);
3768
+ process.stdout.write("", () => process.exit(code));
3769
+ };
3770
+ if (rt.options.installExitHandlers !== false) {
3771
+ let signaled = false;
3772
+ const onSignal = (signal) => {
3773
+ if (externalListenerCount(signal) > 0 || signaled) return;
3774
+ signaled = true;
3775
+ runner.cancel();
3776
+ void Promise.race([runner.done, sleep(LOCAL_RUN_CANCEL_GRACE_MS)]).then(finish);
3777
+ };
3778
+ const onInt = markInternal(() => onSignal("SIGINT"));
3779
+ const onTerm = markInternal(() => onSignal("SIGTERM"));
3780
+ process.on("SIGINT", onInt);
3781
+ process.on("SIGTERM", onTerm);
3782
+ rt.uninstallShutdown = () => {
3783
+ process.off("SIGINT", onInt);
3784
+ process.off("SIGTERM", onTerm);
3785
+ };
3786
+ }
3787
+ void runner.run();
3788
+ void runner.done.then(finish);
3789
+ }
3488
3790
  async function boot(rt) {
3791
+ if (rt.localRun) {
3792
+ rt.booted = true;
3793
+ launchLocalRun(rt, rt.localRun);
3794
+ return;
3795
+ }
3489
3796
  if (!rt.instanceId) {
3490
3797
  const id = await fallbackInstanceId();
3491
3798
  if (runtime !== rt) return;
@@ -3522,9 +3829,12 @@ function startAgent(fn, mode, handler, options) {
3522
3829
  }
3523
3830
  const logger = makeLogger(options.debug ?? false);
3524
3831
  const agentHandler = { mode, handler, options: resolveRunOptions(options, mode, logger) };
3525
- const rt = createRuntime(options, agentHandler);
3526
- if (mode === "remote" && options.pollIntervalMs !== void 0) rt.pollIntervalMs = options.pollIntervalMs;
3527
- if (mode === "remote") rt.runId = resolveRunId(options.runId);
3832
+ loadDotenv();
3833
+ const rt = isLocalRun() ? createLocalRunRuntime(options, agentHandler, logger) : createRuntime(options, agentHandler, logger);
3834
+ if (!rt.localRun && mode === "remote") {
3835
+ if (options.pollIntervalMs !== void 0) rt.pollIntervalMs = options.pollIntervalMs;
3836
+ rt.runId = resolveRunId(options.runId);
3837
+ }
3528
3838
  runtime = rt;
3529
3839
  scheduleBoot(rt);
3530
3840
  }
@@ -3547,10 +3857,10 @@ function forwardAttachment(rt, attachment, message, type) {
3547
3857
  const reporter = currentReporter();
3548
3858
  if (!reporter) {
3549
3859
  rt.logger.warn("attachment descartado \u2014 s\xF3 \xE9 suportado dentro de uma execu\xE7\xE3o");
3550
- return;
3860
+ return void 0;
3551
3861
  }
3552
3862
  const withMeta = typeof attachment === "object" && !isImageAttachment(attachment) && "image" in attachment ? image(attachment.image, { caption: attachment.caption, contentType: attachment.contentType }) : attachment;
3553
- reporter.log(toRunLogLevel(type), message, withMeta);
3863
+ return reporter.log(toRunLogLevel(type), message, withMeta);
3554
3864
  }
3555
3865
  function captureException(error, hint) {
3556
3866
  const rt = runtime;
@@ -3561,13 +3871,19 @@ function captureException(error, hint) {
3561
3871
  }
3562
3872
  return void 0;
3563
3873
  }
3874
+ let attachmentId;
3564
3875
  if (hint?.attachment) {
3565
3876
  const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
3566
- forwardAttachment(rt, hint.attachment, message, hint.type ?? "error");
3877
+ attachmentId = forwardAttachment(rt, hint.attachment, message, hint.type ?? "error");
3878
+ }
3879
+ if (!rt.client) {
3880
+ rt.logger.debug("captureException ignorado no modo local (sem Central)");
3881
+ return void 0;
3567
3882
  }
3568
3883
  const runId = currentReporter()?.runId;
3569
3884
  const withRun = runId ? { ...hint, tags: { run_id: runId, ...hint?.tags } } : hint;
3570
- return rt.client.captureException(error, withRun);
3885
+ const contexts = runId && attachmentId ? { run: { id: runId, attachments: [attachmentId] } } : void 0;
3886
+ return rt.client.captureException(error, withRun, contexts);
3571
3887
  }
3572
3888
  async function close(timeoutMs = 3e4) {
3573
3889
  const rt = runtime;
@@ -3577,6 +3893,7 @@ async function close(timeoutMs = 3e4) {
3577
3893
  if (rt.agent) await rt.agent.stop(0);
3578
3894
  rt.local?.stop();
3579
3895
  rt.attached?.stop();
3896
+ rt.localRun?.stop();
3580
3897
  const idle = await rt.tracker.waitIdle(timeoutMs);
3581
3898
  if (!idle) {
3582
3899
  rt.logger.warn(`${rt.tracker.activeCount} run(s) ainda em execu\xE7\xE3o ap\xF3s ${timeoutMs}ms \u2014 abandonando a espera`);
@@ -3585,7 +3902,7 @@ async function close(timeoutMs = 3e4) {
3585
3902
  rt.heartbeat?.stop();
3586
3903
  rt.uninstallShutdown?.();
3587
3904
  rt.uninstallGlobalHandlers?.();
3588
- const flushed = await rt.client.close(timeoutMs);
3905
+ const flushed = rt.client ? await rt.client.close(timeoutMs) : true;
3589
3906
  return idle && flushed;
3590
3907
  }
3591
3908
 
@@ -3614,11 +3931,13 @@ var noop = () => void 0;
3614
3931
  var fallbackEachSink = {
3615
3932
  report: noop,
3616
3933
  startStep: () => void 0,
3934
+ stepScope: (_step, fn) => fn(),
3617
3935
  declareTotal: noop,
3618
3936
  advance: noop,
3619
3937
  checkCanceled: noop,
3620
3938
  isAborted: () => false,
3621
- logError: (message) => printRunLog("error", message)
3939
+ logError: (message) => printRunLog("error", message),
3940
+ flush: () => Promise.resolve()
3622
3941
  };
3623
3942
  var fallbackItems = {
3624
3943
  add: noop,
@@ -3637,7 +3956,6 @@ var fallbackFiles = {
3637
3956
  read: outsideRunFiles
3638
3957
  };
3639
3958
  var neverAborts = new AbortController().signal;
3640
- var detachedTransaction;
3641
3959
  function resolve(member) {
3642
3960
  const reporter = currentReporter();
3643
3961
  if (reporter) return reporter.context;
@@ -3658,8 +3976,12 @@ var hobots = Object.defineProperties({}, {
3658
3976
  items: getter("items", () => fallbackItems),
3659
3977
  progress: getter("progress", () => fallbackProgress),
3660
3978
  files: getter("files", () => fallbackFiles),
3661
- transaction: getter("transaction", () => detachedTransaction ??= new Transaction(void 0, "hobots.detached")),
3662
3979
  signal: getter("signal", () => neverAborts),
3980
+ step: method((name, fn, options) => {
3981
+ const ctx = resolve("step");
3982
+ if (ctx) return ctx.step(name, fn, options);
3983
+ return new Transaction(void 0, "hobots.detached").step(name, fn, options);
3984
+ }),
3663
3985
  throwIfCanceled: method(() => resolve("throwIfCanceled")?.throwIfCanceled()),
3664
3986
  sleep: method((ms) => resolve("sleep")?.sleep(ms) ?? sleep(ms)),
3665
3987
  flush: method((timeoutMs) => resolve("flush")?.flush(timeoutMs) ?? Promise.resolve()),