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.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
 
5
5
  // src/utils.ts
6
6
  var SDK_NAME = "hobots";
7
- var SDK_VERSION = true ? "4.5.0" : "0.0.0-dev";
7
+ var SDK_VERSION = true ? "5.0.0" : "0.0.0-dev";
8
8
  function isNode() {
9
9
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
10
10
  }
@@ -57,8 +57,13 @@ function abortableSleep(ms, signal) {
57
57
  function jitter(baseMs) {
58
58
  return Math.floor(baseMs / 2 + Math.random() * (baseMs / 2));
59
59
  }
60
+ var TRUNCATION_SUFFIX = "\u2026 [truncado]";
60
61
  function truncate(value, max) {
61
- return value.length > max ? `${value.slice(0, max)}\u2026 [truncado]` : value;
62
+ return value.length > max ? `${value.slice(0, max)}${TRUNCATION_SUFFIX}` : value;
63
+ }
64
+ function clip(value, max) {
65
+ if (value.length <= max) return value;
66
+ return `${value.slice(0, Math.max(0, max - TRUNCATION_SUFFIX.length))}${TRUNCATION_SUFFIX}`;
62
67
  }
63
68
  function safeString(value) {
64
69
  if (typeof value === "string") return value;
@@ -156,6 +161,8 @@ function colorize(text, color) {
156
161
  var INSTANCE_ID_ENV = "HOBOTS_INSTANCE_ID";
157
162
  var ENVIRONMENT_ENV = "HOBOTS_ENVIRONMENT";
158
163
  var RUN_ID_ENV = "HOBOTS_RUN_ID";
164
+ var LOCAL_RUN_ENV = "HOBOTS_LOCAL_RUN";
165
+ var RUN_PARAMS_ENV = "HOBOTS_RUN_PARAMS";
159
166
  var dotenvLoaded = false;
160
167
  function loadDotenv() {
161
168
  if (dotenvLoaded) return;
@@ -181,6 +188,24 @@ function resolveRunId(option) {
181
188
  if (typeof process === "undefined" || !process.env) return void 0;
182
189
  return process.env[RUN_ID_ENV]?.trim() || void 0;
183
190
  }
191
+ function isLocalRun() {
192
+ if (typeof process === "undefined" || !process.env) return false;
193
+ return process.env[LOCAL_RUN_ENV]?.trim() === "1";
194
+ }
195
+ function resolveLocalRunParams() {
196
+ const raw = process.env[RUN_PARAMS_ENV]?.trim();
197
+ if (!raw) return {};
198
+ let parsed;
199
+ try {
200
+ parsed = JSON.parse(raw);
201
+ } catch {
202
+ throw new Error(`${RUN_PARAMS_ENV} inv\xE1lido: n\xE3o \xE9 JSON v\xE1lido`);
203
+ }
204
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
205
+ throw new Error(`${RUN_PARAMS_ENV} inv\xE1lido: esperado um objeto JSON`);
206
+ }
207
+ return parsed;
208
+ }
184
209
  function resolveEnvironmentOption(option) {
185
210
  const fromOption = option?.trim();
186
211
  if (fromOption) return fromOption;
@@ -394,9 +419,18 @@ var MAX_FINGERPRINT_ITEMS = 10;
394
419
  var MAX_FINGERPRINT_ITEM_CHARS = 255;
395
420
  var MAX_TRANSACTION_NAME_CHARS = 500;
396
421
  var MAX_STEPS = 200;
422
+ var MAX_STEP_DESCRIPTION_CHARS = 2048;
423
+ var MAX_STEP_OP_CHARS = 255;
397
424
  function clipTags(tags) {
398
425
  return Object.fromEntries(Object.entries(tags).map(([key, value]) => [key, truncate(String(value), MAX_TAG_VALUE_CHARS)]));
399
426
  }
427
+ function clipStep(step) {
428
+ return {
429
+ ...step,
430
+ ...step.op ? { op: clip(step.op, MAX_STEP_OP_CHARS) } : {},
431
+ ...step.description ? { description: clip(step.description, MAX_STEP_DESCRIPTION_CHARS) } : {}
432
+ };
433
+ }
400
434
  function clipFingerprint(fingerprint) {
401
435
  if (fingerprint === void 0) return void 0;
402
436
  return fingerprint.slice(0, MAX_FINGERPRINT_ITEMS).map((item) => truncate(String(item), MAX_FINGERPRINT_ITEM_CHARS));
@@ -455,12 +489,13 @@ var Client = class {
455
489
  [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
456
490
  return `Client { baseUrl: '${this.#config.baseUrl}' }`;
457
491
  }
458
- captureException(error, hint) {
492
+ /** `contexts` é interno ao SDK (ex.: `contexts.run` com o print do captureException) — o evento ainda pode sobrescrever. */
493
+ captureException(error, hint, contexts) {
459
494
  if (typeof error === "object" && error !== null) captured.add(error);
460
- return this.captureEvent(eventFromException(error, hint?.type ?? "error"), hint);
495
+ return this.captureEvent(eventFromException(error, hint?.type ?? "error"), hint, contexts);
461
496
  }
462
- captureEvent(event, hint) {
463
- const final = this.prepareEvent(event, hint);
497
+ captureEvent(event, hint, contexts) {
498
+ const final = this.prepareEvent(contexts ? { ...event, contexts: { ...contexts, ...event.contexts } } : event, hint);
464
499
  this.transport.enqueue({ type: "event", payload: final });
465
500
  return final.event_id;
466
501
  }
@@ -483,14 +518,15 @@ var Client = class {
483
518
  sendTransaction(payload) {
484
519
  let steps = payload.steps;
485
520
  if (steps.length > MAX_STEPS) {
486
- this.logger.warn(`transaction com ${steps.length} steps \u2014 enviando as primeiras ${MAX_STEPS}`);
521
+ this.logger.warn(`transaction com ${steps.length} etapas \u2014 enviando as primeiras ${MAX_STEPS}`);
487
522
  steps = steps.slice(0, MAX_STEPS);
488
523
  }
524
+ steps = steps.map(clipStep);
489
525
  this.transport.enqueue({
490
526
  type: "transaction",
491
527
  payload: {
492
528
  ...payload,
493
- name: truncate(payload.name || "(sem nome)", MAX_TRANSACTION_NAME_CHARS),
529
+ name: clip(payload.name || "(sem nome)", MAX_TRANSACTION_NAME_CHARS),
494
530
  steps,
495
531
  release: payload.release ?? this.options.release,
496
532
  environment: this.resolveEnvironment(payload.environment),
@@ -1290,11 +1326,11 @@ var ItemHandle = class {
1290
1326
  }
1291
1327
  failed(message, payload, options) {
1292
1328
  this.record("failed", message, payload, options?.image);
1293
- if (this.step && this.step.status !== "error") this.step.setStatus("error", message);
1329
+ if (this.step && this.step.status !== "error") this.step._setStatus("error", message);
1294
1330
  }
1295
1331
  occurrence(message, payload, options) {
1296
1332
  this.record("occurrence", message, payload, options?.image);
1297
- if (this.step && this.step.status === "ok") this.step.setStatus("warning", message);
1333
+ if (this.step && this.step.status === "ok") this.step._setStatus("warning", message);
1298
1334
  }
1299
1335
  record(status, message, payload, image3) {
1300
1336
  this.reported = true;
@@ -1325,23 +1361,24 @@ async function eachItem(list, handler, options, sink) {
1325
1361
  const step = sink.startStep(options.op ?? "item", description);
1326
1362
  const handle = new ItemHandle(index, id, step, options.payload?.(element, index), sink);
1327
1363
  try {
1328
- await handler(element, handle, index);
1364
+ await sink.stepScope(step, async () => handler(element, handle, index));
1329
1365
  if (!handle.reported) handle.succeeded();
1330
1366
  } catch (error) {
1331
1367
  if (isTerminalError(error) || sink.isAborted()) {
1332
- step?.setStatus("cancelled").succeeded();
1368
+ step?._setStatus("cancelled")._close();
1333
1369
  throw error;
1334
1370
  }
1335
1371
  const message = thrownMessage(error);
1336
1372
  if (!handle.reported) handle.failed(message);
1337
- step?.failed(error);
1373
+ step?._failed(error);
1338
1374
  sink.logError(`item ${description} falhou: ${message}`);
1339
1375
  }
1340
- step?.succeeded();
1376
+ step?._close();
1341
1377
  if (options.progress !== false) sink.advance();
1342
1378
  result.total += 1;
1343
1379
  result[handle.status ?? "succeeded"] += 1;
1344
1380
  index += 1;
1381
+ if (options.flush !== false) await sink.flush();
1345
1382
  }
1346
1383
  return result;
1347
1384
  }
@@ -1381,6 +1418,11 @@ function isInsideDir(root, candidate, sep) {
1381
1418
  function unavailableLocal(ref) {
1382
1419
  return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
1383
1420
  }
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;
1425
+ }
1384
1426
  function errorCode(error) {
1385
1427
  return error?.code;
1386
1428
  }
@@ -1548,8 +1590,16 @@ var RunFileStore = class {
1548
1590
  this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
1549
1591
  }
1550
1592
  }
1551
- /** storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de origem. */
1593
+ /**
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.
1596
+ */
1552
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
+ }
1553
1603
  const base = process.env[LOCAL_FILES_DIR_ENV];
1554
1604
  if (!base) throw unavailableLocal(ref);
1555
1605
  const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
@@ -1583,6 +1633,26 @@ var RunFileStore = class {
1583
1633
  }
1584
1634
  await rm(folder, { recursive: true, force: true });
1585
1635
  }
1636
+ /**
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.
1639
+ */
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
+ }
1655
+ }
1586
1656
  translate(error, ref) {
1587
1657
  if (this.deps.signal.aborted) return runCanceledError();
1588
1658
  if (error instanceof TasksApiError && error.status === 410) {
@@ -1711,6 +1781,11 @@ function printRunLog(level, message) {
1711
1781
  const method2 = level === "warning" ? "warn" : level === "success" ? "info" : level;
1712
1782
  console[method2](line, message);
1713
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
+ }
1714
1789
  function cancelableSleep(ms, signal) {
1715
1790
  return new Promise((resolve2, reject) => {
1716
1791
  if (signal.aborted) {
@@ -1780,13 +1855,15 @@ var RunReporter = class {
1780
1855
  const eachSink = {
1781
1856
  report: (status, message, payload, id, image3) => this.addItem(status, message, payload, id, image3),
1782
1857
  startStep: (op, description) => tx.start(op, description),
1858
+ stepScope: (step, fn) => tx.scope(step, fn),
1783
1859
  declareTotal: (total) => {
1784
1860
  if (this.progressTotal == null) this.setProgress(this.progressCurrent, total);
1785
1861
  },
1786
1862
  advance: () => this.setProgress(this.progressCurrent + 1, this.progressTotal),
1787
1863
  checkCanceled,
1788
1864
  isAborted: () => this.abortController.signal.aborted,
1789
- logError: (message) => this.log("error", message)
1865
+ logError: (message) => this.log("error", message),
1866
+ flush: () => this.flush()
1790
1867
  };
1791
1868
  const items = {
1792
1869
  add: (item) => {
@@ -1841,7 +1918,14 @@ var RunReporter = class {
1841
1918
  items,
1842
1919
  progress,
1843
1920
  files: this.fileStore.files,
1844
- transaction: tx,
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
+ },
1845
1929
  flush: (timeoutMs) => this.flush(timeoutMs),
1846
1930
  signal,
1847
1931
  throwIfCanceled: checkCanceled,
@@ -1904,6 +1988,8 @@ var RunReporter = class {
1904
1988
  progressDirty = false;
1905
1989
  progressTimer;
1906
1990
  progressWarned = false;
1991
+ /** Relógio do último eco `[hobots:progress]` no terminal (throttle do eco, não do wire). */
1992
+ progressLastPrint = 0;
1907
1993
  /** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
1908
1994
  fileStore;
1909
1995
  /** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
@@ -1954,15 +2040,17 @@ var RunReporter = class {
1954
2040
  /**
1955
2041
  * Linha de log da run com eco no terminal. `image` (opcional) vira o anexo da linha:
1956
2042
  * o id entra na entrada ANTES de qualquer I/O — é isso que mantém a ordem dos logs
1957
- * intacta e o handler livre da latência do upload.
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.
1958
2045
  */
1959
2046
  log(level, message, image3) {
1960
- if (this.dead) return;
2047
+ if (this.dead) return void 0;
1961
2048
  const scrubbed = scrubSecrets(safeString(message));
1962
2049
  const ts = nowISO();
1963
2050
  const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
1964
2051
  printRunLog(level, attachmentId ? `${scrubbed} [imagem]` : scrubbed);
1965
2052
  this.push(level, scrubbed, true, attachmentId ? [attachmentId] : void 0, ts);
2053
+ return attachmentId;
1966
2054
  }
1967
2055
  /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
1968
2056
  push(level, message, alreadyScrubbed = false, attachments, ts = nowISO()) {
@@ -2008,7 +2096,7 @@ var RunReporter = class {
2008
2096
  }
2009
2097
  }
2010
2098
  /**
2011
- * `hobots.flush()`: o bot decide quando entregar o que já fez — steps raiz finalizadas
2099
+ * `hobots.flush()`: o bot decide quando entregar o que já fez — etapas raiz finalizadas
2012
2100
  * viram transactions parciais da run, e itens, logs e progresso pendentes saem já, sem
2013
2101
  * esperar o throttle. Aguarda a entrega com teto. Nunca lança (pode ir num `finally`).
2014
2102
  */
@@ -2066,10 +2154,16 @@ var RunReporter = class {
2066
2154
  }
2067
2155
  const safeCurrent = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : this.progressCurrent;
2068
2156
  const next = total == null ? safeCurrent : Math.min(safeCurrent, total);
2069
- if (next === this.progressCurrent && total === this.progressTotal) return;
2157
+ const totalChanged = total !== this.progressTotal;
2158
+ if (next === this.progressCurrent && !totalChanged) return;
2070
2159
  this.progressCurrent = next;
2071
2160
  this.progressTotal = total;
2072
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);
2166
+ }
2073
2167
  this.progressDirty = true;
2074
2168
  if (this.progressTimer) return;
2075
2169
  this.progressTimer = setTimeout(() => {
@@ -2244,7 +2338,7 @@ var RunReporter = class {
2244
2338
  }
2245
2339
  // ── Anexos ───────────────────────────────────────────────────────────────────
2246
2340
  /**
2247
- * Pendura uma imagem num evento da run (log, item ou step): faz as checagens que não
2341
+ * Pendura uma imagem num evento da run (log, item ou etapa): faz as checagens que não
2248
2342
  * dependem de I/O (reporter morto, instalação sem anexos, cota), reserva o id e dispara
2249
2343
  * o upload em segundo plano. Devolve o id — já referenciável pelo evento — ou undefined
2250
2344
  * quando a imagem foi descartada (sempre com aviso no log da run). Nunca lança: todo
@@ -2417,11 +2511,13 @@ var RunReporter = class {
2417
2511
 
2418
2512
  // src/tasks/run-context-store.ts
2419
2513
  var storage;
2514
+ var stepStorage;
2420
2515
  var initPromise;
2421
2516
  async function ensureStorage() {
2422
2517
  if (storage || !isNode()) return;
2423
2518
  initPromise ??= import("async_hooks").then(({ AsyncLocalStorage: ALS }) => {
2424
2519
  storage = new ALS();
2520
+ stepStorage = new ALS();
2425
2521
  });
2426
2522
  await initPromise;
2427
2523
  }
@@ -2432,12 +2528,23 @@ async function runWithRunContext(store, fn) {
2432
2528
  function currentRunReporter() {
2433
2529
  return storage?.getStore()?.reporter;
2434
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
+ }
2435
2539
 
2436
2540
  // src/transaction.ts
2437
2541
  var FLUSH_TRANSACTION_OP = "hobots.run.flush";
2542
+ var DEFAULT_STEP_RETRIES = 1;
2543
+ var DEFAULT_STEP_RETRY_DELAY_MS = 5e3;
2438
2544
  var MAX_STEP_MESSAGE_CHARS = 2e3;
2545
+ var MAX_BACKLOG = 100;
2439
2546
  function stepMessage(text) {
2440
- return truncate(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
2547
+ return clip(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
2441
2548
  }
2442
2549
  function stepId() {
2443
2550
  return uuid4().slice(0, 16);
@@ -2445,6 +2552,13 @@ function stepId() {
2445
2552
  function stepLabel(op, description) {
2446
2553
  return [op, description].filter(Boolean).join(" \u2014 ") || "(sem nome)";
2447
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
+ }
2448
2562
  function levelForStatus(status) {
2449
2563
  switch (status) {
2450
2564
  case "warning":
@@ -2484,69 +2598,68 @@ var Step = class {
2484
2598
  startTimestamp = nowISO();
2485
2599
  status = "ok";
2486
2600
  endTimestamp;
2487
- data;
2488
- /**
2489
- * Desfecho da step. `message` (opcional) é o motivo legível — vai em `data.message`
2490
- * e aparece na Central (performance e banner da execução) e no log de FINISH.
2491
- * `options.image` pendura um print do estado da tela em `data.attachments`.
2492
- */
2493
- setStatus(status, message, options) {
2494
- this.status = status;
2495
- if (message) this.setData("message", stepMessage(message));
2601
+ _data;
2602
+ // ── API do bot ────────────────────────────────────────────────────────────
2603
+ warning(message, options) {
2604
+ if (this.status !== "error") this._setStatus("warning", message);
2496
2605
  if (options?.image !== void 0) this.attachImage(options.image);
2497
2606
  return this;
2498
2607
  }
2608
+ image(image3) {
2609
+ this.attachImage(image3);
2610
+ return this;
2611
+ }
2612
+ data(key, value) {
2613
+ if (key === "attachments") return this;
2614
+ this._setData(key, value);
2615
+ return this;
2616
+ }
2617
+ // ── Internos (chamados pelo SDK) ──────────────────────────────────────────
2618
+ /** `data` da etapa como vai no wire (undefined quando vazio). */
2619
+ get payload() {
2620
+ return this._data;
2621
+ }
2622
+ _setStatus(status, message) {
2623
+ this.status = status;
2624
+ if (message) this._setData("message", stepMessage(safeString(message)));
2625
+ return this;
2626
+ }
2499
2627
  /**
2500
- * Marca a step como `error` guardando o motivo em `data.error` ({ type, message }) e a
2501
- * fecha. Aceita qualquer valor lançado; `message` sobrescreve a mensagem do erro.
2502
- * `options.image` pendura um print do estado da tela em `data.attachments`.
2503
- * Numa step já fechada não faz nada.
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.
2504
2630
  */
2505
- failed(error, message, options) {
2631
+ _failed(error, message) {
2506
2632
  if (this.endTimestamp) return this;
2507
2633
  this.status = "error";
2508
2634
  const type = error instanceof Error ? error.name : typeof error === "object" && error !== null ? "Error" : void 0;
2509
- const raw = message ?? (error instanceof Error ? error.message || error.name : String(error));
2510
- this.setData("error", { ...type ? { type } : {}, message: stepMessage(raw) });
2511
- if (options?.image !== void 0) this.attachImage(options.image);
2512
- this.close();
2635
+ const raw = message ?? errorText(error);
2636
+ this._setData("error", { ...type ? { type } : {}, message: stepMessage(safeString(raw)) });
2637
+ this._close();
2513
2638
  return this;
2514
2639
  }
2640
+ _setData(key, value) {
2641
+ this._data = { ...this._data, [key]: value };
2642
+ }
2515
2643
  /**
2516
2644
  * Dispara o upload pela run (sink do RunReporter) e referencia o anexo em
2517
2645
  * `data.attachments` (chave reservada; teto igual ao das linhas de log). Fora de uma
2518
2646
  * run não há onde pendurar a imagem — descarta com aviso único.
2519
2647
  */
2520
2648
  attachImage(image3) {
2521
- const current = Array.isArray(this.data?.attachments) ? this.data.attachments : [];
2649
+ const current = Array.isArray(this._data?.attachments) ? this._data.attachments : [];
2522
2650
  if (current.length >= MAX_ATTACHMENTS_PER_LOG_ENTRY) return;
2523
2651
  const id = this.transaction.attachImage(image3);
2524
2652
  if (!id) return;
2525
- this.setData("attachments", [...current, id]);
2653
+ this._setData("attachments", [...current, id]);
2526
2654
  }
2527
- /** Motivo registrado via setStatus(…, message) ou failed — usado no log de FINISH. */
2655
+ /** Motivo registrado (warning/erro) — usado no log de FINISH. */
2528
2656
  reason() {
2529
- const error = this.data?.error;
2530
- const text = typeof error?.message === "string" ? error.message : this.data?.message;
2657
+ const error = this._data?.error;
2658
+ const text = typeof error?.message === "string" ? error.message : this._data?.message;
2531
2659
  return typeof text === "string" && text ? text : void 0;
2532
2660
  }
2533
- setData(key, value) {
2534
- this.data = { ...this.data, [key]: value };
2535
- return this;
2536
- }
2537
- /** Step aninhada (filha desta step). */
2538
- start(op, description) {
2539
- return this.transaction.createStep(this.stepId, op, description);
2540
- }
2541
- /**
2542
- * Fecha a step. O status fica o que estiver (`ok` por padrão, ou o de `setStatus`).
2543
- * Idempotente; a transaction fecha no fim da run o que ficou aberto.
2544
- */
2545
- succeeded() {
2546
- this.close();
2547
- return this;
2548
- }
2549
- close() {
2661
+ /** Fecha a etapa mantendo o status que estiver. Idempotente. */
2662
+ _close() {
2550
2663
  if (this.endTimestamp) return;
2551
2664
  this.endTimestamp = nowISO();
2552
2665
  this.transaction.emit(
@@ -2562,22 +2675,23 @@ var Step = class {
2562
2675
  toJSON() {
2563
2676
  return {
2564
2677
  step_id: this.stepId,
2565
- parent_step_id: this.parentStepId,
2566
- op: this.op,
2567
- description: this.description,
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) } : {},
2568
2681
  status: this.status,
2569
2682
  start_timestamp: this.startTimestamp,
2570
2683
  timestamp: this.endTimestamp ?? nowISO(),
2571
- data: this.data
2684
+ ...this._data ? { data: this._data } : {}
2572
2685
  };
2573
2686
  }
2574
2687
  };
2575
2688
  var Transaction = class {
2576
- constructor(client, name, op, runId) {
2689
+ constructor(client, name, op, runId, options = {}) {
2577
2690
  this.client = client;
2578
2691
  this.name = name;
2579
2692
  this.op = op;
2580
2693
  this.runId = runId;
2694
+ this.echo = options.echo ?? false;
2581
2695
  this.emit("transaction", "start", op ? `${name} (${op})` : name);
2582
2696
  }
2583
2697
  client;
@@ -2589,33 +2703,36 @@ var Transaction = class {
2589
2703
  startTimestamp = nowISO();
2590
2704
  status = "ok";
2591
2705
  steps = [];
2592
- tags = {};
2593
2706
  finished = false;
2594
2707
  lifecycleSink;
2595
2708
  attachmentSink;
2596
2709
  warnedNoAttachmentSink = false;
2597
- // instalado pelo RunReporter: abrir uma step é um ponto de parada cooperativo (lança RunCanceled)
2710
+ // instalado pelo RunReporter: abrir uma etapa é um ponto de parada cooperativo (lança RunCanceled)
2598
2711
  stepGuard;
2712
+ // modo local (sem client): o FINISH de cada etapa sai no terminal, como `[hobots:item]`
2713
+ echo;
2599
2714
  // eventos emitidos antes do sink existir (o START da transaction sai no construtor,
2600
2715
  // antes do RunReporter conectar) — replay no setLifecycleSink
2601
2716
  lifecycleBacklog = [];
2717
+ // ── Lifecycle / eco ───────────────────────────────────────────────────────
2602
2718
  /**
2603
- * Registra o início/fim de uma step ou transaction: espelha a linha no sink de
2604
- * lifecycle, se conectado (é assim que os eventos de performance aparecem nos
2605
- * logs da run no app), e ecoa no terminal apenas com `debug: true` — o SDK não
2606
- * escreve no stdout do processo do usuário sem permissão.
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.
2607
2723
  */
2608
2724
  emit(kind, phase, label, startTs, endTs, status, reason) {
2609
2725
  const durationMs = startTs && endTs ? new Date(endTs).getTime() - new Date(startTs).getTime() : void 0;
2610
2726
  const suffix = (durationMs != null ? ` (${durationMs}ms)` : "") + (reason ? `: ${reason}` : "");
2611
- if (this.client?.debug) {
2727
+ const localFinish = this.echo && kind === "step" && phase === "finish";
2728
+ if (this.client?.debug || localFinish) {
2612
2729
  const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
2613
2730
  console.log(`${tag} ${label}${suffix}`);
2614
2731
  }
2615
2732
  const level = phase === "finish" ? levelForStatus(status ?? "ok") : "info";
2616
2733
  const message = `[${kind}] ${phase.toUpperCase()} ${label}${suffix}`;
2617
2734
  if (this.lifecycleSink) this.lifecycleSink(level, message);
2618
- else if (this.lifecycleBacklog.length < 100) this.lifecycleBacklog.push([level, message]);
2735
+ else if (this.lifecycleBacklog.length < MAX_BACKLOG) this.lifecycleBacklog.push([level, message]);
2619
2736
  }
2620
2737
  /** Conecta o espelho de lifecycle (RunReporter) e faz replay do backlog em ordem. */
2621
2738
  setLifecycleSink(sink) {
@@ -2624,81 +2741,111 @@ var Transaction = class {
2624
2741
  this.lifecycleBacklog = [];
2625
2742
  for (const [level, message] of backlog) sink(level, message);
2626
2743
  }
2627
- /** Conecta o pipeline de anexos da run — é o que dá imagem às steps. */
2744
+ /** Conecta o pipeline de anexos da run — é o que dá imagem às etapas. */
2628
2745
  setAttachmentSink(sink) {
2629
2746
  this.attachmentSink = sink;
2630
2747
  }
2631
- /** Imagem de uma step → id do anexo pela run; sem run, descarta com aviso único. */
2748
+ /** Imagem de uma etapa → id do anexo pela run; sem run, descarta com aviso único. */
2632
2749
  attachImage(image3) {
2633
2750
  if (this.attachmentSink) return this.attachmentSink(image3, nowISO());
2634
2751
  if (!this.warnedNoAttachmentSink) {
2635
2752
  this.warnedNoAttachmentSink = true;
2636
- console.warn("[hobots] imagem de step descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
2753
+ console.warn("[hobots] imagem de etapa descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
2637
2754
  }
2638
2755
  return void 0;
2639
2756
  }
2757
+ // ── Etapas ────────────────────────────────────────────────────────────────
2640
2758
  /**
2641
- * Conecta a checagem de cancelamento chamada antes de cada step nova — é assim que
2642
- * `hobots.transaction.start()` (e `step.start()`) param uma run cancelada.
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.
2643
2761
  */
2644
2762
  setStepGuard(guard) {
2645
2763
  this.stepGuard = guard;
2646
2764
  }
2647
- createStep(parentStepId, op, description) {
2765
+ /** A etapa aberta por `step()` (ou `items.each`) no contexto async atual, se houver. */
2766
+ currentStep() {
2767
+ return currentStep(this);
2768
+ }
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);
2773
+ }
2774
+ /** Abre uma etapa sob a corrente (ou direta da transaction). Interno: o bot usa `step()`. */
2775
+ start(op, description) {
2648
2776
  this.stepGuard?.();
2649
- const step = new Step(this, parentStepId, op, description);
2777
+ const parent = this.currentStep();
2778
+ const step = new Step(this, parent ? parent.stepId : this.stepId, op, description);
2650
2779
  this.steps.push(step);
2651
2780
  this.emit("step", "start", stepLabel(op, description));
2652
2781
  return step;
2653
2782
  }
2654
- /** Step direta da transaction. `op` é o nome usado por `succeeded(name)`/`failed(error, name)`. */
2655
- start(op, description) {
2656
- return this.createStep(this.stepId, op, description);
2657
- }
2658
2783
  /**
2659
- * Fecha a step aberta mais recente — com `name`, a mais recente aberta com esse `op`.
2660
- * Sem step aberta (ou sem nenhuma com esse nome), não faz nada e devolve undefined.
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.
2661
2791
  */
2662
- succeeded(name) {
2663
- return this.findOpenStep(name)?.succeeded();
2664
- }
2665
- /**
2666
- * Marca como `error` e fecha a step aberta mais recente — com `name`, a mais recente
2667
- * aberta com esse `op`. `options.message` sobrescreve a mensagem do erro e
2668
- * `options.image` pendura um print. Sem step aberta, não faz nada e devolve undefined.
2669
- */
2670
- failed(error, name, options) {
2671
- return this.findOpenStep(name)?.failed(error, options?.message, options);
2672
- }
2673
- findOpenStep(name) {
2674
- for (let i = this.steps.length - 1; i >= 0; i--) {
2675
- const step = this.steps[i];
2676
- if (!step.endTimestamp && (name === void 0 || step.op === name)) return step;
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;
2798
+ try {
2799
+ const result = await retryWithBackoff(
2800
+ (attempt) => {
2801
+ attempts = attempt;
2802
+ return this.scope(step, async () => fn(step));
2803
+ },
2804
+ {
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
+ }
2816
+ );
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;
2823
+ } catch (error) {
2824
+ this.noteAttempts(step, attempts);
2825
+ if (isTerminalError(error)) step._setStatus("cancelled")._close();
2826
+ else step._failed(error);
2827
+ throw error;
2677
2828
  }
2678
- return void 0;
2679
2829
  }
2680
- setTag(key, value) {
2681
- this.tags[key] = value;
2682
- return this;
2830
+ noteAttempts(step, attempts) {
2831
+ if (attempts > 1) step._setData("attempts", attempts);
2683
2832
  }
2833
+ // ── Metadados ─────────────────────────────────────────────────────────────
2684
2834
  setStatus(status) {
2685
2835
  this.status = status;
2686
2836
  return this;
2687
2837
  }
2688
- setName(name) {
2689
- this.name = name;
2690
- return this;
2691
- }
2838
+ // ── Encerramento ──────────────────────────────────────────────────────────
2692
2839
  /**
2693
- * Envia já as steps raiz finalizadas (e todas as descendentes, fechando as que
2840
+ * Envia já as etapas raiz finalizadas (e todas as descendentes, fechando as que
2694
2841
  * ficaram abertas) como transactions parciais da mesma run (`op` hobots.run.flush,
2695
- * `event_id` novo, até 200 steps cada) e as tira da memória. Steps raiz ainda abertas
2842
+ * `event_id` novo, até 200 etapas cada) e as tira da memória. Etapas raiz ainda abertas
2696
2843
  * ficam para o próximo flush ou para o `finish()`. É assim que um agente sempre
2697
2844
  * ligado (`start()`), cuja run não termina, entrega as etapas enquanto processa.
2698
- * Devolve quantas steps foram enviadas.
2845
+ * Devolve quantas etapas foram enviadas (0 sem client, sem nada terminado ou depois do `finish()`).
2699
2846
  */
2700
2847
  flush() {
2701
- if (this.finished) return 0;
2848
+ if (!this.client || this.finished) return 0;
2702
2849
  const ready = /* @__PURE__ */ new Set();
2703
2850
  for (const step of this.steps) {
2704
2851
  if (step.parentStepId === this.stepId && step.endTimestamp) ready.add(step.stepId);
@@ -2714,15 +2861,13 @@ var Transaction = class {
2714
2861
  remaining.push(step);
2715
2862
  }
2716
2863
  }
2717
- for (const step of batch) step.succeeded();
2864
+ for (const step of batch) step._close();
2718
2865
  this.steps.splice(0, this.steps.length, ...remaining);
2719
- if (this.client) {
2720
- for (let i = 0; i < batch.length; i += MAX_STEPS) {
2721
- const chunk = batch.slice(i, i + MAX_STEPS);
2722
- const startTimestamp = chunk.reduce((min, step) => step.startTimestamp < min ? step.startTimestamp : min, chunk[0].startTimestamp);
2723
- const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2724
- this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, startTimestamp, nowISO(), chunk));
2725
- }
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));
2726
2871
  }
2727
2872
  return batch.length;
2728
2873
  }
@@ -2739,14 +2884,15 @@ var Transaction = class {
2739
2884
  start_timestamp: startTimestamp,
2740
2885
  timestamp,
2741
2886
  steps: steps.map((step) => step.toJSON()),
2742
- tags: Object.keys(this.tags).length > 0 ? { ...this.tags } : void 0,
2887
+ // sem tags próprias: o client mescla as do processo
2888
+ tags: void 0,
2743
2889
  run_id: this.runId
2744
2890
  };
2745
2891
  }
2746
2892
  finish() {
2747
2893
  if (this.finished) return;
2748
2894
  this.finished = true;
2749
- for (const step of this.steps) step.succeeded();
2895
+ for (const step of this.steps) step._close();
2750
2896
  const endTimestamp = nowISO();
2751
2897
  this.emit(
2752
2898
  "transaction",
@@ -2774,7 +2920,7 @@ var DEFAULT_RETRY_DELAY_MS = 5e3;
2774
2920
  async function executeRun(input) {
2775
2921
  const { run, handler, api, client, logger, tracker } = input;
2776
2922
  const options = handler.options;
2777
- const tx = new Transaction(client, "run", "hobots.run", run.id);
2923
+ const tx = new Transaction(client, "run", "hobots.run", run.id, { echo: !client });
2778
2924
  const reporter = new RunReporter(
2779
2925
  api,
2780
2926
  run.id,
@@ -3014,42 +3160,26 @@ var TaskAgent = class {
3014
3160
  }
3015
3161
  };
3016
3162
 
3017
- // src/tasks/attached.ts
3018
- var START_RETRY_DELAYS_MS = [1e3, 4e3, 16e3];
3019
- function isTransient(error) {
3020
- if (!(error instanceof TasksApiError)) return true;
3021
- return error.status >= 500 || error.status === 429;
3022
- }
3023
- function describeRejection(runId, error) {
3024
- if (error instanceof TasksApiError && error.status === 404) {
3025
- return `run ${runId} n\xE3o existe na plataforma (404) \u2014 nada a executar`;
3026
- }
3027
- if (error instanceof TasksApiError && error.status === 409) {
3028
- return `run ${runId} n\xE3o pode ser iniciada (409: cancelada, j\xE1 iniciada ou de outra inst\xE2ncia) \u2014 nada a executar`;
3029
- }
3030
- return `n\xE3o foi poss\xEDvel iniciar a run ${runId} (${String(error)}) \u2014 nada a executar`;
3031
- }
3032
- var AttachedRunner = class {
3033
- constructor(runId, handler, deps) {
3034
- this.runId = runId;
3163
+ // src/tasks/single-run.ts
3164
+ var SingleRunRunner = class {
3165
+ constructor(handler, deps) {
3035
3166
  this.handler = handler;
3036
3167
  this.deps = deps;
3037
3168
  this.done = new Promise((resolve2) => {
3038
3169
  this.resolveDone = resolve2;
3039
3170
  });
3040
3171
  }
3041
- runId;
3042
3172
  handler;
3043
3173
  deps;
3044
3174
  abort = new AbortController();
3045
- /** Resolve quando a run terminou (ou não pôde ser iniciada, ou o runner foi parado antes). */
3175
+ /** Resolve quando a run terminou (ou não pôde começar, ou o runner foi parado antes). */
3046
3176
  done;
3047
3177
  resolveDone;
3048
3178
  async run() {
3049
3179
  try {
3050
- const run = await this.startRun();
3180
+ const run = await this.openRun();
3051
3181
  if (!run) return;
3052
- this.deps.logger.debug(`run ${run.id} iniciada (HOBOTS_RUN_ID)`);
3182
+ this.deps.logger.debug(this.opened(run));
3053
3183
  await executeRun({
3054
3184
  run,
3055
3185
  handler: this.handler,
@@ -3064,11 +3194,37 @@ var AttachedRunner = class {
3064
3194
  this.resolveDone();
3065
3195
  }
3066
3196
  }
3067
- /** Desiste de iniciar a run (close() antes da plataforma responder). Uma run já iniciada segue pelo tracker. */
3197
+ /** Desiste de obter a run (close() antes dela existir). Uma run já em execução segue pelo tracker. */
3068
3198
  stop() {
3069
3199
  this.abort.abort();
3070
3200
  }
3071
- async startRun() {
3201
+ };
3202
+
3203
+ // src/tasks/attached.ts
3204
+ var START_RETRY_DELAYS_MS = [1e3, 4e3, 16e3];
3205
+ function isTransient(error) {
3206
+ if (!(error instanceof TasksApiError)) return true;
3207
+ return error.status >= 500 || error.status === 429;
3208
+ }
3209
+ function describeRejection(runId, error) {
3210
+ if (error instanceof TasksApiError && error.status === 404) {
3211
+ return `run ${runId} n\xE3o existe na plataforma (404) \u2014 nada a executar`;
3212
+ }
3213
+ if (error instanceof TasksApiError && error.status === 409) {
3214
+ return `run ${runId} n\xE3o pode ser iniciada (409: cancelada, j\xE1 iniciada ou de outra inst\xE2ncia) \u2014 nada a executar`;
3215
+ }
3216
+ return `n\xE3o foi poss\xEDvel iniciar a run ${runId} (${String(error)}) \u2014 nada a executar`;
3217
+ }
3218
+ var AttachedRunner = class extends SingleRunRunner {
3219
+ constructor(runId, handler, deps) {
3220
+ super(handler, deps);
3221
+ this.runId = runId;
3222
+ }
3223
+ runId;
3224
+ opened(run) {
3225
+ return `run ${run.id} iniciada (HOBOTS_RUN_ID)`;
3226
+ }
3227
+ async openRun() {
3072
3228
  for (let attempt = 0; ; attempt++) {
3073
3229
  if (this.abort.signal.aborted) return void 0;
3074
3230
  try {
@@ -3131,46 +3287,16 @@ function normalizeItemColumns(input, logger) {
3131
3287
  }
3132
3288
 
3133
3289
  // src/tasks/local.ts
3134
- var LocalRunner = class {
3290
+ var LocalRunner = class extends SingleRunRunner {
3291
+ backoff;
3135
3292
  constructor(handler, deps) {
3136
- this.handler = handler;
3137
- this.deps = deps;
3293
+ super(handler, deps);
3138
3294
  this.backoff = new Backoff(deps.logger, "cria\xE7\xE3o da run");
3139
- this.done = new Promise((resolve2) => {
3140
- this.resolveDone = resolve2;
3141
- });
3142
3295
  }
3143
- handler;
3144
- deps;
3145
- abort = new AbortController();
3146
- backoff;
3147
- /** Resolve quando a run terminou (ou o runner foi parado antes de abri-la). */
3148
- done;
3149
- resolveDone;
3150
- async run() {
3151
- try {
3152
- const run = await this.createRun();
3153
- if (!run) return;
3154
- this.deps.logger.debug(`run ${run.id} aberta (modo start)`);
3155
- await executeRun({
3156
- run,
3157
- handler: this.handler,
3158
- api: this.deps.api,
3159
- client: this.deps.client,
3160
- logger: this.deps.logger,
3161
- tracker: this.deps.tracker
3162
- });
3163
- } catch (error) {
3164
- this.deps.logger.error("erro inesperado na execu\xE7\xE3o do agente:", error);
3165
- } finally {
3166
- this.resolveDone();
3167
- }
3296
+ opened(run) {
3297
+ return `run ${run.id} aberta (modo start)`;
3168
3298
  }
3169
- /** Desiste de abrir a run (close() antes da plataforma responder). Uma run já aberta segue pelo tracker. */
3170
- stop() {
3171
- this.abort.abort();
3172
- }
3173
- async createRun() {
3299
+ async openRun() {
3174
3300
  for (; ; ) {
3175
3301
  if (this.abort.signal.aborted) return void 0;
3176
3302
  try {
@@ -3183,6 +3309,129 @@ var LocalRunner = class {
3183
3309
  }
3184
3310
  };
3185
3311
 
3312
+ // src/tasks/local-transport.ts
3313
+ var LOCAL_RUN_ID = "local";
3314
+ var ITEM_LINE_PREFIX = "[hobots:item]";
3315
+ var SUMMARY_LINE_PREFIX = "[hobots:summary]";
3316
+ var ATTACHMENTS_DISCARDED_WARNING = "imagens e anexos n\xE3o s\xE3o enviados no modo local (descartados)";
3317
+ function unavailable(name) {
3318
+ return new Error(`arquivo "${name}" enviado pela Central n\xE3o est\xE1 dispon\xEDvel no modo local`);
3319
+ }
3320
+ function cell(value) {
3321
+ return (value ?? "").replace(/\r\n|[\t\r\n]/g, " ");
3322
+ }
3323
+ var LocalRunTransport = class {
3324
+ constructor(params = {}, out = (line) => void process.stdout.write(`${line}
3325
+ `)) {
3326
+ this.params = params;
3327
+ this.out = out;
3328
+ }
3329
+ params;
3330
+ out;
3331
+ counts = { succeeded: 0, occurrence: 0, failed: 0 };
3332
+ warnedAttachments = false;
3333
+ /** Status terminal que o pipeline reportou — `undefined` enquanto a run corre (ou se ela nem reportou). */
3334
+ terminalStatus;
3335
+ async updateRun(_runId, update) {
3336
+ if (update.status === "running") return;
3337
+ this.terminalStatus = update.status;
3338
+ if (update.status === "failed" && update.error && update.error.type !== "RunCanceled") {
3339
+ printRunLog("error", `execu\xE7\xE3o falhou \u2014 ${update.error.type ?? "Error"}: ${update.error.message}`);
3340
+ if (update.error.stack) console.error(update.error.stack);
3341
+ }
3342
+ }
3343
+ async updateRunOnce() {
3344
+ }
3345
+ async createItems(_runId, request) {
3346
+ for (const item of request.items) {
3347
+ this.counts[item.status]++;
3348
+ this.out(`${ITEM_LINE_PREFIX} ${item.status} ${cell(item.id)} ${cell(item.message)}`);
3349
+ }
3350
+ return { accepted: request.items.length, duplicates: 0 };
3351
+ }
3352
+ /** Totais por status — chame uma vez, no fim da run, antes da linha de conclusão. */
3353
+ printSummary() {
3354
+ const { succeeded, occurrence, failed } = this.counts;
3355
+ this.out(
3356
+ `${SUMMARY_LINE_PREFIX} total=${succeeded + occurrence + failed} ok=${succeeded} avisos=${occurrence} falhas=${failed} pulados=0`
3357
+ );
3358
+ }
3359
+ async createAttachment(_runId, request) {
3360
+ if (!this.warnedAttachments) {
3361
+ this.warnedAttachments = true;
3362
+ printRunLog("warning", ATTACHMENTS_DISCARDED_WARNING);
3363
+ }
3364
+ return { id: request.id, upload_url: "", headers: {}, expires_in: 0 };
3365
+ }
3366
+ async uploadAttachment() {
3367
+ }
3368
+ async confirmAttachment() {
3369
+ }
3370
+ /** Nome do arquivo pelo id (nos params); sem ele, o próprio id. */
3371
+ fileName(fileId) {
3372
+ return collectFileRefs(this.params).find((ref) => ref.id === fileId)?.name ?? fileId;
3373
+ }
3374
+ async requestFileDownload(_runId, fileId) {
3375
+ throw unavailable(this.fileName(fileId));
3376
+ }
3377
+ async fetchFile(url) {
3378
+ throw unavailable(url);
3379
+ }
3380
+ async confirmFileDownload(_runId, fileId) {
3381
+ throw unavailable(this.fileName(fileId));
3382
+ }
3383
+ };
3384
+
3385
+ // src/tasks/local-run.ts
3386
+ var LOCAL_RUN_EXIT_CODES = { succeeded: 0, failed: 1, canceled: 130 };
3387
+ var LOCAL_RUN_CANCEL_GRACE_MS = 4e3;
3388
+ var LOCAL_RUN_BANNER = "modo local \u2014 execu\xE7\xE3o sem a Central; params de HOBOTS_RUN_PARAMS";
3389
+ var LocalRunRunner = class extends SingleRunRunner {
3390
+ constructor(params, handler, deps) {
3391
+ super(handler, deps);
3392
+ this.params = params;
3393
+ }
3394
+ params;
3395
+ canceled = false;
3396
+ startedAt = Date.now();
3397
+ finished = false;
3398
+ run() {
3399
+ this.startedAt = Date.now();
3400
+ return super.run();
3401
+ }
3402
+ opened() {
3403
+ return "run local iniciada (HOBOTS_LOCAL_RUN)";
3404
+ }
3405
+ async openRun() {
3406
+ if (this.abort.signal.aborted) return void 0;
3407
+ return { id: LOCAL_RUN_ID, params: this.params, form: null, source: "local", created_at: nowISO() };
3408
+ }
3409
+ /** SIGINT/SIGTERM: dispara o `ctx.signal` da run em voo — o handler para no próximo ponto de parada. */
3410
+ cancel() {
3411
+ this.canceled = true;
3412
+ this.deps.tracker.singleActive()?.abort();
3413
+ }
3414
+ /** Desfecho da run: cancelada pelo sinal, senão o status terminal reportado (sem reporte = falha). */
3415
+ get outcome() {
3416
+ if (this.canceled) return "canceled";
3417
+ return this.deps.api.terminalStatus ?? "failed";
3418
+ }
3419
+ /**
3420
+ * Fecha o relato no terminal — `[hobots:summary]` e a linha de conclusão — e devolve o
3421
+ * desfecho. Idempotente: o runtime chama ao fim da run ou quando a graça do cancelamento
3422
+ * esgota com o handler ainda rodando.
3423
+ */
3424
+ finish() {
3425
+ const outcome = this.outcome;
3426
+ if (this.finished) return outcome;
3427
+ this.finished = true;
3428
+ this.deps.api.printSummary();
3429
+ const seconds = Math.round((Date.now() - this.startedAt) / 1e3);
3430
+ printRunLog("info", `execu\xE7\xE3o local conclu\xEDda (${outcome}) em ${seconds}s`);
3431
+ return outcome;
3432
+ }
3433
+ };
3434
+
3186
3435
  // src/tasks/registry.ts
3187
3436
  var RunTracker = class {
3188
3437
  constructor(api) {
@@ -3298,26 +3547,10 @@ function hostname() {
3298
3547
  if (!isNode()) return void 0;
3299
3548
  return process.env.HOSTNAME || process.env.COMPUTERNAME || void 0;
3300
3549
  }
3301
- function createRuntime(options, handler) {
3302
- loadDotenv();
3303
- const config = resolveConfig({ clientSecret: options.clientSecret });
3304
- const logger = makeLogger(options.debug ?? false);
3305
- const environment = resolveEnvironmentOption(options.environment);
3306
- const client = new Client(
3307
- {
3308
- environment,
3309
- release: options.release,
3310
- tags: options.tags,
3311
- debug: options.debug,
3312
- captureRunErrors: options.captureUnhandled !== false
3313
- },
3314
- config
3315
- );
3316
- const api = new TasksApi({ baseUrl: config.runsBaseUrl, key: config.key, logger });
3317
- const rt = {
3550
+ function baseRuntime(options, handler, logger, api) {
3551
+ return {
3318
3552
  options: { ...options, clientSecret: void 0 },
3319
- config,
3320
- client,
3553
+ client: void 0,
3321
3554
  logger,
3322
3555
  api,
3323
3556
  handler,
@@ -3327,19 +3560,54 @@ function createRuntime(options, handler) {
3327
3560
  agent: void 0,
3328
3561
  local: void 0,
3329
3562
  attached: void 0,
3563
+ localRun: void 0,
3330
3564
  runId: void 0,
3331
3565
  instanceId: resolveInstanceId(options.instanceId),
3332
- environment,
3566
+ environment: void 0,
3333
3567
  pollIntervalMs: void 0,
3334
3568
  booted: false,
3335
3569
  bootScheduled: false,
3336
3570
  bootPromise: void 0,
3337
- uninstallGlobalHandlers: options.captureUnhandled !== false ? installGlobalHandlers(client) : void 0,
3571
+ uninstallGlobalHandlers: void 0,
3338
3572
  uninstallShutdown: void 0
3339
3573
  };
3574
+ }
3575
+ function createRuntime(options, handler, logger) {
3576
+ const config = resolveConfig({ clientSecret: options.clientSecret });
3577
+ const environment = resolveEnvironmentOption(options.environment);
3578
+ const client = new Client(
3579
+ {
3580
+ environment,
3581
+ release: options.release,
3582
+ tags: options.tags,
3583
+ debug: options.debug,
3584
+ captureRunErrors: options.captureUnhandled !== false
3585
+ },
3586
+ config
3587
+ );
3588
+ const api = new TasksApi({ baseUrl: config.runsBaseUrl, key: config.key, logger });
3589
+ const rt = baseRuntime(options, handler, logger, api);
3590
+ rt.client = client;
3591
+ rt.environment = environment;
3592
+ rt.uninstallGlobalHandlers = options.captureUnhandled !== false ? installGlobalHandlers(client) : void 0;
3340
3593
  logger.debug("inicializado", { endpoint: config.ingestEndpoint });
3341
3594
  return rt;
3342
3595
  }
3596
+ function createLocalRunRuntime(options, handler, logger) {
3597
+ let params;
3598
+ try {
3599
+ params = resolveLocalRunParams();
3600
+ } catch (error) {
3601
+ printRunLog("error", error instanceof Error ? error.message : String(error));
3602
+ process.exit(2);
3603
+ throw error;
3604
+ }
3605
+ const transport = new LocalRunTransport(params);
3606
+ const rt = baseRuntime(options, handler, logger, transport);
3607
+ rt.localRun = new LocalRunRunner(params, handler, { api: transport, client: void 0, logger, tracker: rt.tracker });
3608
+ printRunLog("info", LOCAL_RUN_BANNER);
3609
+ return rt;
3610
+ }
3343
3611
  function validateHandler(handler, fn) {
3344
3612
  if (typeof handler !== "function") {
3345
3613
  throw new Error(`[hobots] ${fn}(): o handler precisa ser uma fun\xE7\xE3o \u2014 ${fn}(async (params) => { ... })`);
@@ -3382,9 +3650,12 @@ function instanceInfo(rt) {
3382
3650
  machine: rt.sampler?.sample()
3383
3651
  };
3384
3652
  }
3653
+ function platformApi(rt) {
3654
+ return rt.api;
3655
+ }
3385
3656
  function ensureAgent(rt) {
3386
3657
  rt.agent ??= new TaskAgent({
3387
- api: rt.api,
3658
+ api: platformApi(rt),
3388
3659
  client: rt.client,
3389
3660
  logger: rt.logger,
3390
3661
  handler: rt.handler,
@@ -3396,7 +3667,7 @@ function ensureAgent(rt) {
3396
3667
  }
3397
3668
  function launchLocal(rt) {
3398
3669
  const runner = new LocalRunner(rt.handler, {
3399
- api: rt.api,
3670
+ api: platformApi(rt),
3400
3671
  client: rt.client,
3401
3672
  logger: rt.logger,
3402
3673
  tracker: rt.tracker,
@@ -3407,7 +3678,7 @@ function launchLocal(rt) {
3407
3678
  }
3408
3679
  function launchAttached(rt, runId) {
3409
3680
  const runner = new AttachedRunner(runId, rt.handler, {
3410
- api: rt.api,
3681
+ api: platformApi(rt),
3411
3682
  client: rt.client,
3412
3683
  logger: rt.logger,
3413
3684
  tracker: rt.tracker,
@@ -3419,7 +3690,43 @@ function launchAttached(rt, runId) {
3419
3690
  if (runtime === rt) void close();
3420
3691
  });
3421
3692
  }
3693
+ function launchLocalRun(rt, runner) {
3694
+ let finished = false;
3695
+ const finish = () => {
3696
+ if (finished) return;
3697
+ finished = true;
3698
+ rt.uninstallShutdown?.();
3699
+ rt.uninstallShutdown = void 0;
3700
+ const code = LOCAL_RUN_EXIT_CODES[runner.finish()];
3701
+ if (runtime === rt) void close(0);
3702
+ process.stdout.write("", () => process.exit(code));
3703
+ };
3704
+ if (rt.options.installExitHandlers !== false) {
3705
+ let signaled = false;
3706
+ const onSignal = (signal) => {
3707
+ if (externalListenerCount(signal) > 0 || signaled) return;
3708
+ signaled = true;
3709
+ runner.cancel();
3710
+ void Promise.race([runner.done, sleep(LOCAL_RUN_CANCEL_GRACE_MS)]).then(finish);
3711
+ };
3712
+ const onInt = markInternal(() => onSignal("SIGINT"));
3713
+ const onTerm = markInternal(() => onSignal("SIGTERM"));
3714
+ process.on("SIGINT", onInt);
3715
+ process.on("SIGTERM", onTerm);
3716
+ rt.uninstallShutdown = () => {
3717
+ process.off("SIGINT", onInt);
3718
+ process.off("SIGTERM", onTerm);
3719
+ };
3720
+ }
3721
+ void runner.run();
3722
+ void runner.done.then(finish);
3723
+ }
3422
3724
  async function boot(rt) {
3725
+ if (rt.localRun) {
3726
+ rt.booted = true;
3727
+ launchLocalRun(rt, rt.localRun);
3728
+ return;
3729
+ }
3423
3730
  if (!rt.instanceId) {
3424
3731
  const id = await fallbackInstanceId();
3425
3732
  if (runtime !== rt) return;
@@ -3456,9 +3763,12 @@ function startAgent(fn, mode, handler, options) {
3456
3763
  }
3457
3764
  const logger = makeLogger(options.debug ?? false);
3458
3765
  const agentHandler = { mode, handler, options: resolveRunOptions(options, mode, logger) };
3459
- const rt = createRuntime(options, agentHandler);
3460
- if (mode === "remote" && options.pollIntervalMs !== void 0) rt.pollIntervalMs = options.pollIntervalMs;
3461
- if (mode === "remote") rt.runId = resolveRunId(options.runId);
3766
+ loadDotenv();
3767
+ const rt = isLocalRun() ? createLocalRunRuntime(options, agentHandler, logger) : createRuntime(options, agentHandler, logger);
3768
+ if (!rt.localRun && mode === "remote") {
3769
+ if (options.pollIntervalMs !== void 0) rt.pollIntervalMs = options.pollIntervalMs;
3770
+ rt.runId = resolveRunId(options.runId);
3771
+ }
3462
3772
  runtime = rt;
3463
3773
  scheduleBoot(rt);
3464
3774
  }
@@ -3481,10 +3791,10 @@ function forwardAttachment(rt, attachment, message, type) {
3481
3791
  const reporter = currentReporter();
3482
3792
  if (!reporter) {
3483
3793
  rt.logger.warn("attachment descartado \u2014 s\xF3 \xE9 suportado dentro de uma execu\xE7\xE3o");
3484
- return;
3794
+ return void 0;
3485
3795
  }
3486
3796
  const withMeta = typeof attachment === "object" && !isImageAttachment(attachment) && "image" in attachment ? image(attachment.image, { caption: attachment.caption, contentType: attachment.contentType }) : attachment;
3487
- reporter.log(toRunLogLevel(type), message, withMeta);
3797
+ return reporter.log(toRunLogLevel(type), message, withMeta);
3488
3798
  }
3489
3799
  function captureException(error, hint) {
3490
3800
  const rt = runtime;
@@ -3495,13 +3805,19 @@ function captureException(error, hint) {
3495
3805
  }
3496
3806
  return void 0;
3497
3807
  }
3808
+ let attachmentId;
3498
3809
  if (hint?.attachment) {
3499
3810
  const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
3500
- forwardAttachment(rt, hint.attachment, message, hint.type ?? "error");
3811
+ attachmentId = forwardAttachment(rt, hint.attachment, message, hint.type ?? "error");
3812
+ }
3813
+ if (!rt.client) {
3814
+ rt.logger.debug("captureException ignorado no modo local (sem Central)");
3815
+ return void 0;
3501
3816
  }
3502
3817
  const runId = currentReporter()?.runId;
3503
3818
  const withRun = runId ? { ...hint, tags: { run_id: runId, ...hint?.tags } } : hint;
3504
- return rt.client.captureException(error, withRun);
3819
+ const contexts = runId && attachmentId ? { run: { id: runId, attachments: [attachmentId] } } : void 0;
3820
+ return rt.client.captureException(error, withRun, contexts);
3505
3821
  }
3506
3822
  async function close(timeoutMs = 3e4) {
3507
3823
  const rt = runtime;
@@ -3511,6 +3827,7 @@ async function close(timeoutMs = 3e4) {
3511
3827
  if (rt.agent) await rt.agent.stop(0);
3512
3828
  rt.local?.stop();
3513
3829
  rt.attached?.stop();
3830
+ rt.localRun?.stop();
3514
3831
  const idle = await rt.tracker.waitIdle(timeoutMs);
3515
3832
  if (!idle) {
3516
3833
  rt.logger.warn(`${rt.tracker.activeCount} run(s) ainda em execu\xE7\xE3o ap\xF3s ${timeoutMs}ms \u2014 abandonando a espera`);
@@ -3519,7 +3836,7 @@ async function close(timeoutMs = 3e4) {
3519
3836
  rt.heartbeat?.stop();
3520
3837
  rt.uninstallShutdown?.();
3521
3838
  rt.uninstallGlobalHandlers?.();
3522
- const flushed = await rt.client.close(timeoutMs);
3839
+ const flushed = rt.client ? await rt.client.close(timeoutMs) : true;
3523
3840
  return idle && flushed;
3524
3841
  }
3525
3842
 
@@ -3548,11 +3865,13 @@ var noop = () => void 0;
3548
3865
  var fallbackEachSink = {
3549
3866
  report: noop,
3550
3867
  startStep: () => void 0,
3868
+ stepScope: (_step, fn) => fn(),
3551
3869
  declareTotal: noop,
3552
3870
  advance: noop,
3553
3871
  checkCanceled: noop,
3554
3872
  isAborted: () => false,
3555
- logError: (message) => printRunLog("error", message)
3873
+ logError: (message) => printRunLog("error", message),
3874
+ flush: () => Promise.resolve()
3556
3875
  };
3557
3876
  var fallbackItems = {
3558
3877
  add: noop,
@@ -3571,7 +3890,6 @@ var fallbackFiles = {
3571
3890
  read: outsideRunFiles
3572
3891
  };
3573
3892
  var neverAborts = new AbortController().signal;
3574
- var detachedTransaction;
3575
3893
  function resolve(member) {
3576
3894
  const reporter = currentReporter();
3577
3895
  if (reporter) return reporter.context;
@@ -3592,8 +3910,12 @@ var hobots = Object.defineProperties({}, {
3592
3910
  items: getter("items", () => fallbackItems),
3593
3911
  progress: getter("progress", () => fallbackProgress),
3594
3912
  files: getter("files", () => fallbackFiles),
3595
- transaction: getter("transaction", () => detachedTransaction ??= new Transaction(void 0, "hobots.detached")),
3596
3913
  signal: getter("signal", () => neverAborts),
3914
+ step: method((name, fn, options) => {
3915
+ const ctx = resolve("step");
3916
+ if (ctx) return ctx.step(name, fn, options);
3917
+ return new Transaction(void 0, "hobots.detached").step(name, fn, options);
3918
+ }),
3597
3919
  throwIfCanceled: method(() => resolve("throwIfCanceled")?.throwIfCanceled()),
3598
3920
  sleep: method((ms) => resolve("sleep")?.sleep(ms) ?? sleep(ms)),
3599
3921
  flush: method((timeoutMs) => resolve("flush")?.flush(timeoutMs) ?? Promise.resolve()),