hobots 4.5.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
+ Occurrence: () => Occurrence,
33
34
  captureException: () => captureException2,
34
35
  close: () => close2,
35
36
  default: () => src_default,
@@ -44,7 +45,7 @@ module.exports = __toCommonJS(src_exports);
44
45
 
45
46
  // src/utils.ts
46
47
  var SDK_NAME = "hobots";
47
- var SDK_VERSION = true ? "4.5.0" : "0.0.0-dev";
48
+ var SDK_VERSION = true ? "5.1.0" : "0.0.0-dev";
48
49
  function isNode() {
49
50
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
50
51
  }
@@ -97,8 +98,13 @@ function abortableSleep(ms, signal) {
97
98
  function jitter(baseMs) {
98
99
  return Math.floor(baseMs / 2 + Math.random() * (baseMs / 2));
99
100
  }
101
+ var TRUNCATION_SUFFIX = "\u2026 [truncado]";
100
102
  function truncate(value, max) {
101
- return value.length > max ? `${value.slice(0, max)}\u2026 [truncado]` : value;
103
+ return value.length > max ? `${value.slice(0, max)}${TRUNCATION_SUFFIX}` : value;
104
+ }
105
+ function clip(value, max) {
106
+ if (value.length <= max) return value;
107
+ return `${value.slice(0, Math.max(0, max - TRUNCATION_SUFFIX.length))}${TRUNCATION_SUFFIX}`;
102
108
  }
103
109
  function safeString(value) {
104
110
  if (typeof value === "string") return value;
@@ -196,6 +202,8 @@ function colorize(text, color) {
196
202
  var INSTANCE_ID_ENV = "HOBOTS_INSTANCE_ID";
197
203
  var ENVIRONMENT_ENV = "HOBOTS_ENVIRONMENT";
198
204
  var RUN_ID_ENV = "HOBOTS_RUN_ID";
205
+ var LOCAL_RUN_ENV = "HOBOTS_LOCAL_RUN";
206
+ var RUN_PARAMS_ENV = "HOBOTS_RUN_PARAMS";
199
207
  var dotenvLoaded = false;
200
208
  function loadDotenv() {
201
209
  if (dotenvLoaded) return;
@@ -221,6 +229,24 @@ function resolveRunId(option) {
221
229
  if (typeof process === "undefined" || !process.env) return void 0;
222
230
  return process.env[RUN_ID_ENV]?.trim() || void 0;
223
231
  }
232
+ function isLocalRun() {
233
+ if (typeof process === "undefined" || !process.env) return false;
234
+ return process.env[LOCAL_RUN_ENV]?.trim() === "1";
235
+ }
236
+ function resolveLocalRunParams() {
237
+ const raw = process.env[RUN_PARAMS_ENV]?.trim();
238
+ if (!raw) return {};
239
+ let parsed;
240
+ try {
241
+ parsed = JSON.parse(raw);
242
+ } catch {
243
+ throw new Error(`${RUN_PARAMS_ENV} inv\xE1lido: n\xE3o \xE9 JSON v\xE1lido`);
244
+ }
245
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
246
+ throw new Error(`${RUN_PARAMS_ENV} inv\xE1lido: esperado um objeto JSON`);
247
+ }
248
+ return parsed;
249
+ }
224
250
  function resolveEnvironmentOption(option) {
225
251
  const fromOption = option?.trim();
226
252
  if (fromOption) return fromOption;
@@ -332,6 +358,7 @@ var protocol_limits_default = {
332
358
  MAX_ITEM_PAYLOAD_BYTES: 16384,
333
359
  MAX_ITEM_COLUMNS: 12,
334
360
  MAX_ITEM_COLUMN_LABEL_CHARS: 60,
361
+ MAX_ITEMS_DONE_PER_PAGE: 1e3,
335
362
  MAX_ATTACHMENT_BYTES: 5242880,
336
363
  MAX_ATTACHMENTS_PER_RUN: 20,
337
364
  MAX_ATTACHMENTS_PER_LOG_ENTRY: 4,
@@ -460,9 +487,18 @@ var MAX_FINGERPRINT_ITEMS = 10;
460
487
  var MAX_FINGERPRINT_ITEM_CHARS = 255;
461
488
  var MAX_TRANSACTION_NAME_CHARS = 500;
462
489
  var MAX_STEPS = 200;
490
+ var MAX_STEP_DESCRIPTION_CHARS = 2048;
491
+ var MAX_STEP_OP_CHARS = 255;
463
492
  function clipTags(tags) {
464
493
  return Object.fromEntries(Object.entries(tags).map(([key, value]) => [key, truncate(String(value), MAX_TAG_VALUE_CHARS)]));
465
494
  }
495
+ function clipStep(step) {
496
+ return {
497
+ ...step,
498
+ ...step.op ? { op: clip(step.op, MAX_STEP_OP_CHARS) } : {},
499
+ ...step.description ? { description: clip(step.description, MAX_STEP_DESCRIPTION_CHARS) } : {}
500
+ };
501
+ }
466
502
  function clipFingerprint(fingerprint) {
467
503
  if (fingerprint === void 0) return void 0;
468
504
  return fingerprint.slice(0, MAX_FINGERPRINT_ITEMS).map((item) => truncate(String(item), MAX_FINGERPRINT_ITEM_CHARS));
@@ -521,12 +557,13 @@ var Client = class {
521
557
  [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
522
558
  return `Client { baseUrl: '${this.#config.baseUrl}' }`;
523
559
  }
524
- captureException(error, hint) {
560
+ /** `contexts` é interno ao SDK (ex.: `contexts.run` com o print do captureException) — o evento ainda pode sobrescrever. */
561
+ captureException(error, hint, contexts) {
525
562
  if (typeof error === "object" && error !== null) captured.add(error);
526
- return this.captureEvent(eventFromException(error, hint?.type ?? "error"), hint);
563
+ return this.captureEvent(eventFromException(error, hint?.type ?? "error"), hint, contexts);
527
564
  }
528
- captureEvent(event, hint) {
529
- const final = this.prepareEvent(event, hint);
565
+ captureEvent(event, hint, contexts) {
566
+ const final = this.prepareEvent(contexts ? { ...event, contexts: { ...contexts, ...event.contexts } } : event, hint);
530
567
  this.transport.enqueue({ type: "event", payload: final });
531
568
  return final.event_id;
532
569
  }
@@ -549,14 +586,15 @@ var Client = class {
549
586
  sendTransaction(payload) {
550
587
  let steps = payload.steps;
551
588
  if (steps.length > MAX_STEPS) {
552
- this.logger.warn(`transaction com ${steps.length} steps \u2014 enviando as primeiras ${MAX_STEPS}`);
589
+ this.logger.warn(`transaction com ${steps.length} etapas \u2014 enviando as primeiras ${MAX_STEPS}`);
553
590
  steps = steps.slice(0, MAX_STEPS);
554
591
  }
592
+ steps = steps.map(clipStep);
555
593
  this.transport.enqueue({
556
594
  type: "transaction",
557
595
  payload: {
558
596
  ...payload,
559
- name: truncate(payload.name || "(sem nome)", MAX_TRANSACTION_NAME_CHARS),
597
+ name: clip(payload.name || "(sem nome)", MAX_TRANSACTION_NAME_CHARS),
560
598
  steps,
561
599
  release: payload.release ?? this.options.release,
562
600
  environment: this.resolveEnvironment(payload.environment),
@@ -1129,6 +1167,36 @@ var TasksApi = class {
1129
1167
  duplicates: typeof body?.duplicates === "number" ? body.duplicates : 0
1130
1168
  };
1131
1169
  }
1170
+ /**
1171
+ * Uma página dos ids já concluídos neste trabalho (`items.each` pula). Retry em falha
1172
+ * de rede/5xx; 4xx é definitivo (404 = servidor sem o recurso).
1173
+ */
1174
+ async listDoneItems(runId, request) {
1175
+ let response;
1176
+ try {
1177
+ response = await fetchWithRetry(
1178
+ () => fetch(`${this.baseUrl}/${encodeURIComponent(runId)}/items/done`, {
1179
+ redirect: "error",
1180
+ method: "POST",
1181
+ headers: this.headers(),
1182
+ body: JSON.stringify(request),
1183
+ signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
1184
+ }),
1185
+ { logger: this.logger, label: `itens conclu\xEDdos da run ${runId}` }
1186
+ );
1187
+ } catch (error) {
1188
+ throw new Error(`consulta de itens conclu\xEDdos da run ${runId} descartada ap\xF3s retries: ${String(error)}`);
1189
+ }
1190
+ if (!response.ok) {
1191
+ throw new TasksApiError(`consulta de itens conclu\xEDdos rejeitada (HTTP ${response.status})`, response.status);
1192
+ }
1193
+ const body = await response.json().catch(() => null);
1194
+ if (!Array.isArray(body?.ids)) throw new TasksApiError("consulta de itens conclu\xEDdos: resposta malformada", 0);
1195
+ return {
1196
+ ids: body.ids.map((id) => String(id)),
1197
+ next: typeof body.next === "string" && body.next.length > 0 ? body.next : null
1198
+ };
1199
+ }
1132
1200
  /**
1133
1201
  * Registra o anexo e obtém a URL presignada de upload. 404/501/503 significam
1134
1202
  * "esta instalação não tem anexos" — o RunReporter degrada em silêncio a partir daí.
@@ -1315,6 +1383,20 @@ var Backoff = class {
1315
1383
  };
1316
1384
 
1317
1385
  // src/tasks/errors.ts
1386
+ var Occurrence = class extends Error {
1387
+ payload;
1388
+ /** Print do item (bytes/caminho ou `hobots.image(...)`) — o `each` pendura no item. */
1389
+ image;
1390
+ constructor(message, payload, options) {
1391
+ super(message);
1392
+ this.name = "Occurrence";
1393
+ this.payload = payload;
1394
+ this.image = options?.image;
1395
+ }
1396
+ };
1397
+ function isOccurrence(error) {
1398
+ return error instanceof Error && error.name === "Occurrence";
1399
+ }
1318
1400
  function runCanceledError() {
1319
1401
  return Object.assign(new Error("execu\xE7\xE3o cancelada"), { name: "RunCanceled" });
1320
1402
  }
@@ -1331,1507 +1413,1745 @@ function isCancellation(error) {
1331
1413
  return error instanceof Error && (error.name === "RunCanceled" || error.name === "AbortError");
1332
1414
  }
1333
1415
 
1334
- // src/tasks/each.ts
1335
- function thrownMessage(error) {
1336
- if (error instanceof Error) return error.message || error.name;
1337
- return safeString(error);
1338
- }
1339
- var ItemHandle = class {
1340
- constructor(index, id, step, basePayload, sink) {
1341
- this.index = index;
1342
- this.id = id;
1343
- this.step = step;
1344
- this.basePayload = basePayload;
1345
- this.sink = sink;
1346
- }
1347
- index;
1348
- id;
1349
- step;
1350
- basePayload;
1351
- sink;
1352
- reported = false;
1353
- status;
1354
- succeeded(payload, options) {
1355
- this.record("succeeded", void 0, payload, options?.image);
1356
- }
1357
- failed(message, payload, options) {
1358
- this.record("failed", message, payload, options?.image);
1359
- if (this.step && this.step.status !== "error") this.step.setStatus("error", message);
1360
- }
1361
- occurrence(message, payload, options) {
1362
- this.record("occurrence", message, payload, options?.image);
1363
- if (this.step && this.step.status === "ok") this.step.setStatus("warning", message);
1364
- }
1365
- record(status, message, payload, image3) {
1366
- this.reported = true;
1367
- this.status = status;
1368
- const merged = { ...this.basePayload ?? {}, ...payload ?? {} };
1369
- this.sink.report(status, message, Object.keys(merged).length > 0 ? merged : void 0, this.id, image3);
1416
+ // src/tasks/attachments.ts
1417
+ var MAX_ATTACHMENT_BYTES = WIRE_LIMITS.MAX_ATTACHMENT_BYTES;
1418
+ var MAX_ATTACHMENTS_PER_RUN = WIRE_LIMITS.MAX_ATTACHMENTS_PER_RUN;
1419
+ var MAX_ATTACHMENTS_PER_LOG_ENTRY = WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY;
1420
+ var MAX_CONCURRENT_UPLOADS = 3;
1421
+ var MAX_CAPTION_CHARS = WIRE_LIMITS.MAX_CAPTION_CHARS;
1422
+ var ATTACHMENT_DRAIN_MS = 15e3;
1423
+ var IMAGE_BRAND = /* @__PURE__ */ Symbol.for("hobots.image");
1424
+ var ImageAttachment = class {
1425
+ constructor(input, caption, contentType) {
1426
+ this.input = input;
1427
+ this.caption = caption;
1428
+ this.contentType = contentType;
1370
1429
  }
1430
+ input;
1431
+ caption;
1432
+ contentType;
1433
+ [IMAGE_BRAND] = true;
1371
1434
  };
1372
- function resolveId(options, element, index) {
1373
- const raw = options.id?.(element, index);
1374
- if (raw === null || raw === void 0) return void 0;
1375
- const text = safeString(raw);
1376
- return text.length > 0 ? text : void 0;
1435
+ function image(input, options = {}) {
1436
+ return new ImageAttachment(input, options.caption, options.contentType);
1377
1437
  }
1378
- function knownLength(list) {
1379
- const length = list?.length;
1380
- return typeof length === "number" && Number.isInteger(length) && length >= 1 ? length : void 0;
1438
+ function isImageAttachment(value) {
1439
+ return typeof value === "object" && value !== null && value[IMAGE_BRAND] === true;
1381
1440
  }
1382
- async function eachItem(list, handler, options, sink) {
1383
- const result = { total: 0, succeeded: 0, failed: 0, occurrence: 0 };
1384
- const length = options.progress === false ? void 0 : knownLength(list);
1385
- if (length !== void 0) sink.declareTotal(length);
1386
- let index = 0;
1387
- for await (const element of list) {
1388
- sink.checkCanceled();
1389
- const id = resolveId(options, element, index);
1390
- const description = options.description?.(element, index) ?? id ?? `#${index + 1}`;
1391
- const step = sink.startStep(options.op ?? "item", description);
1392
- const handle = new ItemHandle(index, id, step, options.payload?.(element, index), sink);
1393
- try {
1394
- await handler(element, handle, index);
1395
- if (!handle.reported) handle.succeeded();
1396
- } catch (error) {
1397
- if (isTerminalError(error) || sink.isAborted()) {
1398
- step?.setStatus("cancelled").succeeded();
1399
- throw error;
1400
- }
1401
- const message = thrownMessage(error);
1402
- if (!handle.reported) handle.failed(message);
1403
- step?.failed(error);
1404
- sink.logError(`item ${description} falhou: ${message}`);
1405
- }
1406
- step?.succeeded();
1407
- if (options.progress !== false) sink.advance();
1408
- result.total += 1;
1409
- result[handle.status ?? "succeeded"] += 1;
1410
- index += 1;
1441
+ function toImageAttachment(value) {
1442
+ return isImageAttachment(value) ? value : new ImageAttachment(value);
1443
+ }
1444
+ async function readImageFile(path) {
1445
+ if (!isNode()) {
1446
+ throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
1411
1447
  }
1412
- return result;
1448
+ const { readFile } = await import("fs/promises");
1449
+ return new Uint8Array(await readFile(path));
1413
1450
  }
1414
-
1415
- // src/tasks/files.ts
1416
- var LOCAL_FILES_DIR_ENV = "HOBOTS_LOCAL_FILES_DIR";
1417
- var FILE_DOWNLOAD_IDLE_TIMEOUT_MS = 6e4;
1418
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1419
- function isFileRef(value) {
1420
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1421
- const ref = value;
1422
- return typeof ref.id === "string" && typeof ref.name === "string" && typeof ref.size === "number" && typeof ref.contentType === "string" && (ref.storage === "s3" || ref.storage === "local");
1451
+ function startsWith(bytes, signature, offset = 0) {
1452
+ if (bytes.length < offset + signature.length) return false;
1453
+ return signature.every((byte, index) => bytes[offset + index] === byte);
1423
1454
  }
1424
- function fileField(params, field) {
1425
- if (!Object.prototype.hasOwnProperty.call(params, field)) return void 0;
1426
- const value = params[field];
1427
- if (isFileRef(value)) return value;
1428
- if (Array.isArray(value) && value.every(isFileRef)) return value;
1455
+ var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
1456
+ var MAX_PNG_DIMENSION = WIRE_LIMITS.MAX_IMAGE_DIMENSION_PX;
1457
+ var JPEG_MAGIC = [255, 216, 255];
1458
+ var RIFF_MAGIC = [82, 73, 70, 70];
1459
+ var WEBP_MAGIC = [87, 69, 66, 80];
1460
+ function sniffContentType(bytes) {
1461
+ if (startsWith(bytes, PNG_MAGIC)) return "image/png";
1462
+ if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
1463
+ if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
1429
1464
  return void 0;
1430
1465
  }
1431
- function collectFileRefs(params) {
1432
- const refs = [];
1433
- for (const field of Object.keys(params)) {
1434
- const value = fileField(params, field);
1435
- if (value !== void 0) refs.push(...Array.isArray(value) ? value : [value]);
1436
- }
1437
- return refs;
1466
+ function readPngDimensions(bytes) {
1467
+ if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
1468
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1469
+ const width = view.getUint32(16);
1470
+ const height = view.getUint32(20);
1471
+ if (width === 0 || height === 0) return void 0;
1472
+ if (width > MAX_PNG_DIMENSION || height > MAX_PNG_DIMENSION) return void 0;
1473
+ return { width, height };
1438
1474
  }
1439
- function safeFileName(name, fallback) {
1440
- const cleaned = name.replace(/^.*[\\/]/, "").replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
1441
- return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
1475
+ async function toBytes(input) {
1476
+ if (typeof input === "string") return readImageFile(input);
1477
+ if (input instanceof Uint8Array) return input;
1478
+ return new Uint8Array(input);
1442
1479
  }
1443
- function isInsideDir(root, candidate, sep) {
1444
- const base = root.endsWith(sep) ? root : root + sep;
1445
- return candidate.startsWith(base) && candidate.length > base.length;
1480
+
1481
+ // src/tasks/run-context-store.ts
1482
+ var storage;
1483
+ var stepStorage;
1484
+ var initPromise;
1485
+ async function ensureStorage() {
1486
+ if (storage || !isNode()) return;
1487
+ initPromise ??= import("async_hooks").then(({ AsyncLocalStorage: ALS }) => {
1488
+ storage = new ALS();
1489
+ stepStorage = new ALS();
1490
+ });
1491
+ await initPromise;
1446
1492
  }
1447
- function unavailableLocal(ref) {
1448
- return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
1493
+ async function runWithRunContext(store, fn) {
1494
+ await ensureStorage();
1495
+ return storage ? storage.run(store, fn) : fn();
1449
1496
  }
1450
- function errorCode(error) {
1451
- return error?.code;
1497
+ function currentRunReporter() {
1498
+ return storage?.getStore()?.reporter;
1452
1499
  }
1453
- var RunFileStore = class {
1454
- constructor(deps) {
1455
- this.deps = deps;
1456
- this.files = {
1457
- list: () => collectFileRefs(deps.params),
1458
- get: (field) => fileField(deps.params, field),
1459
- // async: erro de uso (campo sem arquivo, valor inválido) chega como Promise rejeitada, nunca síncrono
1460
- download: async (refOrField, options) => this.download(this.single(refOrField), options?.dest),
1461
- downloadAll: async (field, options) => this.downloadAll(field, options?.dest),
1462
- read: async (refOrField) => this.read(this.single(refOrField))
1463
- };
1464
- }
1465
- deps;
1466
- files;
1467
- tempDir;
1468
- /** Apaga a pasta temporária da execução (cópias que o bot não levou para `dest`). Nunca lança. */
1469
- async cleanup() {
1470
- const pending = this.tempDir;
1471
- this.tempDir = void 0;
1472
- if (!pending) return;
1500
+ async function runWithStepScope(scope, fn) {
1501
+ await ensureStorage();
1502
+ return stepStorage ? stepStorage.run(scope, fn) : fn();
1503
+ }
1504
+ function currentStep(transaction) {
1505
+ const scope = stepStorage?.getStore();
1506
+ return scope?.transaction === transaction ? scope.step : void 0;
1507
+ }
1508
+
1509
+ // src/retry.ts
1510
+ var DEFAULT_RETRIES = 3;
1511
+ var DEFAULT_DELAY_MS = 1e3;
1512
+ function resolveDelay(delay, attempt, fallbackMs) {
1513
+ const value = typeof delay === "function" ? delay(attempt) : delay ?? fallbackMs;
1514
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1515
+ }
1516
+ async function retryWithBackoff(fn, options = {}) {
1517
+ const requested = options.retries ?? DEFAULT_RETRIES;
1518
+ const retries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_RETRIES;
1519
+ const wait = options.wait ?? sleep;
1520
+ for (let attempt = 1; ; attempt++) {
1521
+ if (options.signal?.aborted) throw runCanceledError();
1473
1522
  try {
1474
- const { rm } = await import("fs/promises");
1475
- await rm(await pending, { recursive: true, force: true });
1476
- } catch {
1477
- }
1478
- }
1479
- field(field) {
1480
- const value = fileField(this.deps.params, field);
1481
- if (value === void 0) throw new Error(`campo "${field}" n\xE3o tem arquivo nos params`);
1482
- return value;
1483
- }
1484
- single(refOrField) {
1485
- if (typeof refOrField === "string") {
1486
- const value = this.field(refOrField);
1487
- if (Array.isArray(value)) {
1488
- throw new Error(`campo "${refOrField}" tem v\xE1rios arquivos \u2014 use downloadAll("${refOrField}") ou passe um item da lista`);
1489
- }
1490
- return value;
1523
+ return await fn(attempt);
1524
+ } catch (error) {
1525
+ const exhausted = attempt > retries;
1526
+ const retriable = !isTerminalError(error) && !options.signal?.aborted && (options.shouldRetry?.(error) ?? true);
1527
+ if (exhausted || !retriable) throw error;
1528
+ const delay = resolveDelay(options.delayMs, attempt, DEFAULT_DELAY_MS);
1529
+ options.onRetry?.(error, attempt, delay);
1530
+ await wait(delay);
1491
1531
  }
1492
- if (!isFileRef(refOrField)) throw new TypeError("esperado um FileRef ou o nome de um campo de arquivo");
1493
- return refOrField;
1494
- }
1495
- async downloadAll(field, dest) {
1496
- const value = this.field(field);
1497
- const refs = Array.isArray(value) ? value : [value];
1498
- const paths = [];
1499
- for (const ref of refs) paths.push(await this.download(ref, dest, true));
1500
- return paths;
1501
1532
  }
1502
- async read(ref) {
1503
- const { readFile, rm } = await import("fs/promises");
1504
- const { dirname } = await import("path");
1505
- const path = await this.download(ref, void 0);
1506
- try {
1507
- return await readFile(path);
1508
- } finally {
1509
- await rm(dirname(path), { recursive: true, force: true });
1510
- }
1533
+ }
1534
+
1535
+ // src/transaction.ts
1536
+ var FLUSH_TRANSACTION_OP = "hobots.run.flush";
1537
+ var DEFAULT_STEP_RETRIES = 1;
1538
+ var DEFAULT_STEP_RETRY_DELAY_MS = 5e3;
1539
+ var MAX_STEP_MESSAGE_CHARS = 2e3;
1540
+ var MAX_BACKLOG = 100;
1541
+ function stepMessage(text) {
1542
+ return clip(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
1543
+ }
1544
+ function stepId() {
1545
+ return uuid4().slice(0, 16);
1546
+ }
1547
+ function stepLabel(op, description) {
1548
+ return [op, description].filter(Boolean).join(" \u2014 ") || "(sem nome)";
1549
+ }
1550
+ function errorText(error) {
1551
+ if (error instanceof Error) return error.message || error.name;
1552
+ return safeString(error);
1553
+ }
1554
+ function errorReason(error) {
1555
+ return error instanceof Error ? `${error.name}: ${errorText(error)}` : errorText(error);
1556
+ }
1557
+ function levelForStatus(status) {
1558
+ switch (status) {
1559
+ case "warning":
1560
+ return "warning";
1561
+ case "error":
1562
+ return "error";
1563
+ case "cancelled":
1564
+ return "warning";
1565
+ default:
1566
+ return "success";
1511
1567
  }
1512
- async download(ref, dest, destIsDir = false) {
1513
- this.checkCanceled();
1514
- const target = await this.resolveTarget(ref, dest, destIsDir);
1515
- if (ref.storage === "local") await this.copyLocal(ref, target);
1516
- else await this.downloadS3(ref, target);
1517
- return target;
1568
+ }
1569
+ function colorForStatus(status) {
1570
+ switch (status) {
1571
+ case "warning":
1572
+ return "yellow";
1573
+ case "error":
1574
+ return "red";
1575
+ case "cancelled":
1576
+ return "gray";
1577
+ default:
1578
+ return "green";
1518
1579
  }
1519
- checkCanceled() {
1520
- if (this.deps.signal.aborted) throw runCanceledError();
1580
+ }
1581
+ var Step = class {
1582
+ constructor(transaction, parentStepId, op, description) {
1583
+ this.transaction = transaction;
1584
+ this.parentStepId = parentStepId;
1585
+ this.op = op;
1586
+ this.description = description;
1521
1587
  }
1522
- runTempDir() {
1523
- this.tempDir ??= (async () => {
1524
- const { mkdtemp } = await import("fs/promises");
1525
- const { tmpdir } = await import("os");
1526
- const { join } = await import("path");
1527
- return mkdtemp(join(tmpdir(), "hobots-files-"));
1528
- })();
1529
- return this.tempDir;
1588
+ transaction;
1589
+ parentStepId;
1590
+ op;
1591
+ description;
1592
+ stepId = stepId();
1593
+ startTimestamp = nowISO();
1594
+ status = "ok";
1595
+ endTimestamp;
1596
+ _data;
1597
+ // ── API do bot ────────────────────────────────────────────────────────────
1598
+ warning(message, options) {
1599
+ if (this.status !== "error") this._setStatus("warning", message);
1600
+ if (options?.image !== void 0) this.attachImage(options.image);
1601
+ return this;
1530
1602
  }
1531
- async resolveTarget(ref, dest, destIsDir) {
1532
- const { mkdir, mkdtemp, stat } = await import("fs/promises");
1533
- const { dirname, join, resolve: resolve2 } = await import("path");
1534
- const name = safeFileName(ref.name, "arquivo");
1535
- if (dest === void 0) {
1536
- return join(await mkdtemp(join(await this.runTempDir(), "f-")), name);
1537
- }
1538
- const isDir = destIsDir || dest.endsWith("/") || dest.endsWith("\\") || (await stat(dest).catch(() => void 0))?.isDirectory() === true;
1539
- if (isDir) {
1540
- await mkdir(dest, { recursive: true });
1541
- return resolve2(dest, name);
1603
+ image(image3) {
1604
+ this.attachImage(image3);
1605
+ return this;
1606
+ }
1607
+ data(key, value) {
1608
+ if (key === "attachments") return this;
1609
+ this._setData(key, value);
1610
+ return this;
1611
+ }
1612
+ // ── Internos (chamados pelo SDK) ──────────────────────────────────────────
1613
+ /** `data` da etapa como vai no wire (undefined quando vazio). */
1614
+ get payload() {
1615
+ return this._data;
1616
+ }
1617
+ _setStatus(status, message) {
1618
+ this.status = status;
1619
+ if (message) this._setData("message", stepMessage(safeString(message)));
1620
+ return this;
1621
+ }
1622
+ /**
1623
+ * Marca `error` guardando `{ type, message }` em `data.error` e fecha a etapa. Aceita qualquer
1624
+ * valor lançado; `message` sobrescreve a mensagem do erro. Numa etapa já fechada não faz nada.
1625
+ */
1626
+ _failed(error, message) {
1627
+ if (this.endTimestamp) return this;
1628
+ this.status = "error";
1629
+ const type = error instanceof Error ? error.name : typeof error === "object" && error !== null ? "Error" : void 0;
1630
+ const raw = message ?? errorText(error);
1631
+ this._setData("error", { ...type ? { type } : {}, message: stepMessage(safeString(raw)) });
1632
+ this._close();
1633
+ return this;
1634
+ }
1635
+ _setData(key, value) {
1636
+ this._data = { ...this._data, [key]: value };
1637
+ }
1638
+ _deleteData(key) {
1639
+ if (!this._data || !(key in this._data)) return;
1640
+ const rest = { ...this._data };
1641
+ delete rest[key];
1642
+ this._data = Object.keys(rest).length > 0 ? rest : void 0;
1643
+ }
1644
+ /**
1645
+ * Dispara o upload pela run (sink do RunReporter) e referencia o anexo em
1646
+ * `data.attachments` (chave reservada; teto igual ao das linhas de log). Fora de uma
1647
+ * run não há onde pendurar a imagem — descarta com aviso único.
1648
+ */
1649
+ attachImage(image3) {
1650
+ const current = Array.isArray(this._data?.attachments) ? this._data.attachments : [];
1651
+ if (current.length >= MAX_ATTACHMENTS_PER_LOG_ENTRY) return;
1652
+ const id = this.transaction.attachImage(image3);
1653
+ if (!id) return;
1654
+ this._setData("attachments", [...current, id]);
1655
+ }
1656
+ /** Motivo registrado (warning/erro) — usado no log de FINISH. */
1657
+ reason() {
1658
+ const error = this._data?.error;
1659
+ const text = typeof error?.message === "string" ? error.message : this._data?.message;
1660
+ return typeof text === "string" && text ? text : void 0;
1661
+ }
1662
+ /** Fecha a etapa mantendo o status que estiver. Idempotente. */
1663
+ _close() {
1664
+ if (this.endTimestamp) return;
1665
+ this.endTimestamp = nowISO();
1666
+ this.transaction.emit(
1667
+ "step",
1668
+ "finish",
1669
+ stepLabel(this.op, this.description),
1670
+ this.startTimestamp,
1671
+ this.endTimestamp,
1672
+ this.status,
1673
+ this.reason()
1674
+ );
1675
+ }
1676
+ toJSON() {
1677
+ return {
1678
+ step_id: this.stepId,
1679
+ ...this.parentStepId ? { parent_step_id: this.parentStepId } : {},
1680
+ ...this.op ? { op: clip(this.op, MAX_STEP_OP_CHARS) } : {},
1681
+ ...this.description ? { description: clip(this.description, MAX_STEP_DESCRIPTION_CHARS) } : {},
1682
+ status: this.status,
1683
+ start_timestamp: this.startTimestamp,
1684
+ timestamp: this.endTimestamp ?? nowISO(),
1685
+ ...this._data ? { data: this._data } : {}
1686
+ };
1687
+ }
1688
+ };
1689
+ var Transaction = class {
1690
+ constructor(client, name, op, runId, options = {}) {
1691
+ this.client = client;
1692
+ this.name = name;
1693
+ this.op = op;
1694
+ this.runId = runId;
1695
+ this.echo = options.echo ?? false;
1696
+ this.emit("transaction", "start", op ? `${name} (${op})` : name);
1697
+ }
1698
+ client;
1699
+ name;
1700
+ op;
1701
+ runId;
1702
+ eventId = uuid4();
1703
+ stepId = stepId();
1704
+ startTimestamp = nowISO();
1705
+ status = "ok";
1706
+ steps = [];
1707
+ finished = false;
1708
+ lifecycleSink;
1709
+ attachmentSink;
1710
+ warnedNoAttachmentSink = false;
1711
+ // instalado pelo RunReporter: abrir uma etapa é um ponto de parada cooperativo (lança RunCanceled)
1712
+ stepGuard;
1713
+ // modo local (sem client): o FINISH de cada etapa sai no terminal, como `[hobots:item]`
1714
+ echo;
1715
+ // eventos emitidos antes do sink existir (o START da transaction sai no construtor,
1716
+ // antes do RunReporter conectar) — replay no setLifecycleSink
1717
+ lifecycleBacklog = [];
1718
+ // ── Lifecycle / eco ───────────────────────────────────────────────────────
1719
+ /**
1720
+ * Registra o início/fim de uma etapa ou da transaction: espelha a linha no sink de
1721
+ * lifecycle, se conectado (é assim que os eventos de performance aparecem nos logs da
1722
+ * run no app), e ecoa no terminal com `debug: true` — ou, no modo local, só o FINISH das
1723
+ * etapas (`[hobots:step] FINISH …`), para o Studio e o dev verem.
1724
+ */
1725
+ emit(kind, phase, label, startTs, endTs, status, reason) {
1726
+ const durationMs = startTs && endTs ? new Date(endTs).getTime() - new Date(startTs).getTime() : void 0;
1727
+ const suffix = (durationMs != null ? ` (${durationMs}ms)` : "") + (reason ? `: ${reason}` : "");
1728
+ const localFinish = this.echo && kind === "step" && phase === "finish";
1729
+ if (this.client?.debug || localFinish) {
1730
+ const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
1731
+ console.log(`${tag} ${label}${suffix}`);
1542
1732
  }
1543
- await mkdir(dirname(resolve2(dest)), { recursive: true });
1544
- return resolve2(dest);
1733
+ const level = phase === "finish" ? levelForStatus(status ?? "ok") : "info";
1734
+ const message = `[${kind}] ${phase.toUpperCase()} ${label}${suffix}`;
1735
+ if (this.lifecycleSink) this.lifecycleSink(level, message);
1736
+ else if (this.lifecycleBacklog.length < MAX_BACKLOG) this.lifecycleBacklog.push([level, message]);
1545
1737
  }
1546
- /** storage 's3': URL pré-assinada → GET em streaming → confere o tamanho → só então DELETE. */
1547
- async downloadS3(ref, target) {
1548
- const { api, runId, signal } = this.deps;
1549
- let expected;
1550
- let url;
1551
- try {
1552
- const info = await api.requestFileDownload(runId, ref.id, signal);
1553
- expected = info.size >= 0 ? info.size : ref.size;
1554
- url = info.url;
1555
- } catch (error) {
1556
- throw this.translate(error, ref);
1738
+ /** Conecta o espelho de lifecycle (RunReporter) e faz replay do backlog em ordem. */
1739
+ setLifecycleSink(sink) {
1740
+ this.lifecycleSink = sink;
1741
+ const backlog = this.lifecycleBacklog;
1742
+ this.lifecycleBacklog = [];
1743
+ for (const [level, message] of backlog) sink(level, message);
1744
+ }
1745
+ /** Conecta o pipeline de anexos da run — é o que dá imagem às etapas. */
1746
+ setAttachmentSink(sink) {
1747
+ this.attachmentSink = sink;
1748
+ }
1749
+ /** Imagem de uma etapa → id do anexo pela run; sem run, descarta com aviso único. */
1750
+ attachImage(image3) {
1751
+ if (this.attachmentSink) return this.attachmentSink(image3, nowISO());
1752
+ if (!this.warnedNoAttachmentSink) {
1753
+ this.warnedNoAttachmentSink = true;
1754
+ console.warn("[hobots] imagem de etapa descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
1557
1755
  }
1558
- const { open, rename, rm } = await import("fs/promises");
1559
- const part = `${target}.part`;
1560
- const idleTimeoutMs = this.deps.idleTimeoutMs ?? FILE_DOWNLOAD_IDLE_TIMEOUT_MS;
1561
- const controller = new AbortController();
1562
- let timedOut = false;
1563
- let timer;
1564
- const arm = () => {
1565
- clearTimeout(timer);
1566
- timer = setTimeout(() => {
1567
- timedOut = true;
1568
- controller.abort();
1569
- }, idleTimeoutMs);
1570
- };
1571
- const onCancel = () => controller.abort();
1572
- signal.addEventListener("abort", onCancel, { once: true });
1756
+ return void 0;
1757
+ }
1758
+ // ── Etapas ────────────────────────────────────────────────────────────────
1759
+ /**
1760
+ * Conecta a checagem de cancelamento chamada antes de cada etapa nova (inclusive antes de
1761
+ * cada nova tentativa) — é assim que `hobots.step()` para uma run cancelada.
1762
+ */
1763
+ setStepGuard(guard) {
1764
+ this.stepGuard = guard;
1765
+ }
1766
+ /** A etapa aberta por `step()` (ou `items.each`) no contexto async atual, se houver. */
1767
+ currentStep() {
1768
+ return currentStep(this);
1769
+ }
1770
+ /** Faz de `step` a etapa corrente durante `fn` (filhas aninham nela). Sem etapa, só roda `fn`. */
1771
+ scope(step, fn) {
1772
+ if (!step) return fn();
1773
+ return runWithStepScope({ transaction: this, step }, fn);
1774
+ }
1775
+ /** Abre uma etapa sob a corrente (ou direta da transaction). Interno: o bot usa `step()`. */
1776
+ start(op, description) {
1777
+ this.stepGuard?.();
1778
+ const parent = this.currentStep();
1779
+ const step = new Step(this, parent ? parent.stepId : this.stepId, op, description);
1780
+ this.steps.push(step);
1781
+ this.emit("step", "start", stepLabel(op, description));
1782
+ return step;
1783
+ }
1784
+ /**
1785
+ * Registra a etapa `name` executando `fn(etapa)` até dar certo ou esgotar `retries`
1786
+ * tentativas extras (default 1), esperando `retryDelayMs` entre elas (default 5 000).
1787
+ * Resolve com o retorno de `fn`.
1788
+ *
1789
+ * Uma etapa só no waterfall, cobrindo todas as tentativas: `data.attempts` quando houve
1790
+ * mais de uma; `warning` com o motivo quando a última deu certo; `error` (e a exceção
1791
+ * sobe) quando esgotou; `cancelled` em cancelamento/timeout, que nunca repetem.
1792
+ */
1793
+ async step(name, fn, options = {}) {
1794
+ const requested = options.retries ?? DEFAULT_STEP_RETRIES;
1795
+ const totalRetries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_STEP_RETRIES;
1796
+ const step = this.start(void 0, safeString(name));
1797
+ let attempts = 0;
1798
+ let lastFailure;
1573
1799
  try {
1574
- arm();
1575
- const response = await api.fetchFile(url, controller.signal);
1576
- const handle = await open(part, "w");
1577
- let written = 0;
1578
- try {
1579
- const reader = response.body?.getReader();
1580
- const stopReading = () => void reader?.cancel().catch(() => void 0);
1581
- controller.signal.addEventListener("abort", stopReading, { once: true });
1582
- for (; ; ) {
1583
- const chunk = reader ? await reader.read() : { done: true, value: void 0 };
1584
- if (chunk.done) break;
1585
- arm();
1586
- written += chunk.value.byteLength;
1587
- if (written > expected) {
1588
- stopReading();
1589
- break;
1800
+ const result = await retryWithBackoff(
1801
+ (attempt) => {
1802
+ attempts = attempt;
1803
+ return this.scope(step, async () => fn(step));
1804
+ },
1805
+ {
1806
+ retries: totalRetries,
1807
+ delayMs: (attempt) => resolveDelay(options.retryDelayMs, attempt, DEFAULT_STEP_RETRY_DELAY_MS),
1808
+ signal: options.signal,
1809
+ wait: options.wait,
1810
+ onRetry: (error, attempt, delayMs) => {
1811
+ lastFailure = { error };
1812
+ options.warn?.(
1813
+ `etapa "${step.description}": tentativa ${attempt}/${totalRetries + 1} falhou: ${truncate(errorReason(error), 500)} \u2014 nova tentativa em ${delayMs}ms`
1814
+ );
1590
1815
  }
1591
- await handle.write(chunk.value);
1592
1816
  }
1593
- } finally {
1594
- await handle.close();
1595
- }
1596
- if (written !== expected) {
1597
- throw new Error(`tamanho divergente no download de "${ref.name}": recebidos ${written} bytes, esperados ${expected}`);
1817
+ );
1818
+ this.noteAttempts(step, attempts);
1819
+ if (lastFailure && step.status === "ok") {
1820
+ step._setStatus("warning", `tentativa ${attempts - 1}/${totalRetries + 1} falhou: ${errorReason(lastFailure.error)}`);
1598
1821
  }
1599
- await rename(part, target);
1600
- } catch (error) {
1601
- await rm(part, { force: true });
1602
- if (signal.aborted) throw runCanceledError();
1603
- if (timedOut) {
1604
- throw new Error(`download de "${ref.name}" parado h\xE1 ${idleTimeoutMs / 1e3}s \u2014 tente de novo`);
1605
- }
1606
- throw this.translate(error, ref);
1607
- } finally {
1608
- clearTimeout(timer);
1609
- signal.removeEventListener("abort", onCancel);
1610
- }
1611
- try {
1612
- await api.confirmFileDownload(runId, ref.id);
1822
+ step._close();
1823
+ return result;
1613
1824
  } catch (error) {
1614
- this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
1825
+ this.noteAttempts(step, attempts);
1826
+ if (isTerminalError(error)) step._setStatus("cancelled")._close();
1827
+ else step._failed(error);
1828
+ throw error;
1615
1829
  }
1616
1830
  }
1617
- /** storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de origem. */
1618
- async copyLocal(ref, target) {
1619
- const base = process.env[LOCAL_FILES_DIR_ENV];
1620
- if (!base) throw unavailableLocal(ref);
1621
- const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
1622
- const { join, resolve: resolve2, sep } = await import("path");
1623
- const root = resolve2(base);
1624
- const folder = resolve2(root, ref.id);
1625
- if (!isInsideDir(root, folder, sep) || !UUID_RE.test(ref.id)) {
1626
- throw new Error(`id de arquivo local inv\xE1lido: ${JSON.stringify(ref.id)}`);
1627
- }
1628
- let names;
1629
- try {
1630
- names = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
1631
- } catch (error) {
1632
- if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") throw unavailableLocal(ref);
1633
- throw error;
1831
+ noteAttempts(step, attempts) {
1832
+ if (attempts > 1) step._setData("attempts", attempts);
1833
+ }
1834
+ // ── Metadados ─────────────────────────────────────────────────────────────
1835
+ setStatus(status) {
1836
+ this.status = status;
1837
+ return this;
1838
+ }
1839
+ // ── Encerramento ──────────────────────────────────────────────────────────
1840
+ /**
1841
+ * Envia já as etapas raiz finalizadas (e todas as descendentes, fechando as que
1842
+ * ficaram abertas) como transactions parciais da mesma run (`op` hobots.run.flush,
1843
+ * `event_id` novo, até 200 etapas cada) e as tira da memória. Etapas raiz ainda abertas
1844
+ * ficam para o próximo flush ou para o `finish()`. É assim que um agente sempre
1845
+ * ligado (`start()`), cuja run não termina, entrega as etapas enquanto processa.
1846
+ * Devolve quantas etapas foram enviadas (0 sem client, sem nada terminado ou depois do `finish()`).
1847
+ */
1848
+ flush() {
1849
+ if (!this.client || this.finished) return 0;
1850
+ const ready = /* @__PURE__ */ new Set();
1851
+ for (const step of this.steps) {
1852
+ if (step.parentStepId === this.stepId && step.endTimestamp) ready.add(step.stepId);
1634
1853
  }
1635
- if (names.length === 0) throw unavailableLocal(ref);
1636
- if (names.length > 1) throw new Error(`pasta do arquivo "${ref.name}" tem ${names.length} arquivos \u2014 esperado 1`);
1637
- this.checkCanceled();
1638
- const part = `${target}.part`;
1639
- try {
1640
- await copyFile(join(folder, names[0]), part);
1641
- const { size } = await stat(part);
1642
- if (size !== ref.size) {
1643
- throw new Error(`tamanho divergente na c\xF3pia de "${ref.name}": copiados ${size} bytes, esperados ${ref.size}`);
1854
+ if (ready.size === 0) return 0;
1855
+ const batch = [];
1856
+ const remaining = [];
1857
+ for (const step of this.steps) {
1858
+ if (ready.has(step.stepId) || ready.has(step.parentStepId)) {
1859
+ ready.add(step.stepId);
1860
+ batch.push(step);
1861
+ } else {
1862
+ remaining.push(step);
1644
1863
  }
1645
- await rename(part, target);
1646
- } catch (error) {
1647
- await rm(part, { force: true });
1648
- throw error;
1649
1864
  }
1650
- await rm(folder, { recursive: true, force: true });
1651
- }
1652
- translate(error, ref) {
1653
- if (this.deps.signal.aborted) return runCanceledError();
1654
- if (error instanceof TasksApiError && error.status === 410) {
1655
- return new TasksApiError(`arquivo "${ref.name}" j\xE1 baixado ou expirado`, 410);
1865
+ for (const step of batch) step._close();
1866
+ this.steps.splice(0, this.steps.length, ...remaining);
1867
+ for (let i = 0; i < batch.length; i += MAX_STEPS) {
1868
+ const chunk = batch.slice(i, i + MAX_STEPS);
1869
+ const startTimestamp = chunk.reduce((min, step) => step.startTimestamp < min ? step.startTimestamp : min, chunk[0].startTimestamp);
1870
+ const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
1871
+ this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, startTimestamp, nowISO(), chunk));
1656
1872
  }
1657
- if (error instanceof TasksApiError && error.status === 404) {
1658
- return new TasksApiError(`arquivo "${ref.name}" n\xE3o encontrado nesta execu\xE7\xE3o`, 404);
1873
+ return batch.length;
1874
+ }
1875
+ /** Entrega já (com teto) o que está na fila do transport — as transactions parciais inclusive. */
1876
+ async drain(timeoutMs) {
1877
+ await this.client?.flush(timeoutMs);
1878
+ }
1879
+ payload(eventId, op, status, startTimestamp, timestamp, steps) {
1880
+ return {
1881
+ event_id: eventId,
1882
+ name: this.name,
1883
+ op,
1884
+ status,
1885
+ start_timestamp: startTimestamp,
1886
+ timestamp,
1887
+ steps: steps.map((step) => step.toJSON()),
1888
+ // sem tags próprias: o client mescla as do processo
1889
+ tags: void 0,
1890
+ run_id: this.runId
1891
+ };
1892
+ }
1893
+ finish() {
1894
+ if (this.finished) return;
1895
+ this.finished = true;
1896
+ for (const step of this.steps) step._close();
1897
+ const endTimestamp = nowISO();
1898
+ this.emit(
1899
+ "transaction",
1900
+ "finish",
1901
+ this.op ? `${this.name} (${this.op})` : this.name,
1902
+ this.startTimestamp,
1903
+ endTimestamp,
1904
+ this.status
1905
+ );
1906
+ if (!this.client) return;
1907
+ const steps = this.steps;
1908
+ let first = 0;
1909
+ while (steps.length - first > MAX_STEPS) {
1910
+ const chunk = steps.slice(first, first + MAX_STEPS);
1911
+ const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
1912
+ this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, chunk[0].startTimestamp, endTimestamp, chunk));
1913
+ first += MAX_STEPS;
1659
1914
  }
1660
- return error;
1915
+ this.client.sendTransaction(this.payload(this.eventId, this.op, this.status, this.startTimestamp, endTimestamp, steps.slice(first)));
1661
1916
  }
1662
1917
  };
1663
1918
 
1664
- // src/retry.ts
1665
- var DEFAULT_RETRIES = 3;
1666
- var DEFAULT_DELAY_MS = 1e3;
1667
- function resolveDelay(delay, attempt, fallbackMs) {
1668
- const value = typeof delay === "function" ? delay(attempt) : delay ?? fallbackMs;
1669
- return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1919
+ // src/tasks/each.ts
1920
+ var DEFAULT_ITEM_ID_FIELD = "id";
1921
+ var DEFAULT_EACH_RETRIES = 1;
1922
+ var ID_SEPARATOR = "|";
1923
+ function thrownMessage(error) {
1924
+ if (error instanceof Error) return error.message || error.name;
1925
+ return safeString(error);
1670
1926
  }
1671
- async function retryWithBackoff(fn, options = {}) {
1672
- const requested = options.retries ?? DEFAULT_RETRIES;
1673
- const retries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_RETRIES;
1674
- const wait = options.wait ?? sleep;
1675
- for (let attempt = 1; ; attempt++) {
1676
- if (options.signal?.aborted) throw runCanceledError();
1677
- try {
1678
- return await fn(attempt);
1679
- } catch (error) {
1680
- const exhausted = attempt > retries;
1681
- const retriable = !isTerminalError(error) && !options.signal?.aborted && (options.shouldRetry?.(error) ?? true);
1682
- if (exhausted || !retriable) throw error;
1683
- const delay = resolveDelay(options.delayMs, attempt, DEFAULT_DELAY_MS);
1684
- options.onRetry?.(error, attempt, delay);
1685
- await wait(delay);
1927
+ var ItemHandle = class {
1928
+ constructor(index, id, step, basePayload, sink) {
1929
+ this.index = index;
1930
+ this.step = step;
1931
+ this.basePayload = basePayload;
1932
+ this.sink = sink;
1933
+ this.id = id ?? uuid4();
1934
+ }
1935
+ index;
1936
+ step;
1937
+ basePayload;
1938
+ sink;
1939
+ /** A identidade resolvida por `options.id` ou, sem ela, uma gerada — a mesma em todas as tentativas. */
1940
+ id;
1941
+ reported = false;
1942
+ status;
1943
+ attempt = 1;
1944
+ succeeded(payload, options) {
1945
+ this.record("succeeded", void 0, payload, options?.image);
1946
+ }
1947
+ failed(message, payload, options) {
1948
+ this.record("failed", message, payload, options?.image);
1949
+ if (this.step && this.step.status !== "error") this.step._setStatus("error", message);
1950
+ }
1951
+ occurrence(message, payload, options) {
1952
+ this.record("occurrence", message, payload, options?.image);
1953
+ if (this.step && this.step.status === "ok") this.step._setStatus("warning", message);
1954
+ }
1955
+ /** Nova tentativa: o desfecho da anterior não vale (o id é o mesmo, o último reporte vence). */
1956
+ _nextAttempt(attempt) {
1957
+ this.attempt = attempt;
1958
+ this.reported = false;
1959
+ this.status = void 0;
1960
+ if (this.step && (this.step.status === "error" || this.step.status === "warning")) {
1961
+ this.step._setStatus("ok");
1962
+ this.step._deleteData("message");
1686
1963
  }
1687
1964
  }
1688
- }
1689
-
1690
- // src/tasks/attachments.ts
1691
- var MAX_ATTACHMENT_BYTES = WIRE_LIMITS.MAX_ATTACHMENT_BYTES;
1692
- var MAX_ATTACHMENTS_PER_RUN = WIRE_LIMITS.MAX_ATTACHMENTS_PER_RUN;
1693
- var MAX_ATTACHMENTS_PER_LOG_ENTRY = WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY;
1694
- var MAX_CONCURRENT_UPLOADS = 3;
1695
- var MAX_CAPTION_CHARS = WIRE_LIMITS.MAX_CAPTION_CHARS;
1696
- var ATTACHMENT_DRAIN_MS = 15e3;
1697
- var IMAGE_BRAND = /* @__PURE__ */ Symbol.for("hobots.image");
1698
- var ImageAttachment = class {
1699
- constructor(input, caption, contentType) {
1700
- this.input = input;
1701
- this.caption = caption;
1702
- this.contentType = contentType;
1965
+ record(status, message, payload, image3) {
1966
+ this.reported = true;
1967
+ this.status = status;
1968
+ const merged = { ...this.basePayload ?? {}, ...payload ?? {} };
1969
+ this.sink.report(status, message, Object.keys(merged).length > 0 ? merged : void 0, this.id, image3);
1703
1970
  }
1704
- input;
1705
- caption;
1706
- contentType;
1707
- [IMAGE_BRAND] = true;
1708
1971
  };
1709
- function image(input, options = {}) {
1710
- return new ImageAttachment(input, options.caption, options.contentType);
1972
+ function field(element, name) {
1973
+ if (typeof element !== "object" || element === null) return { present: false, value: void 0 };
1974
+ const record = element;
1975
+ return { present: name in record, value: record[name] };
1711
1976
  }
1712
- function isImageAttachment(value) {
1713
- return typeof value === "object" && value !== null && value[IMAGE_BRAND] === true;
1977
+ function resolveId(spec, element, index, explicit) {
1978
+ if (spec === null) return void 0;
1979
+ let raw;
1980
+ if (typeof spec === "function") {
1981
+ raw = spec(element, index);
1982
+ } else {
1983
+ const fields = spec === void 0 ? [DEFAULT_ITEM_ID_FIELD] : typeof spec === "string" ? [spec] : spec;
1984
+ const parts = [];
1985
+ for (const name of fields) {
1986
+ const { present, value } = field(element, name);
1987
+ if (!present || value === null || value === void 0) {
1988
+ if (explicit) {
1989
+ throw new Error(
1990
+ `items.each: elemento #${index + 1} n\xE3o tem o campo "${name}" informado em id \u2014 passe o nome certo, uma lista de campos ou uma fun\xE7\xE3o (elemento, \xEDndice) => id`
1991
+ );
1992
+ }
1993
+ return void 0;
1994
+ }
1995
+ parts.push(safeString(value));
1996
+ }
1997
+ raw = parts.join(ID_SEPARATOR);
1998
+ }
1999
+ if (raw === null || raw === void 0) return void 0;
2000
+ const text = safeString(raw);
2001
+ return text.length > 0 ? text : void 0;
1714
2002
  }
1715
- function toImageAttachment(value) {
1716
- return isImageAttachment(value) ? value : new ImageAttachment(value);
2003
+ function knownLength(list) {
2004
+ const length = list?.length;
2005
+ return typeof length === "number" && Number.isInteger(length) && length >= 1 ? length : void 0;
1717
2006
  }
1718
- async function readImageFile(path) {
1719
- if (!isNode()) {
1720
- throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
2007
+ function skippedMessage(count) {
2008
+ if (count === 1) return "1 item pulado: j\xE1 conclu\xEDdo neste trabalho numa execu\xE7\xE3o anterior";
2009
+ return `${count} itens pulados: j\xE1 conclu\xEDdos neste trabalho numa execu\xE7\xE3o anterior`;
2010
+ }
2011
+ async function eachItem(list, handler, options, sink) {
2012
+ const result = { total: 0, succeeded: 0, failed: 0, occurrence: 0, skipped: 0 };
2013
+ const requested = options.retries ?? DEFAULT_EACH_RETRIES;
2014
+ const totalRetries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_EACH_RETRIES;
2015
+ const explicitId = options.id !== void 0;
2016
+ const length = options.progress === false ? void 0 : knownLength(list);
2017
+ if (length !== void 0) sink.declareTotal(length);
2018
+ const done = options.skipDone === false ? void 0 : await sink.doneItemIds();
2019
+ let index = 0;
2020
+ for await (const element of list) {
2021
+ sink.checkCanceled();
2022
+ const id = resolveId(options.id, element, index, explicitId);
2023
+ if (done?.size && id !== void 0 && done.has(id)) {
2024
+ result.total += 1;
2025
+ result.skipped += 1;
2026
+ if (options.progress !== false) sink.advance();
2027
+ index += 1;
2028
+ continue;
2029
+ }
2030
+ const description = options.description?.(element, index) ?? id ?? `#${index + 1}`;
2031
+ const step = sink.startStep(options.op ?? "item", description);
2032
+ const handle = new ItemHandle(index, id, step, options.payload?.(element, index), sink);
2033
+ const current = index;
2034
+ let attempts = 0;
2035
+ let lastFailure;
2036
+ try {
2037
+ await retryWithBackoff(
2038
+ (attempt) => {
2039
+ attempts = attempt;
2040
+ if (attempt > 1) handle._nextAttempt(attempt);
2041
+ return sink.stepScope(step, async () => handler(element, handle, current));
2042
+ },
2043
+ {
2044
+ retries: totalRetries,
2045
+ delayMs: (attempt) => resolveDelay(options.retryDelayMs, attempt, DEFAULT_STEP_RETRY_DELAY_MS),
2046
+ // sem nova tentativa: ocorrência (regra de negócio), desfecho já registrado pelo
2047
+ // handler (ele decidiu) ou run abortada (o erro é efeito do cancelamento)
2048
+ shouldRetry: (error) => !isOccurrence(error) && !handle.reported && !sink.isAborted(),
2049
+ wait: (ms) => sink.sleep(ms),
2050
+ onRetry: (error, attempt, delayMs) => {
2051
+ lastFailure = { error };
2052
+ sink.logWarning(
2053
+ `item ${description}: tentativa ${attempt}/${totalRetries + 1} falhou: ${truncate(errorReason(error), 500)} \u2014 nova tentativa em ${delayMs}ms`
2054
+ );
2055
+ }
2056
+ }
2057
+ );
2058
+ if (!handle.reported) handle.succeeded();
2059
+ if (step) {
2060
+ noteAttempts(step, attempts);
2061
+ if (lastFailure && step.status === "ok") {
2062
+ step._setStatus("warning", `tentativa ${attempts - 1}/${totalRetries + 1} falhou: ${errorReason(lastFailure.error)}`);
2063
+ }
2064
+ }
2065
+ } catch (error) {
2066
+ if (isTerminalError(error) || sink.isAborted()) {
2067
+ step?._setStatus("cancelled")._close();
2068
+ throw error;
2069
+ }
2070
+ if (step) noteAttempts(step, attempts);
2071
+ if (isOccurrence(error)) {
2072
+ const occurrence = error;
2073
+ if (!handle.reported) handle.occurrence(occurrence.message, occurrence.payload, { image: occurrence.image });
2074
+ } else {
2075
+ const message = thrownMessage(error);
2076
+ if (!handle.reported) handle.failed(message);
2077
+ step?._failed(error);
2078
+ sink.logError(`item ${description} falhou: ${message}`);
2079
+ }
2080
+ }
2081
+ step?._close();
2082
+ if (options.progress !== false) sink.advance();
2083
+ result.total += 1;
2084
+ result[handle.status ?? "succeeded"] += 1;
2085
+ index += 1;
2086
+ if (options.flush !== false) await sink.flush();
1721
2087
  }
1722
- const { readFile } = await import("fs/promises");
1723
- return new Uint8Array(await readFile(path));
2088
+ if (result.skipped > 0) sink.logInfo(skippedMessage(result.skipped));
2089
+ return result;
1724
2090
  }
1725
- function startsWith(bytes, signature, offset = 0) {
1726
- if (bytes.length < offset + signature.length) return false;
1727
- return signature.every((byte, index) => bytes[offset + index] === byte);
2091
+ function noteAttempts(step, attempts) {
2092
+ if (attempts > 1) step._setData("attempts", attempts);
1728
2093
  }
1729
- var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
1730
- var MAX_PNG_DIMENSION = WIRE_LIMITS.MAX_IMAGE_DIMENSION_PX;
1731
- var JPEG_MAGIC = [255, 216, 255];
1732
- var RIFF_MAGIC = [82, 73, 70, 70];
1733
- var WEBP_MAGIC = [87, 69, 66, 80];
1734
- function sniffContentType(bytes) {
1735
- if (startsWith(bytes, PNG_MAGIC)) return "image/png";
1736
- if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
1737
- if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
1738
- return void 0;
2094
+
2095
+ // src/tasks/files.ts
2096
+ var LOCAL_FILES_DIR_ENV = "HOBOTS_LOCAL_FILES_DIR";
2097
+ var FILE_DOWNLOAD_IDLE_TIMEOUT_MS = 6e4;
2098
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2099
+ function isFileRef(value) {
2100
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2101
+ const ref = value;
2102
+ return typeof ref.id === "string" && typeof ref.name === "string" && typeof ref.size === "number" && typeof ref.contentType === "string" && (ref.storage === "s3" || ref.storage === "local");
1739
2103
  }
1740
- function readPngDimensions(bytes) {
1741
- if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
1742
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1743
- const width = view.getUint32(16);
1744
- const height = view.getUint32(20);
1745
- if (width === 0 || height === 0) return void 0;
1746
- if (width > MAX_PNG_DIMENSION || height > MAX_PNG_DIMENSION) return void 0;
1747
- return { width, height };
2104
+ function fileField(params, field2) {
2105
+ if (!Object.prototype.hasOwnProperty.call(params, field2)) return void 0;
2106
+ const value = params[field2];
2107
+ if (isFileRef(value)) return value;
2108
+ if (Array.isArray(value) && value.every(isFileRef)) return value;
2109
+ return void 0;
1748
2110
  }
1749
- async function toBytes(input) {
1750
- if (typeof input === "string") return readImageFile(input);
1751
- if (input instanceof Uint8Array) return input;
1752
- return new Uint8Array(input);
2111
+ function collectFileRefs(params) {
2112
+ const refs = [];
2113
+ for (const field2 of Object.keys(params)) {
2114
+ const value = fileField(params, field2);
2115
+ if (value !== void 0) refs.push(...Array.isArray(value) ? value : [value]);
2116
+ }
2117
+ return refs;
1753
2118
  }
1754
-
1755
- // src/tasks/context.ts
1756
- var LOG_LEVEL_COLOR = {
1757
- debug: "gray",
1758
- info: "cyan",
1759
- warning: "yellow",
1760
- error: "red",
1761
- success: "green"
1762
- };
1763
- function joinLogArgs(message, rest) {
1764
- if (rest.length === 0) return safeString(message);
1765
- return [message, ...rest].map(safeString).join(" ");
2119
+ function safeFileName(name, fallback) {
2120
+ const cleaned = name.replace(/^.*[\\/]/, "").replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
2121
+ return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
1766
2122
  }
1767
- function splitLogArgs(message, rest) {
1768
- const args = [message, ...rest];
1769
- const last = args[args.length - 1];
1770
- if (!isImageAttachment(last)) return { text: joinLogArgs(message, rest) };
1771
- const text = args.slice(0, -1);
1772
- if (text.length === 0) return { text: last.caption !== void 0 ? safeString(last.caption) : "captura de tela", image: last };
1773
- return { text: joinLogArgs(text[0], text.slice(1)), image: last };
2123
+ function isInsideDir(root, candidate, sep) {
2124
+ const base = root.endsWith(sep) ? root : root + sep;
2125
+ return candidate.startsWith(base) && candidate.length > base.length;
1774
2126
  }
1775
- function printRunLog(level, message) {
1776
- const line = colorize(`[hobots] ${level.toUpperCase()}`, LOG_LEVEL_COLOR[level]);
1777
- const method2 = level === "warning" ? "warn" : level === "success" ? "info" : level;
1778
- console[method2](line, message);
2127
+ function unavailableLocal(ref) {
2128
+ return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
1779
2129
  }
1780
- function cancelableSleep(ms, signal) {
1781
- return new Promise((resolve2, reject) => {
1782
- if (signal.aborted) {
1783
- reject(runCanceledError());
1784
- return;
1785
- }
1786
- const onAbort = () => {
1787
- clearTimeout(timer);
1788
- reject(runCanceledError());
1789
- };
1790
- const timer = setTimeout(() => {
1791
- signal.removeEventListener("abort", onAbort);
1792
- resolve2();
1793
- }, ms);
1794
- signal.addEventListener("abort", onAbort, { once: true });
1795
- });
2130
+ function localRunPath(ref) {
2131
+ if (!isLocalRun()) return void 0;
2132
+ const path = ref.path;
2133
+ return typeof path === "string" && path.trim() !== "" ? path : void 0;
1796
2134
  }
1797
- var DEFAULT_LOG_FLUSH_INTERVAL_MS = 2e3;
1798
- var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
1799
- var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
1800
- var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
1801
- var MAX_LOG_MESSAGE_CHARS = WIRE_LIMITS.MAX_LOG_MESSAGE_CHARS;
1802
- var MAX_LOGS_PER_PATCH = WIRE_LIMITS.MAX_LOGS_PER_PATCH;
1803
- var FLUSH_AT_BUFFERED = 20;
1804
- var MAX_ITEMS_PER_REQUEST = WIRE_LIMITS.MAX_ITEMS_PER_REQUEST;
1805
- var MAX_ITEM_MESSAGE_CHARS = WIRE_LIMITS.MAX_ITEM_MESSAGE_CHARS;
1806
- var MAX_ITEM_PAYLOAD_BYTES = WIRE_LIMITS.MAX_ITEM_PAYLOAD_BYTES;
1807
- var ITEMS_FLUSH_AT_BUFFERED = 100;
1808
- var ITEMS_DRAIN_MS = 15e3;
1809
- var PROGRESS_FLUSH_MS = 1e3;
1810
- function scrubPayloadValue(value, depth) {
1811
- if (typeof value === "string") return scrubSecrets(value);
1812
- if (depth >= 4 || value === null || typeof value !== "object") return value;
1813
- if (Array.isArray(value)) return value.map((entry) => scrubPayloadValue(entry, depth + 1));
1814
- const out = {};
1815
- for (const [key, entry] of Object.entries(value)) {
1816
- out[key] = scrubPayloadValue(entry, depth + 1);
1817
- }
1818
- return out;
2135
+ function errorCode(error) {
2136
+ return error?.code;
1819
2137
  }
1820
- var RunReporter = class {
1821
- constructor(api, runId, logger, tx, flushIntervalMs, cancelCheckIntervalMs, params = {}) {
1822
- this.api = api;
1823
- this.runId = runId;
1824
- this.logger = logger;
1825
- this.tx = tx;
1826
- this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
1827
- this.cancelCheckIntervalMs = Math.min(
1828
- MAX_CANCEL_CHECK_INTERVAL_MS,
1829
- Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
1830
- );
1831
- const logAt = (level) => (message, ...rest) => {
1832
- const { text, image: image3 } = splitLogArgs(message, rest);
1833
- this.log(level, text, image3);
1834
- };
1835
- const log = {
1836
- debug: logAt("debug"),
1837
- info: logAt("info"),
1838
- warn: logAt("warning"),
1839
- error: logAt("error"),
1840
- success: logAt("success")
1841
- };
1842
- tx.setLifecycleSink((level, message) => this.push(level, message));
1843
- tx.setAttachmentSink((image3, ts) => this.queueAttachment(image3, ts));
1844
- const checkCanceled = () => this.checkCanceled();
1845
- tx.setStepGuard(checkCanceled);
1846
- const eachSink = {
1847
- report: (status, message, payload, id, image3) => this.addItem(status, message, payload, id, image3),
1848
- startStep: (op, description) => tx.start(op, description),
1849
- declareTotal: (total) => {
1850
- if (this.progressTotal == null) this.setProgress(this.progressCurrent, total);
1851
- },
1852
- advance: () => this.setProgress(this.progressCurrent + 1, this.progressTotal),
1853
- checkCanceled,
1854
- isAborted: () => this.abortController.signal.aborted,
1855
- logError: (message) => this.log("error", message)
1856
- };
1857
- const items = {
1858
- add: (item) => {
1859
- checkCanceled();
1860
- this.addItem(item.status, item.message, item.payload, item.id, item.image);
1861
- },
1862
- succeeded: (payload, options) => {
1863
- checkCanceled();
1864
- this.addItem("succeeded", void 0, payload, options?.id, options?.image);
1865
- },
1866
- failed: (message, payload, options) => {
1867
- checkCanceled();
1868
- this.addItem("failed", message, payload, options?.id, options?.image);
1869
- },
1870
- occurrence: (message, payload, options) => {
1871
- checkCanceled();
1872
- this.addItem("occurrence", message, payload, options?.id, options?.image);
1873
- },
1874
- each: (list, handler, options = {}) => eachItem(list, handler, options, eachSink)
2138
+ var RunFileStore = class {
2139
+ constructor(deps) {
2140
+ this.deps = deps;
2141
+ this.files = {
2142
+ list: () => collectFileRefs(deps.params),
2143
+ get: (field2) => fileField(deps.params, field2),
2144
+ // async: erro de uso (campo sem arquivo, valor inválido) chega como Promise rejeitada, nunca síncrono
2145
+ download: async (refOrField, options) => this.download(this.single(refOrField), options?.dest),
2146
+ downloadAll: async (field2, options) => this.downloadAll(field2, options?.dest),
2147
+ read: async (refOrField) => this.read(this.single(refOrField))
1875
2148
  };
1876
- const progress = Object.defineProperties(
1877
- {
1878
- total: (total) => {
1879
- checkCanceled();
1880
- this.setProgress(this.progressCurrent, total);
1881
- },
1882
- advance: (count = 1) => {
1883
- checkCanceled();
1884
- this.setProgress(this.progressCurrent + count, this.progressTotal);
1885
- },
1886
- set: (current, total) => {
1887
- checkCanceled();
1888
- this.setProgress(current, total ?? this.progressTotal);
1889
- }
1890
- },
1891
- {
1892
- current: { enumerable: true, get: () => this.progressCurrent },
1893
- snapshot: {
1894
- enumerable: true,
1895
- get: () => this.progressTotal == null ? null : { current: this.progressCurrent, total: this.progressTotal }
1896
- }
2149
+ }
2150
+ deps;
2151
+ files;
2152
+ tempDir;
2153
+ /** Apaga a pasta temporária da execução (cópias que o bot não levou para `dest`). Nunca lança. */
2154
+ async cleanup() {
2155
+ const pending = this.tempDir;
2156
+ this.tempDir = void 0;
2157
+ if (!pending) return;
2158
+ try {
2159
+ const { rm } = await import("fs/promises");
2160
+ await rm(await pending, { recursive: true, force: true });
2161
+ } catch {
2162
+ }
2163
+ }
2164
+ field(field2) {
2165
+ const value = fileField(this.deps.params, field2);
2166
+ if (value === void 0) throw new Error(`campo "${field2}" n\xE3o tem arquivo nos params`);
2167
+ return value;
2168
+ }
2169
+ single(refOrField) {
2170
+ if (typeof refOrField === "string") {
2171
+ const value = this.field(refOrField);
2172
+ if (Array.isArray(value)) {
2173
+ throw new Error(`campo "${refOrField}" tem v\xE1rios arquivos \u2014 use downloadAll("${refOrField}") ou passe um item da lista`);
1897
2174
  }
1898
- // defineProperties não tipa os getters adicionados — a interface é a fonte de verdade
1899
- );
1900
- const signal = this.abortController.signal;
1901
- const sleep2 = (ms) => cancelableSleep(ms, signal);
1902
- this.fileStore = new RunFileStore({ api, runId, params, signal, warn: (message) => this.log("warning", message) });
1903
- this.context = {
1904
- runId,
1905
- params,
1906
- log,
1907
- items,
1908
- progress,
1909
- files: this.fileStore.files,
1910
- transaction: tx,
1911
- flush: (timeoutMs) => this.flush(timeoutMs),
1912
- signal,
1913
- throwIfCanceled: checkCanceled,
1914
- sleep: sleep2,
1915
- retry: (fn, options) => retryWithBackoff(fn, { ...options, signal, wait: sleep2 })
1916
- };
2175
+ return value;
2176
+ }
2177
+ if (!isFileRef(refOrField)) throw new TypeError("esperado um FileRef ou o nome de um campo de arquivo");
2178
+ return refOrField;
1917
2179
  }
1918
- api;
1919
- runId;
1920
- logger;
1921
- tx;
1922
- context;
1923
- buffer = [];
1924
- dead = false;
1925
- chain = Promise.resolve();
1926
- pendingUpdates = 0;
1927
- timer;
1928
- watchTimer;
1929
- abortController = new AbortController();
1930
- flushIntervalMs;
1931
- cancelCheckIntervalMs;
1932
- // Itens de execução vivem FORA de `this.chain` (status/liveness não pode ficar
1933
- // atrás de milhares de itens), mas têm cadeia própria serializada — ordem de
1934
- // envio preservada e sem lotes sobrepostos.
1935
- itemsBuffer = [];
1936
- itemsChain = Promise.resolve();
1937
- itemsTimer;
1938
- itemSeq = 0;
1939
- /** Servidor sem o recurso (404) — para de tentar (e de avisar) nesta run. */
1940
- itemsUnsupported = false;
1941
- /**
1942
- * Cota de itens por run do servidor (429, legado — hoje a api não tem teto de itens) —
1943
- * degrada com um aviso só.
1944
- */
1945
- itemsQuotaExceeded = false;
1946
- /**
1947
- * Armazenamento do plano cheio (402 `storage_limit`): o lote é descartado e o aviso sai
1948
- * uma vez por episódio. Os envios continuam — num agente `start()` o espaço pode ser
1949
- * liberado (ou o plano trocado) sem reiniciar; um lote aceito rearma o aviso.
1950
- */
1951
- itemsStorageFull = false;
1952
- /**
1953
- * Só o kill() do exit path fecha os itens — `dead` (run cancelada/finalizada no
1954
- * servidor) NÃO fecha: o servidor aceita itens de run terminal (trabalho já feito).
1955
- */
1956
- itemsClosed = false;
1957
- // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
1958
- // status/logs e não pode ficar bloqueada esperando um binário subir.
1959
- pendingAttachments = /* @__PURE__ */ new Set();
1960
- attachmentCount = 0;
1961
- uploadSlots = MAX_CONCURRENT_UPLOADS;
1962
- uploadQueue = [];
1963
- /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
1964
- attachmentsUnsupported = false;
1965
- // Progresso (ctx.progress): snapshot absoluto, enviado com throttle via piggyback
1966
- // em `enqueue()` — nunca gera tráfego próprio enquanto logs/heartbeat já fluem.
1967
- progressCurrent = 0;
1968
- progressTotal = null;
1969
- /** Snapshot ainda não entregue ao servidor. */
1970
- progressDirty = false;
1971
- progressTimer;
1972
- progressWarned = false;
1973
- /** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
1974
- fileStore;
1975
- /** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
1976
- cleanupFiles() {
1977
- return this.fileStore.cleanup();
2180
+ async downloadAll(field2, dest) {
2181
+ const value = this.field(field2);
2182
+ const refs = Array.isArray(value) ? value : [value];
2183
+ const paths = [];
2184
+ for (const ref of refs) paths.push(await this.download(ref, dest, true));
2185
+ return paths;
1978
2186
  }
1979
- get isDead() {
1980
- return this.dead;
2187
+ async read(ref) {
2188
+ const { readFile, rm } = await import("fs/promises");
2189
+ const { dirname } = await import("path");
2190
+ const path = await this.download(ref, void 0);
2191
+ try {
2192
+ return await readFile(path);
2193
+ } finally {
2194
+ await rm(dirname(path), { recursive: true, force: true });
2195
+ }
2196
+ }
2197
+ async download(ref, dest, destIsDir = false) {
2198
+ this.checkCanceled();
2199
+ const target = await this.resolveTarget(ref, dest, destIsDir);
2200
+ if (ref.storage === "local") await this.copyLocal(ref, target);
2201
+ else await this.downloadS3(ref, target);
2202
+ return target;
1981
2203
  }
1982
- /** Lança `RunCanceled` se o signal desta run já disparou. */
1983
2204
  checkCanceled() {
1984
- if (this.abortController.signal.aborted) throw runCanceledError();
2205
+ if (this.deps.signal.aborted) throw runCanceledError();
1985
2206
  }
1986
- /** Aborta o ctx.signal sem matar o reporter (timeout/shutdown ainda reportam o terminal). */
1987
- abort() {
1988
- this.stopCancelWatch();
1989
- if (!this.abortController.signal.aborted) this.abortController.abort();
2207
+ runTempDir() {
2208
+ this.tempDir ??= (async () => {
2209
+ const { mkdtemp } = await import("fs/promises");
2210
+ const { tmpdir } = await import("os");
2211
+ const { join } = await import("path");
2212
+ return mkdtemp(join(tmpdir(), "hobots-files-"));
2213
+ })();
2214
+ return this.tempDir;
1990
2215
  }
1991
- /**
1992
- * Mata o reporter de vez: aborta o signal e suprime todo reporte futuro.
1993
- * Usado no exit path — depois que o agente reportou AgentTerminated por fora
1994
- * da fila, um handler zumbi não pode reenviar status por cima.
1995
- */
1996
- kill() {
1997
- this.abort();
1998
- this.dead = true;
1999
- this.clearFlushTimer();
2000
- this.clearProgressTimer();
2001
- this.buffer = [];
2002
- this.itemsClosed = true;
2003
- this.clearItemsTimer();
2004
- if (this.itemsBuffer.length > 0) {
2005
- this.logger.warn(`${this.itemsBuffer.length} item(ns) de execu\xE7\xE3o descartados (processo encerrando)`);
2006
- this.itemsBuffer = [];
2216
+ async resolveTarget(ref, dest, destIsDir) {
2217
+ const { mkdir, mkdtemp, stat } = await import("fs/promises");
2218
+ const { dirname, join, resolve: resolve2 } = await import("path");
2219
+ const name = safeFileName(ref.name, "arquivo");
2220
+ if (dest === void 0) {
2221
+ return join(await mkdtemp(join(await this.runTempDir(), "f-")), name);
2222
+ }
2223
+ const isDir = destIsDir || dest.endsWith("/") || dest.endsWith("\\") || (await stat(dest).catch(() => void 0))?.isDirectory() === true;
2224
+ if (isDir) {
2225
+ await mkdir(dest, { recursive: true });
2226
+ return resolve2(dest, name);
2227
+ }
2228
+ await mkdir(dirname(resolve2(dest)), { recursive: true });
2229
+ return resolve2(dest);
2230
+ }
2231
+ /** storage 's3': URL pré-assinada → GET em streaming → confere o tamanho → só então DELETE. */
2232
+ async downloadS3(ref, target) {
2233
+ const { api, runId, signal } = this.deps;
2234
+ let expected;
2235
+ let url;
2236
+ try {
2237
+ const info = await api.requestFileDownload(runId, ref.id, signal);
2238
+ expected = info.size >= 0 ? info.size : ref.size;
2239
+ url = info.url;
2240
+ } catch (error) {
2241
+ throw this.translate(error, ref);
2242
+ }
2243
+ const { open, rename, rm } = await import("fs/promises");
2244
+ const part = `${target}.part`;
2245
+ const idleTimeoutMs = this.deps.idleTimeoutMs ?? FILE_DOWNLOAD_IDLE_TIMEOUT_MS;
2246
+ const controller = new AbortController();
2247
+ let timedOut = false;
2248
+ let timer;
2249
+ const arm = () => {
2250
+ clearTimeout(timer);
2251
+ timer = setTimeout(() => {
2252
+ timedOut = true;
2253
+ controller.abort();
2254
+ }, idleTimeoutMs);
2255
+ };
2256
+ const onCancel = () => controller.abort();
2257
+ signal.addEventListener("abort", onCancel, { once: true });
2258
+ try {
2259
+ arm();
2260
+ const response = await api.fetchFile(url, controller.signal);
2261
+ const handle = await open(part, "w");
2262
+ let written = 0;
2263
+ try {
2264
+ const reader = response.body?.getReader();
2265
+ const stopReading = () => void reader?.cancel().catch(() => void 0);
2266
+ controller.signal.addEventListener("abort", stopReading, { once: true });
2267
+ for (; ; ) {
2268
+ const chunk = reader ? await reader.read() : { done: true, value: void 0 };
2269
+ if (chunk.done) break;
2270
+ arm();
2271
+ written += chunk.value.byteLength;
2272
+ if (written > expected) {
2273
+ stopReading();
2274
+ break;
2275
+ }
2276
+ await handle.write(chunk.value);
2277
+ }
2278
+ } finally {
2279
+ await handle.close();
2280
+ }
2281
+ if (written !== expected) {
2282
+ throw new Error(`tamanho divergente no download de "${ref.name}": recebidos ${written} bytes, esperados ${expected}`);
2283
+ }
2284
+ await rename(part, target);
2285
+ } catch (error) {
2286
+ await rm(part, { force: true });
2287
+ if (signal.aborted) throw runCanceledError();
2288
+ if (timedOut) {
2289
+ throw new Error(`download de "${ref.name}" parado h\xE1 ${idleTimeoutMs / 1e3}s \u2014 tente de novo`);
2290
+ }
2291
+ throw this.translate(error, ref);
2292
+ } finally {
2293
+ clearTimeout(timer);
2294
+ signal.removeEventListener("abort", onCancel);
2295
+ }
2296
+ try {
2297
+ await api.confirmFileDownload(runId, ref.id);
2298
+ } catch (error) {
2299
+ this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
2007
2300
  }
2008
- const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
2009
- for (const resume of waiting) resume();
2010
2301
  }
2011
2302
  /**
2012
- * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
2013
- * que descarta o buffer). Capado em um chunk — o exit path não tem retry nem fila,
2014
- * então preserva os logs mais recentes, os mais valiosos na morte do processo.
2303
+ * storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de
2304
+ * origem. No modo local, um FileRef com `path` copia direto desse caminho, sem apagar a origem.
2015
2305
  */
2016
- drainLogs() {
2017
- this.clearFlushTimer();
2018
- return this.takeLogs().slice(-MAX_LOGS_PER_PATCH);
2306
+ async copyLocal(ref, target) {
2307
+ const direct = localRunPath(ref);
2308
+ if (direct !== void 0) {
2309
+ await this.copyFromPath(ref, direct, target);
2310
+ return;
2311
+ }
2312
+ const base = process.env[LOCAL_FILES_DIR_ENV];
2313
+ if (!base) throw unavailableLocal(ref);
2314
+ const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
2315
+ const { join, resolve: resolve2, sep } = await import("path");
2316
+ const root = resolve2(base);
2317
+ const folder = resolve2(root, ref.id);
2318
+ if (!isInsideDir(root, folder, sep) || !UUID_RE.test(ref.id)) {
2319
+ throw new Error(`id de arquivo local inv\xE1lido: ${JSON.stringify(ref.id)}`);
2320
+ }
2321
+ let names;
2322
+ try {
2323
+ names = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
2324
+ } catch (error) {
2325
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") throw unavailableLocal(ref);
2326
+ throw error;
2327
+ }
2328
+ if (names.length === 0) throw unavailableLocal(ref);
2329
+ if (names.length > 1) throw new Error(`pasta do arquivo "${ref.name}" tem ${names.length} arquivos \u2014 esperado 1`);
2330
+ this.checkCanceled();
2331
+ const part = `${target}.part`;
2332
+ try {
2333
+ await copyFile(join(folder, names[0]), part);
2334
+ const { size } = await stat(part);
2335
+ if (size !== ref.size) {
2336
+ throw new Error(`tamanho divergente na c\xF3pia de "${ref.name}": copiados ${size} bytes, esperados ${ref.size}`);
2337
+ }
2338
+ await rename(part, target);
2339
+ } catch (error) {
2340
+ await rm(part, { force: true });
2341
+ throw error;
2342
+ }
2343
+ await rm(folder, { recursive: true, force: true });
2019
2344
  }
2020
2345
  /**
2021
- * Linha de log da run com eco no terminal. `image` (opcional) vira o anexo da linha:
2022
- * 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.
2346
+ * Modo local: cópia do arquivo apontado por `path` (teste roda várias vezes — a fonte fica).
2347
+ * Sem conferência de tamanho: o `size` do FileRef é informativo aqui.
2024
2348
  */
2025
- log(level, message, image3) {
2026
- if (this.dead) return;
2027
- const scrubbed = scrubSecrets(safeString(message));
2028
- const ts = nowISO();
2029
- const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2030
- printRunLog(level, attachmentId ? `${scrubbed} [imagem]` : scrubbed);
2031
- this.push(level, scrubbed, true, attachmentId ? [attachmentId] : void 0, ts);
2349
+ async copyFromPath(ref, path, target) {
2350
+ const { copyFile, rename, rm, stat } = await import("fs/promises");
2351
+ const { resolve: resolve2 } = await import("path");
2352
+ const source = resolve2(path);
2353
+ const info = await stat(source).catch(() => void 0);
2354
+ if (!info?.isFile()) throw new Error(`arquivo "${ref.name}" n\xE3o encontrado em ${path}`);
2355
+ this.checkCanceled();
2356
+ const part = `${target}.part`;
2357
+ try {
2358
+ await copyFile(source, part);
2359
+ await rename(part, target);
2360
+ } catch (error) {
2361
+ await rm(part, { force: true });
2362
+ throw error;
2363
+ }
2032
2364
  }
2033
- /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
2034
- push(level, message, alreadyScrubbed = false, attachments, ts = nowISO()) {
2035
- if (this.dead) return ts;
2036
- const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
2037
- this.buffer.push({
2038
- ts,
2039
- level,
2040
- message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
2041
- ...attachments && attachments.length > 0 ? { attachments: attachments.slice(0, WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY) } : {}
2042
- });
2043
- if (this.buffer.length >= FLUSH_AT_BUFFERED) {
2044
- this.flushLogs();
2045
- } else {
2046
- this.scheduleFlush();
2365
+ translate(error, ref) {
2366
+ if (this.deps.signal.aborted) return runCanceledError();
2367
+ if (error instanceof TasksApiError && error.status === 410) {
2368
+ return new TasksApiError(`arquivo "${ref.name}" j\xE1 baixado ou expirado`, 410);
2047
2369
  }
2048
- return ts;
2370
+ if (error instanceof TasksApiError && error.status === 404) {
2371
+ return new TasksApiError(`arquivo "${ref.name}" n\xE3o encontrado nesta execu\xE7\xE3o`, 404);
2372
+ }
2373
+ return error;
2049
2374
  }
2050
- scheduleFlush() {
2051
- if (this.timer) return;
2052
- this.timer = setTimeout(() => {
2053
- this.timer = void 0;
2054
- this.flushLogs();
2055
- }, this.flushIntervalMs);
2056
- unrefTimer(this.timer);
2057
- }
2058
- clearFlushTimer() {
2059
- if (this.timer) {
2060
- clearTimeout(this.timer);
2061
- this.timer = void 0;
2062
- }
2063
- }
2064
- takeLogs() {
2065
- return this.buffer.splice(0, this.buffer.length);
2066
- }
2067
- /** Flush incremental: envia o buffer como status 'running' (em chunks). */
2068
- flushLogs() {
2069
- this.clearFlushTimer();
2070
- if (this.dead || this.buffer.length === 0) return;
2071
- const logs = this.buffer.splice(0, this.buffer.length);
2072
- for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
2073
- this.enqueue({ status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) });
2074
- }
2075
- }
2076
- /**
2077
- * `hobots.flush()`: o bot decide quando entregar o que já fez — steps raiz finalizadas
2078
- * viram transactions parciais da run, e itens, logs e progresso pendentes saem já, sem
2079
- * esperar o throttle. Aguarda a entrega com teto. Nunca lança (pode ir num `finally`).
2080
- */
2081
- async flush(timeoutMs = ITEMS_DRAIN_MS) {
2082
- this.tx.flush();
2083
- this.flushItems();
2084
- if (this.progressDirty) this.flushProgress();
2085
- else this.flushLogs();
2086
- await Promise.race([
2087
- Promise.all([this.itemsChain, this.chain, this.tx.drain(timeoutMs)]).catch(() => void 0),
2088
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2089
- ]);
2090
- }
2091
- /** Primeiro PATCH (claimed → running). Retorna false se a run já morreu (cancelada). */
2092
- async reportRunning() {
2093
- this.enqueue({ status: "running" });
2094
- await this.chain;
2095
- if (!this.dead) this.startCancelWatch();
2096
- return !this.dead;
2097
- }
2098
- reportSuccess() {
2099
- this.stopCancelWatch();
2100
- this.clearFlushTimer();
2101
- this.forceFinalProgress();
2102
- this.enqueue({ status: "succeeded", logs: this.takeLogsForTerminal() });
2103
- }
2104
- reportFailure(error) {
2105
- this.stopCancelWatch();
2106
- this.clearFlushTimer();
2107
- this.forceFinalProgress();
2108
- this.enqueue({ status: "failed", error, logs: this.takeLogsForTerminal() });
2109
- }
2110
- /**
2111
- * Esvazia o buffer para o PATCH terminal respeitando o teto de 100 logs por PATCH:
2112
- * o excedente sai antes, em PATCHes `running` na mesma cadeia (a ordem se mantém).
2113
- */
2114
- takeLogsForTerminal() {
2115
- let logs = this.takeLogs();
2116
- while (logs.length > MAX_LOGS_PER_PATCH) {
2117
- this.enqueue({ status: "running", logs: logs.slice(0, MAX_LOGS_PER_PATCH) });
2118
- logs = logs.slice(MAX_LOGS_PER_PATCH);
2119
- }
2120
- return logs;
2121
- }
2122
- // ── Progresso ────────────────────────────────────────────────────────────────
2123
- /** Valida, clampa e marca o snapshot como pendente; agenda flush só se nada mais vai sair. */
2124
- setProgress(current, total) {
2125
- if (this.dead) return;
2126
- if (total != null && (!Number.isInteger(total) || total < 1)) {
2127
- if (!this.progressWarned) {
2128
- this.progressWarned = true;
2129
- this.logger.warn(`ctx.progress: total inv\xE1lido (${String(total)}) \u2014 precisa ser inteiro >= 1; ignorado`);
2130
- }
2375
+ };
2376
+
2377
+ // src/tasks/context.ts
2378
+ var LOG_LEVEL_COLOR = {
2379
+ debug: "gray",
2380
+ info: "cyan",
2381
+ warning: "yellow",
2382
+ error: "red",
2383
+ success: "green"
2384
+ };
2385
+ function joinLogArgs(message, rest) {
2386
+ if (rest.length === 0) return safeString(message);
2387
+ return [message, ...rest].map(safeString).join(" ");
2388
+ }
2389
+ function splitLogArgs(message, rest) {
2390
+ const args = [message, ...rest];
2391
+ const last = args[args.length - 1];
2392
+ if (!isImageAttachment(last)) return { text: joinLogArgs(message, rest) };
2393
+ const text = args.slice(0, -1);
2394
+ if (text.length === 0) return { text: last.caption !== void 0 ? safeString(last.caption) : "captura de tela", image: last };
2395
+ return { text: joinLogArgs(text[0], text.slice(1)), image: last };
2396
+ }
2397
+ function printRunLog(level, message) {
2398
+ const line = colorize(`[hobots] ${level.toUpperCase()}`, LOG_LEVEL_COLOR[level]);
2399
+ const method2 = level === "warning" ? "warn" : level === "success" ? "info" : level;
2400
+ console[method2](line, message);
2401
+ }
2402
+ var PROGRESS_LINE_PREFIX = "[hobots:progress]";
2403
+ var PROGRESS_PRINT_THROTTLE_MS = 250;
2404
+ function printRunProgress(current, total) {
2405
+ console.log(`${PROGRESS_LINE_PREFIX} ${current}/${total}`);
2406
+ }
2407
+ function cancelableSleep(ms, signal) {
2408
+ return new Promise((resolve2, reject) => {
2409
+ if (signal.aborted) {
2410
+ reject(runCanceledError());
2131
2411
  return;
2132
2412
  }
2133
- const safeCurrent = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : this.progressCurrent;
2134
- const next = total == null ? safeCurrent : Math.min(safeCurrent, total);
2135
- if (next === this.progressCurrent && total === this.progressTotal) return;
2136
- this.progressCurrent = next;
2137
- this.progressTotal = total;
2138
- if (total == null) return;
2139
- this.progressDirty = true;
2140
- if (this.progressTimer) return;
2141
- this.progressTimer = setTimeout(() => {
2142
- this.progressTimer = void 0;
2143
- this.flushProgress();
2144
- }, PROGRESS_FLUSH_MS);
2145
- unrefTimer(this.progressTimer);
2146
- }
2147
- flushProgress() {
2148
- this.clearProgressTimer();
2149
- if (this.dead || !this.progressDirty) return;
2150
- if (this.buffer.length > 0) this.flushLogs();
2151
- else this.enqueue({ status: "running" });
2152
- }
2153
- clearProgressTimer() {
2154
- if (this.progressTimer) {
2155
- clearTimeout(this.progressTimer);
2156
- this.progressTimer = void 0;
2157
- }
2158
- }
2159
- /** O PATCH terminal sempre leva o snapshot final (o app mostra "87/100" numa run que falhou). */
2160
- forceFinalProgress() {
2161
- this.clearProgressTimer();
2162
- this.progressDirty = this.progressTotal != null;
2163
- }
2164
- /** Consome o snapshot pendente para pegar carona no próximo PATCH. */
2165
- takeProgress() {
2166
- if (!this.progressDirty || this.progressTotal == null) return void 0;
2167
- this.progressDirty = false;
2168
- return { current: this.progressCurrent, total: this.progressTotal };
2169
- }
2170
- /**
2171
- * Heartbeat de cancelamento: enquanto o handler roda, um PATCH 'running' vazio
2172
- * periódico faz o servidor responder 409 se a run foi cancelada no app — o que
2173
- * mata o reporter e dispara ctx.signal, mesmo em handlers que nunca logam.
2174
- */
2175
- startCancelWatch() {
2176
- if (this.watchTimer) return;
2177
- this.watchTimer = setInterval(() => {
2178
- if (this.dead || this.pendingUpdates > 0 || this.buffer.length > 0) return;
2179
- this.enqueue({ status: "running" });
2180
- }, this.cancelCheckIntervalMs);
2181
- unrefTimer(this.watchTimer);
2182
- }
2183
- stopCancelWatch() {
2184
- if (this.watchTimer) {
2185
- clearInterval(this.watchTimer);
2186
- this.watchTimer = void 0;
2187
- }
2188
- }
2189
- /** Aguarda a fila de PATCHes esvaziar (chame depois do terminal). */
2190
- finalize() {
2191
- return this.chain;
2192
- }
2193
- // ── Itens de execução ────────────────────────────────────────────────────────
2194
- /**
2195
- * Bufferiza um item (síncrono — a chamada nunca espera rede). Diferente do PATCH
2196
- * de status, o servidor aceita itens mesmo com a run cancelada (trabalho já feito),
2197
- * então um reporter morto não descarta: só um kill() de exit path descarta.
2198
- */
2199
- addItem(status, message, payload, id, image3) {
2200
- if (this.itemsClosed || this.itemsUnsupported) return;
2201
- let scrubbedPayload;
2202
- if (payload) {
2203
- scrubbedPayload = scrubPayloadValue(payload, 0);
2204
- let serializedLength;
2205
- try {
2206
- serializedLength = JSON.stringify(scrubbedPayload).length;
2207
- } catch {
2208
- }
2209
- if (serializedLength === void 0 || serializedLength > MAX_ITEM_PAYLOAD_BYTES) {
2210
- this.logger.warn(
2211
- `payload do item descartado (${serializedLength === void 0 ? "n\xE3o serializ\xE1vel" : `${serializedLength} bytes > ${MAX_ITEM_PAYLOAD_BYTES}`}) \u2014 o item foi mantido`
2212
- );
2213
- scrubbedPayload = void 0;
2214
- }
2215
- }
2216
- const ts = nowISO();
2217
- const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2218
- this.itemsBuffer.push({
2219
- id: id ? truncate(String(id), 255) : uuid4(),
2220
- seq: this.itemSeq++,
2221
- status,
2222
- ...message ? { message: truncate(scrubSecrets(safeString(message)), MAX_ITEM_MESSAGE_CHARS) } : {},
2223
- ...scrubbedPayload ? { payload: scrubbedPayload } : {},
2224
- ts,
2225
- ...attachmentId ? { attachments: [attachmentId] } : {}
2226
- });
2227
- if (this.itemsBuffer.length >= ITEMS_FLUSH_AT_BUFFERED) {
2228
- this.flushItems();
2229
- } else {
2230
- this.scheduleItemsFlush();
2231
- }
2232
- }
2233
- scheduleItemsFlush() {
2234
- if (this.itemsTimer) return;
2235
- this.itemsTimer = setTimeout(() => {
2236
- this.itemsTimer = void 0;
2237
- this.flushItems();
2238
- }, this.flushIntervalMs);
2239
- unrefTimer(this.itemsTimer);
2240
- }
2241
- clearItemsTimer() {
2242
- if (this.itemsTimer) {
2243
- clearTimeout(this.itemsTimer);
2244
- this.itemsTimer = void 0;
2245
- }
2246
- }
2247
- /** Envia o buffer de itens em lotes serializados na cadeia própria. */
2248
- flushItems() {
2249
- this.clearItemsTimer();
2250
- if (this.itemsBuffer.length === 0 || this.itemsUnsupported) return;
2251
- const items = this.itemsBuffer.splice(0, this.itemsBuffer.length);
2252
- for (let i = 0; i < items.length; i += MAX_ITEMS_PER_REQUEST) {
2253
- const batch = items.slice(i, i + MAX_ITEMS_PER_REQUEST);
2254
- this.itemsChain = this.itemsChain.then(() => this.sendItems(batch));
2255
- }
2256
- }
2257
- async sendItems(batch) {
2258
- if (this.itemsUnsupported) return;
2259
- try {
2260
- await this.api.createItems(this.runId, { items: batch });
2261
- this.itemsStorageFull = false;
2262
- } catch (error) {
2263
- if (error instanceof TasksApiError && error.status === 404) {
2264
- this.itemsUnsupported = true;
2265
- this.logger.warn("itens de execu\xE7\xE3o n\xE3o s\xE3o suportados por este servidor Hobots \u2014 descartados");
2266
- return;
2267
- }
2268
- if (error instanceof TasksApiError && error.status === 429) {
2269
- if (!this.itemsQuotaExceeded) {
2270
- this.itemsQuotaExceeded = true;
2271
- this.log("warning", "itens descartados: limite de itens por execu\xE7\xE3o atingido no servidor");
2272
- }
2273
- return;
2274
- }
2275
- if (error instanceof TasksApiError && error.status === 402) {
2276
- if (!this.itemsStorageFull) {
2277
- this.itemsStorageFull = true;
2278
- this.log("warning", "itens descartados: armazenamento do plano cheio \u2014 libere espa\xE7o ou fa\xE7a upgrade em Plano e cobran\xE7a");
2279
- }
2280
- return;
2281
- }
2282
- if (error instanceof TasksApiError) {
2283
- this.logger.error(`lote de ${batch.length} item(ns) rejeitado pelo servidor:`, error);
2284
- this.log("warning", `${batch.length} item(ns) descartado(s): envio rejeitado pelo servidor (HTTP ${error.status})`);
2285
- return;
2286
- }
2287
- this.itemsBuffer.unshift(...batch);
2288
- this.scheduleItemsFlush();
2289
- this.logger.error(`lote de ${batch.length} item(ns) n\xE3o enviado (devolvido ao buffer):`, error);
2290
- }
2291
- }
2292
- /**
2293
- * Dreno final de itens antes do PATCH terminal (com teto): garante que os
2294
- * totalizadores estejam completos quando a run vira succeeded/failed. Como o
2295
- * servidor aceita itens tardios, perder a corrida aqui não perde dados.
2296
- */
2297
- async finalizeItems(timeoutMs = ITEMS_DRAIN_MS) {
2298
- this.flushItems();
2299
- await Promise.race([
2300
- this.itemsChain,
2301
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2302
- ]);
2303
- if (this.itemsBuffer.length > 0 && !this.itemsUnsupported && !this.itemsQuotaExceeded) {
2304
- this.flushItems();
2305
- await Promise.race([
2306
- this.itemsChain,
2307
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2308
- ]);
2309
- }
2310
- }
2311
- // ── Anexos ───────────────────────────────────────────────────────────────────
2312
- /**
2313
- * Pendura uma imagem num evento da run (log, item ou step): faz as checagens que não
2314
- * dependem de I/O (reporter morto, instalação sem anexos, cota), reserva o id e dispara
2315
- * o upload em segundo plano. Devolve o id — já referenciável pelo evento — ou undefined
2316
- * quando a imagem foi descartada (sempre com aviso no log da run). Nunca lança: todo
2317
- * caminho de erro do upload vira aviso. O hint `attachment` de `hobots.captureException`
2318
- * também cai aqui via `log()`.
2319
- */
2320
- queueAttachment(imageIn, ts) {
2321
- if (this.dead) return void 0;
2322
- const image3 = toImageAttachment(imageIn);
2323
- if (this.attachmentsUnsupported) return void 0;
2324
- if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
2325
- if (this.attachmentCount === MAX_ATTACHMENTS_PER_RUN) {
2326
- this.attachmentCount++;
2327
- this.log("warning", `imagem descartada: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
2328
- }
2329
- return void 0;
2330
- }
2331
- this.attachmentCount++;
2332
- const id = uuid4();
2333
- const rawCaption = image3.caption !== void 0 ? safeString(image3.caption) : void 0;
2334
- const caption = rawCaption ? truncate(scrubSecrets(rawCaption), MAX_CAPTION_CHARS) : void 0;
2335
- const work = this.uploadAttachment(id, image3.input, { caption, logTs: ts, contentType: image3.contentType }).then(() => void 0).catch(() => void 0);
2336
- this.pendingAttachments.add(work);
2337
- void work.finally(() => this.pendingAttachments.delete(work));
2338
- return id;
2413
+ const onAbort = () => {
2414
+ clearTimeout(timer);
2415
+ reject(runCanceledError());
2416
+ };
2417
+ const timer = setTimeout(() => {
2418
+ signal.removeEventListener("abort", onAbort);
2419
+ resolve2();
2420
+ }, ms);
2421
+ signal.addEventListener("abort", onAbort, { once: true });
2422
+ });
2423
+ }
2424
+ var DEFAULT_LOG_FLUSH_INTERVAL_MS = 2e3;
2425
+ var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
2426
+ var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
2427
+ var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
2428
+ var MAX_LOG_MESSAGE_CHARS = WIRE_LIMITS.MAX_LOG_MESSAGE_CHARS;
2429
+ var MAX_LOGS_PER_PATCH = WIRE_LIMITS.MAX_LOGS_PER_PATCH;
2430
+ var FLUSH_AT_BUFFERED = 20;
2431
+ var MAX_ITEMS_PER_REQUEST = WIRE_LIMITS.MAX_ITEMS_PER_REQUEST;
2432
+ var MAX_ITEMS_DONE_PER_PAGE = WIRE_LIMITS.MAX_ITEMS_DONE_PER_PAGE;
2433
+ var MAX_ITEM_MESSAGE_CHARS = WIRE_LIMITS.MAX_ITEM_MESSAGE_CHARS;
2434
+ var MAX_ITEM_PAYLOAD_BYTES = WIRE_LIMITS.MAX_ITEM_PAYLOAD_BYTES;
2435
+ var ITEMS_FLUSH_AT_BUFFERED = 100;
2436
+ var ITEMS_DRAIN_MS = 15e3;
2437
+ var PROGRESS_FLUSH_MS = 1e3;
2438
+ function scrubPayloadValue(value, depth) {
2439
+ if (typeof value === "string") return scrubSecrets(value);
2440
+ if (depth >= 4 || value === null || typeof value !== "object") return value;
2441
+ if (Array.isArray(value)) return value.map((entry) => scrubPayloadValue(entry, depth + 1));
2442
+ const out = {};
2443
+ for (const [key, entry] of Object.entries(value)) {
2444
+ out[key] = scrubPayloadValue(entry, depth + 1);
2339
2445
  }
2340
- /** Retorna o motivo da falha, ou undefined em sucesso. */
2341
- async uploadAttachment(id, image3, meta) {
2342
- let bytes;
2343
- try {
2344
- bytes = await toBytes(image3);
2345
- } catch (error) {
2346
- this.log("warning", `imagem n\xE3o p\xF4de ser lida: ${String(error)}`);
2347
- return "read-failed";
2348
- }
2349
- const contentType = sniffContentType(bytes) ?? meta.contentType;
2350
- if (!contentType) {
2351
- this.log("warning", "imagem descartada: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
2352
- return "unsupported-format";
2353
- }
2354
- if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
2355
- this.log(
2356
- "warning",
2357
- `imagem descartada: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
2358
- );
2359
- return "too-large";
2360
- }
2361
- const dimensions = readPngDimensions(bytes);
2362
- const release = await this.acquireUploadSlot();
2363
- try {
2364
- if (this.abortController.signal.aborted || this.dead) return "canceled";
2365
- const created = await this.api.createAttachment(
2366
- this.runId,
2367
- {
2368
- id,
2369
- content_type: contentType,
2370
- size_bytes: bytes.byteLength,
2371
- log_ts: meta.logTs,
2372
- ...meta.caption ? { caption: meta.caption } : {},
2373
- ...dimensions ?? {}
2446
+ return out;
2447
+ }
2448
+ var RunReporter = class {
2449
+ constructor(api, runId, logger, tx, flushIntervalMs, cancelCheckIntervalMs, params = {}) {
2450
+ this.api = api;
2451
+ this.runId = runId;
2452
+ this.logger = logger;
2453
+ this.tx = tx;
2454
+ this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
2455
+ this.cancelCheckIntervalMs = Math.min(
2456
+ MAX_CANCEL_CHECK_INTERVAL_MS,
2457
+ Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
2458
+ );
2459
+ const logAt = (level) => (message, ...rest) => {
2460
+ const { text, image: image3 } = splitLogArgs(message, rest);
2461
+ this.log(level, text, image3);
2462
+ };
2463
+ const log = {
2464
+ debug: logAt("debug"),
2465
+ info: logAt("info"),
2466
+ warn: logAt("warning"),
2467
+ error: logAt("error"),
2468
+ success: logAt("success")
2469
+ };
2470
+ tx.setLifecycleSink((level, message) => this.push(level, message));
2471
+ tx.setAttachmentSink((image3, ts) => this.queueAttachment(image3, ts));
2472
+ const checkCanceled = () => this.checkCanceled();
2473
+ tx.setStepGuard(checkCanceled);
2474
+ const eachSink = {
2475
+ report: (status, message, payload, id, image3) => this.addItem(status, message, payload, id, image3),
2476
+ startStep: (op, description) => tx.start(op, description),
2477
+ stepScope: (step, fn) => tx.scope(step, fn),
2478
+ declareTotal: (total) => {
2479
+ if (this.progressTotal == null) this.setProgress(this.progressCurrent, total);
2480
+ },
2481
+ advance: () => this.setProgress(this.progressCurrent + 1, this.progressTotal),
2482
+ checkCanceled,
2483
+ isAborted: () => this.abortController.signal.aborted,
2484
+ logInfo: (message) => this.log("info", message),
2485
+ logWarning: (message) => this.log("warning", message),
2486
+ logError: (message) => this.log("error", message),
2487
+ sleep: (ms) => cancelableSleep(ms, this.abortController.signal),
2488
+ doneItemIds: () => this.listDoneItemIds(),
2489
+ flush: () => this.flush()
2490
+ };
2491
+ const items = {
2492
+ add: (item) => {
2493
+ checkCanceled();
2494
+ this.addItem(item.status, item.message, item.payload, item.id, item.image);
2495
+ },
2496
+ succeeded: (payload, options) => {
2497
+ checkCanceled();
2498
+ this.addItem("succeeded", void 0, payload, options?.id, options?.image);
2499
+ },
2500
+ failed: (message, payload, options) => {
2501
+ checkCanceled();
2502
+ this.addItem("failed", message, payload, options?.id, options?.image);
2503
+ },
2504
+ occurrence: (message, payload, options) => {
2505
+ checkCanceled();
2506
+ this.addItem("occurrence", message, payload, options?.id, options?.image);
2507
+ },
2508
+ each: (list, handler, options = {}) => eachItem(list, handler, options, eachSink)
2509
+ };
2510
+ const progress = Object.defineProperties(
2511
+ {
2512
+ total: (total) => {
2513
+ checkCanceled();
2514
+ this.setProgress(this.progressCurrent, total);
2374
2515
  },
2375
- this.abortController.signal
2376
- );
2377
- await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
2378
- await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
2379
- });
2380
- return void 0;
2381
- } catch (error) {
2382
- if (this.isUnsupported(error)) {
2383
- this.attachmentsUnsupported = true;
2384
- this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
2385
- return "unsupported";
2386
- }
2387
- if (error instanceof TasksApiError && error.status === 429) {
2388
- this.log("warning", "imagem descartada: limite de anexos por execu\xE7\xE3o atingido no servidor");
2389
- return "quota";
2516
+ advance: (count = 1) => {
2517
+ checkCanceled();
2518
+ this.setProgress(this.progressCurrent + count, this.progressTotal);
2519
+ },
2520
+ set: (current, total) => {
2521
+ checkCanceled();
2522
+ this.setProgress(current, total ?? this.progressTotal);
2523
+ }
2524
+ },
2525
+ {
2526
+ current: { enumerable: true, get: () => this.progressCurrent },
2527
+ snapshot: {
2528
+ enumerable: true,
2529
+ get: () => this.progressTotal == null ? null : { current: this.progressCurrent, total: this.progressTotal }
2530
+ }
2390
2531
  }
2391
- if (this.abortController.signal.aborted) return "canceled";
2392
- this.log("warning", `falha ao enviar a imagem: ${String(error)}`);
2393
- void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
2394
- });
2395
- return "upload-failed";
2396
- } finally {
2397
- release();
2398
- }
2399
- }
2400
- /** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
2401
- isUnsupported(error) {
2402
- return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
2403
- }
2404
- /** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
2405
- acquireUploadSlot() {
2406
- const release = () => {
2407
- const next = this.uploadQueue.shift();
2408
- if (next) next();
2409
- else this.uploadSlots++;
2532
+ // defineProperties não tipa os getters adicionados — a interface é a fonte de verdade
2533
+ );
2534
+ const signal = this.abortController.signal;
2535
+ const sleep2 = (ms) => cancelableSleep(ms, signal);
2536
+ this.fileStore = new RunFileStore({ api, runId, params, signal, warn: (message) => this.log("warning", message) });
2537
+ this.context = {
2538
+ runId,
2539
+ params,
2540
+ log,
2541
+ items,
2542
+ progress,
2543
+ files: this.fileStore.files,
2544
+ step: async (name, fn, options) => {
2545
+ const root = tx.currentStep() === void 0;
2546
+ try {
2547
+ return await tx.step(name, fn, { ...options, signal, wait: sleep2, warn: (message) => this.log("warning", message) });
2548
+ } finally {
2549
+ if (options?.flush !== false && root && !signal.aborted) await this.flush();
2550
+ }
2551
+ },
2552
+ flush: (timeoutMs) => this.flush(timeoutMs),
2553
+ signal,
2554
+ throwIfCanceled: checkCanceled,
2555
+ sleep: sleep2,
2556
+ retry: (fn, options) => retryWithBackoff(fn, { ...options, signal, wait: sleep2 })
2410
2557
  };
2411
- if (this.uploadSlots > 0) {
2412
- this.uploadSlots--;
2413
- return Promise.resolve(release);
2414
- }
2415
- return new Promise((resolve2) => {
2416
- this.uploadQueue.push(() => resolve2(release));
2417
- });
2418
2558
  }
2559
+ api;
2560
+ runId;
2561
+ logger;
2562
+ tx;
2563
+ context;
2564
+ buffer = [];
2565
+ dead = false;
2566
+ chain = Promise.resolve();
2567
+ pendingUpdates = 0;
2568
+ timer;
2569
+ watchTimer;
2570
+ abortController = new AbortController();
2571
+ flushIntervalMs;
2572
+ cancelCheckIntervalMs;
2573
+ // Itens de execução vivem FORA de `this.chain` (status/liveness não pode ficar
2574
+ // atrás de milhares de itens), mas têm cadeia própria serializada — ordem de
2575
+ // envio preservada e sem lotes sobrepostos.
2576
+ itemsBuffer = [];
2577
+ itemsChain = Promise.resolve();
2578
+ itemsTimer;
2579
+ itemSeq = 0;
2580
+ /** Servidor sem o recurso (404) — para de tentar (e de avisar) nesta run. */
2581
+ itemsUnsupported = false;
2419
2582
  /**
2420
- * Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
2421
- * exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
2583
+ * Cota de itens por run do servidor (429, legado — hoje a api não tem teto de itens) —
2584
+ * degrada com um aviso só.
2422
2585
  */
2423
- async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
2424
- if (this.pendingAttachments.size === 0) return;
2425
- await Promise.race([
2426
- Promise.allSettled([...this.pendingAttachments]),
2427
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2428
- ]);
2429
- }
2430
- enqueue(updateIn) {
2431
- if (this.dead) {
2432
- this.salvageLogs(updateIn.logs);
2433
- return;
2434
- }
2435
- this.clearProgressTimer();
2436
- const progress = this.takeProgress();
2437
- const update = progress ? { ...updateIn, progress } : updateIn;
2438
- this.pendingUpdates++;
2439
- this.chain = this.chain.then(async () => {
2440
- if (this.dead) {
2441
- this.salvageLogs(update.logs);
2442
- return;
2443
- }
2444
- const payload = update.logs && update.logs.length === 0 ? { ...update, logs: void 0 } : update;
2445
- try {
2446
- await this.api.updateRun(this.runId, payload);
2447
- } catch (error) {
2448
- if (error instanceof TasksApiError && (error.status === 409 || error.status === 404)) {
2449
- this.dead = true;
2450
- this.stopCancelWatch();
2451
- this.abort();
2452
- const pending = this.buffer.splice(0, this.buffer.length);
2453
- this.salvageLogs(pending);
2454
- this.logger.warn(
2455
- `run ${this.runId} cancelada/finalizada no servidor \u2014 status suprimido` + (pending.length > 0 ? `, ${pending.length} log(s) enviados como log tardio` : "")
2456
- );
2457
- return;
2458
- }
2459
- if (update.logs && update.logs.length > 0) {
2460
- this.buffer.unshift(...update.logs);
2461
- this.scheduleFlush();
2462
- }
2463
- if (update.progress && this.progressTotal != null) this.progressDirty = true;
2464
- this.logger.error(`update da run ${this.runId} perdido:`, error);
2465
- }
2466
- }).finally(() => {
2467
- this.pendingUpdates--;
2468
- });
2469
- }
2586
+ itemsQuotaExceeded = false;
2470
2587
  /**
2471
- * Best-effort de log tardio: envia logs para uma run já terminal, fora da fila.
2472
- * O servidor anexa os logs mesmo respondendo 409; erros são engolidos — este é
2473
- * o último recurso, não um canal confiável.
2588
+ * Armazenamento do plano cheio (402 `storage_limit`): o lote é descartado e o aviso sai
2589
+ * uma vez por episódio. Os envios continuam — num agente `start()` o espaço pode ser
2590
+ * liberado (ou o plano trocado) sem reiniciar; um lote aceito rearma o aviso.
2474
2591
  */
2475
- salvageLogs(logs) {
2476
- if (!logs || logs.length === 0) return;
2477
- for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
2478
- void this.api.updateRunOnce(this.runId, { status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) }).catch(() => {
2479
- });
2480
- }
2481
- }
2482
- };
2483
-
2484
- // src/tasks/run-context-store.ts
2485
- var storage;
2486
- var initPromise;
2487
- async function ensureStorage() {
2488
- if (storage || !isNode()) return;
2489
- initPromise ??= import("async_hooks").then(({ AsyncLocalStorage: ALS }) => {
2490
- storage = new ALS();
2491
- });
2492
- await initPromise;
2493
- }
2494
- async function runWithRunContext(store, fn) {
2495
- await ensureStorage();
2496
- return storage ? storage.run(store, fn) : fn();
2497
- }
2498
- function currentRunReporter() {
2499
- return storage?.getStore()?.reporter;
2500
- }
2501
-
2502
- // src/transaction.ts
2503
- var FLUSH_TRANSACTION_OP = "hobots.run.flush";
2504
- var MAX_STEP_MESSAGE_CHARS = 2e3;
2505
- function stepMessage(text) {
2506
- return truncate(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
2507
- }
2508
- function stepId() {
2509
- return uuid4().slice(0, 16);
2510
- }
2511
- function stepLabel(op, description) {
2512
- return [op, description].filter(Boolean).join(" \u2014 ") || "(sem nome)";
2513
- }
2514
- function levelForStatus(status) {
2515
- switch (status) {
2516
- case "warning":
2517
- return "warning";
2518
- case "error":
2519
- return "error";
2520
- case "cancelled":
2521
- return "warning";
2522
- default:
2523
- return "success";
2524
- }
2525
- }
2526
- function colorForStatus(status) {
2527
- switch (status) {
2528
- case "warning":
2529
- return "yellow";
2530
- case "error":
2531
- return "red";
2532
- case "cancelled":
2533
- return "gray";
2534
- default:
2535
- return "green";
2592
+ itemsStorageFull = false;
2593
+ /**
2594
+ * Só o kill() do exit path fecha os itens — `dead` (run cancelada/finalizada no
2595
+ * servidor) NÃO fecha: o servidor aceita itens de run terminal (trabalho já feito).
2596
+ */
2597
+ itemsClosed = false;
2598
+ // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
2599
+ // status/logs e não pode ficar bloqueada esperando um binário subir.
2600
+ pendingAttachments = /* @__PURE__ */ new Set();
2601
+ attachmentCount = 0;
2602
+ uploadSlots = MAX_CONCURRENT_UPLOADS;
2603
+ uploadQueue = [];
2604
+ /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
2605
+ attachmentsUnsupported = false;
2606
+ // Progresso (ctx.progress): snapshot absoluto, enviado com throttle via piggyback
2607
+ // em `enqueue()` — nunca gera tráfego próprio enquanto logs/heartbeat já fluem.
2608
+ progressCurrent = 0;
2609
+ progressTotal = null;
2610
+ /** Snapshot ainda não entregue ao servidor. */
2611
+ progressDirty = false;
2612
+ progressTimer;
2613
+ progressWarned = false;
2614
+ /** Relógio do último eco `[hobots:progress]` no terminal (throttle do eco, não do wire). */
2615
+ progressLastPrint = 0;
2616
+ /** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
2617
+ fileStore;
2618
+ /** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
2619
+ cleanupFiles() {
2620
+ return this.fileStore.cleanup();
2536
2621
  }
2537
- }
2538
- var Step = class {
2539
- constructor(transaction, parentStepId, op, description) {
2540
- this.transaction = transaction;
2541
- this.parentStepId = parentStepId;
2542
- this.op = op;
2543
- this.description = description;
2622
+ get isDead() {
2623
+ return this.dead;
2624
+ }
2625
+ /** Lança `RunCanceled` se o signal desta run já disparou. */
2626
+ checkCanceled() {
2627
+ if (this.abortController.signal.aborted) throw runCanceledError();
2628
+ }
2629
+ /** Aborta o ctx.signal sem matar o reporter (timeout/shutdown ainda reportam o terminal). */
2630
+ abort() {
2631
+ this.stopCancelWatch();
2632
+ if (!this.abortController.signal.aborted) this.abortController.abort();
2544
2633
  }
2545
- transaction;
2546
- parentStepId;
2547
- op;
2548
- description;
2549
- stepId = stepId();
2550
- startTimestamp = nowISO();
2551
- status = "ok";
2552
- endTimestamp;
2553
- data;
2554
2634
  /**
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`.
2635
+ * Mata o reporter de vez: aborta o signal e suprime todo reporte futuro.
2636
+ * Usado no exit path — depois que o agente reportou AgentTerminated por fora
2637
+ * da fila, um handler zumbi não pode reenviar status por cima.
2558
2638
  */
2559
- setStatus(status, message, options) {
2560
- this.status = status;
2561
- if (message) this.setData("message", stepMessage(message));
2562
- if (options?.image !== void 0) this.attachImage(options.image);
2563
- return this;
2639
+ kill() {
2640
+ this.abort();
2641
+ this.dead = true;
2642
+ this.clearFlushTimer();
2643
+ this.clearProgressTimer();
2644
+ this.buffer = [];
2645
+ this.itemsClosed = true;
2646
+ this.clearItemsTimer();
2647
+ if (this.itemsBuffer.length > 0) {
2648
+ this.logger.warn(`${this.itemsBuffer.length} item(ns) de execu\xE7\xE3o descartados (processo encerrando)`);
2649
+ this.itemsBuffer = [];
2650
+ }
2651
+ const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
2652
+ for (const resume of waiting) resume();
2564
2653
  }
2565
2654
  /**
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.
2655
+ * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
2656
+ * que descarta o buffer). Capado em um chunk — o exit path não tem retry nem fila,
2657
+ * então preserva os logs mais recentes, os mais valiosos na morte do processo.
2570
2658
  */
2571
- failed(error, message, options) {
2572
- if (this.endTimestamp) return this;
2573
- this.status = "error";
2574
- 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();
2579
- return this;
2659
+ drainLogs() {
2660
+ this.clearFlushTimer();
2661
+ return this.takeLogs().slice(-MAX_LOGS_PER_PATCH);
2580
2662
  }
2581
2663
  /**
2582
- * Dispara o upload pela run (sink do RunReporter) e referencia o anexo em
2583
- * `data.attachments` (chave reservada; teto igual ao das linhas de log). Fora de uma
2584
- * run não há onde pendurar a imagem — descarta com aviso único.
2664
+ * Linha de log da run com eco no terminal. `image` (opcional) vira o anexo da linha:
2665
+ * o id entra na entrada ANTES de qualquer I/O — é isso que mantém a ordem dos logs
2666
+ * intacta e o handler livre da latência do upload. Devolve o id do anexo (quando houve
2667
+ * imagem e ela não foi descartada) — o `captureException` o leva ao evento da issue.
2585
2668
  */
2586
- attachImage(image3) {
2587
- const current = Array.isArray(this.data?.attachments) ? this.data.attachments : [];
2588
- if (current.length >= MAX_ATTACHMENTS_PER_LOG_ENTRY) return;
2589
- const id = this.transaction.attachImage(image3);
2590
- if (!id) return;
2591
- this.setData("attachments", [...current, id]);
2669
+ log(level, message, image3) {
2670
+ if (this.dead) return void 0;
2671
+ const scrubbed = scrubSecrets(safeString(message));
2672
+ const ts = nowISO();
2673
+ const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2674
+ printRunLog(level, attachmentId ? `${scrubbed} [imagem]` : scrubbed);
2675
+ this.push(level, scrubbed, true, attachmentId ? [attachmentId] : void 0, ts);
2676
+ return attachmentId;
2592
2677
  }
2593
- /** Motivo registrado via setStatus(…, message) ou failed — usado no log de FINISH. */
2594
- reason() {
2595
- const error = this.data?.error;
2596
- const text = typeof error?.message === "string" ? error.message : this.data?.message;
2597
- return typeof text === "string" && text ? text : void 0;
2678
+ /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
2679
+ push(level, message, alreadyScrubbed = false, attachments, ts = nowISO()) {
2680
+ if (this.dead) return ts;
2681
+ const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
2682
+ this.buffer.push({
2683
+ ts,
2684
+ level,
2685
+ message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
2686
+ ...attachments && attachments.length > 0 ? { attachments: attachments.slice(0, WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY) } : {}
2687
+ });
2688
+ if (this.buffer.length >= FLUSH_AT_BUFFERED) {
2689
+ this.flushLogs();
2690
+ } else {
2691
+ this.scheduleFlush();
2692
+ }
2693
+ return ts;
2598
2694
  }
2599
- setData(key, value) {
2600
- this.data = { ...this.data, [key]: value };
2601
- return this;
2695
+ scheduleFlush() {
2696
+ if (this.timer) return;
2697
+ this.timer = setTimeout(() => {
2698
+ this.timer = void 0;
2699
+ this.flushLogs();
2700
+ }, this.flushIntervalMs);
2701
+ unrefTimer(this.timer);
2602
2702
  }
2603
- /** Step aninhada (filha desta step). */
2604
- start(op, description) {
2605
- return this.transaction.createStep(this.stepId, op, description);
2703
+ clearFlushTimer() {
2704
+ if (this.timer) {
2705
+ clearTimeout(this.timer);
2706
+ this.timer = void 0;
2707
+ }
2708
+ }
2709
+ takeLogs() {
2710
+ return this.buffer.splice(0, this.buffer.length);
2711
+ }
2712
+ /** Flush incremental: envia o buffer como status 'running' (em chunks). */
2713
+ flushLogs() {
2714
+ this.clearFlushTimer();
2715
+ if (this.dead || this.buffer.length === 0) return;
2716
+ const logs = this.buffer.splice(0, this.buffer.length);
2717
+ for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
2718
+ this.enqueue({ status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) });
2719
+ }
2606
2720
  }
2607
2721
  /**
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.
2722
+ * `hobots.flush()`: o bot decide quando entregar o que já fez — etapas raiz finalizadas
2723
+ * viram transactions parciais da run, e itens, logs e progresso pendentes saem já, sem
2724
+ * esperar o throttle. Aguarda a entrega com teto. Nunca lança (pode ir num `finally`).
2610
2725
  */
2611
- succeeded() {
2612
- this.close();
2613
- return this;
2726
+ async flush(timeoutMs = ITEMS_DRAIN_MS) {
2727
+ this.tx.flush();
2728
+ this.flushItems();
2729
+ if (this.progressDirty) this.flushProgress();
2730
+ else this.flushLogs();
2731
+ await Promise.race([
2732
+ Promise.all([this.itemsChain, this.chain, this.tx.drain(timeoutMs)]).catch(() => void 0),
2733
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2734
+ ]);
2614
2735
  }
2615
- close() {
2616
- if (this.endTimestamp) return;
2617
- this.endTimestamp = nowISO();
2618
- this.transaction.emit(
2619
- "step",
2620
- "finish",
2621
- stepLabel(this.op, this.description),
2622
- this.startTimestamp,
2623
- this.endTimestamp,
2624
- this.status,
2625
- this.reason()
2626
- );
2736
+ /** Primeiro PATCH (claimed → running). Retorna false se a run já morreu (cancelada). */
2737
+ async reportRunning() {
2738
+ this.enqueue({ status: "running" });
2739
+ await this.chain;
2740
+ if (!this.dead) this.startCancelWatch();
2741
+ return !this.dead;
2627
2742
  }
2628
- toJSON() {
2629
- return {
2630
- step_id: this.stepId,
2631
- parent_step_id: this.parentStepId,
2632
- op: this.op,
2633
- description: this.description,
2634
- status: this.status,
2635
- start_timestamp: this.startTimestamp,
2636
- timestamp: this.endTimestamp ?? nowISO(),
2637
- data: this.data
2638
- };
2743
+ reportSuccess() {
2744
+ this.stopCancelWatch();
2745
+ this.clearFlushTimer();
2746
+ this.forceFinalProgress();
2747
+ this.enqueue({ status: "succeeded", logs: this.takeLogsForTerminal() });
2639
2748
  }
2640
- };
2641
- var Transaction = class {
2642
- constructor(client, name, op, runId) {
2643
- this.client = client;
2644
- this.name = name;
2645
- this.op = op;
2646
- this.runId = runId;
2647
- this.emit("transaction", "start", op ? `${name} (${op})` : name);
2749
+ reportFailure(error) {
2750
+ this.stopCancelWatch();
2751
+ this.clearFlushTimer();
2752
+ this.forceFinalProgress();
2753
+ this.enqueue({ status: "failed", error, logs: this.takeLogsForTerminal() });
2648
2754
  }
2649
- client;
2650
- name;
2651
- op;
2652
- runId;
2653
- eventId = uuid4();
2654
- stepId = stepId();
2655
- startTimestamp = nowISO();
2656
- status = "ok";
2657
- steps = [];
2658
- tags = {};
2659
- finished = false;
2660
- lifecycleSink;
2661
- attachmentSink;
2662
- warnedNoAttachmentSink = false;
2663
- // instalado pelo RunReporter: abrir uma step é um ponto de parada cooperativo (lança RunCanceled)
2664
- stepGuard;
2665
- // eventos emitidos antes do sink existir (o START da transaction sai no construtor,
2666
- // antes do RunReporter conectar) — replay no setLifecycleSink
2667
- lifecycleBacklog = [];
2668
2755
  /**
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.
2756
+ * Esvazia o buffer para o PATCH terminal respeitando o teto de 100 logs por PATCH:
2757
+ * o excedente sai antes, em PATCHes `running` na mesma cadeia (a ordem se mantém).
2673
2758
  */
2674
- emit(kind, phase, label, startTs, endTs, status, reason) {
2675
- const durationMs = startTs && endTs ? new Date(endTs).getTime() - new Date(startTs).getTime() : void 0;
2676
- const suffix = (durationMs != null ? ` (${durationMs}ms)` : "") + (reason ? `: ${reason}` : "");
2677
- if (this.client?.debug) {
2678
- const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
2679
- console.log(`${tag} ${label}${suffix}`);
2759
+ takeLogsForTerminal() {
2760
+ let logs = this.takeLogs();
2761
+ while (logs.length > MAX_LOGS_PER_PATCH) {
2762
+ this.enqueue({ status: "running", logs: logs.slice(0, MAX_LOGS_PER_PATCH) });
2763
+ logs = logs.slice(MAX_LOGS_PER_PATCH);
2680
2764
  }
2681
- const level = phase === "finish" ? levelForStatus(status ?? "ok") : "info";
2682
- const message = `[${kind}] ${phase.toUpperCase()} ${label}${suffix}`;
2683
- if (this.lifecycleSink) this.lifecycleSink(level, message);
2684
- else if (this.lifecycleBacklog.length < 100) this.lifecycleBacklog.push([level, message]);
2765
+ return logs;
2685
2766
  }
2686
- /** Conecta o espelho de lifecycle (RunReporter) e faz replay do backlog em ordem. */
2687
- setLifecycleSink(sink) {
2688
- this.lifecycleSink = sink;
2689
- const backlog = this.lifecycleBacklog;
2690
- this.lifecycleBacklog = [];
2691
- for (const [level, message] of backlog) sink(level, message);
2767
+ // ── Progresso ────────────────────────────────────────────────────────────────
2768
+ /** Valida, clampa e marca o snapshot como pendente; agenda flush só se nada mais vai sair. */
2769
+ setProgress(current, total) {
2770
+ if (this.dead) return;
2771
+ if (total != null && (!Number.isInteger(total) || total < 1)) {
2772
+ if (!this.progressWarned) {
2773
+ this.progressWarned = true;
2774
+ this.logger.warn(`ctx.progress: total inv\xE1lido (${String(total)}) \u2014 precisa ser inteiro >= 1; ignorado`);
2775
+ }
2776
+ return;
2777
+ }
2778
+ const safeCurrent = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : this.progressCurrent;
2779
+ const next = total == null ? safeCurrent : Math.min(safeCurrent, total);
2780
+ const totalChanged = total !== this.progressTotal;
2781
+ if (next === this.progressCurrent && !totalChanged) return;
2782
+ this.progressCurrent = next;
2783
+ this.progressTotal = total;
2784
+ if (total == null) return;
2785
+ const now = Date.now();
2786
+ if (totalChanged || next >= total || now - this.progressLastPrint >= PROGRESS_PRINT_THROTTLE_MS) {
2787
+ this.progressLastPrint = now;
2788
+ printRunProgress(next, total);
2789
+ }
2790
+ this.progressDirty = true;
2791
+ if (this.progressTimer) return;
2792
+ this.progressTimer = setTimeout(() => {
2793
+ this.progressTimer = void 0;
2794
+ this.flushProgress();
2795
+ }, PROGRESS_FLUSH_MS);
2796
+ unrefTimer(this.progressTimer);
2692
2797
  }
2693
- /** Conecta o pipeline de anexos da run — é o que dá imagem às steps. */
2694
- setAttachmentSink(sink) {
2695
- this.attachmentSink = sink;
2798
+ flushProgress() {
2799
+ this.clearProgressTimer();
2800
+ if (this.dead || !this.progressDirty) return;
2801
+ if (this.buffer.length > 0) this.flushLogs();
2802
+ else this.enqueue({ status: "running" });
2696
2803
  }
2697
- /** Imagem de uma step → id do anexo pela run; sem run, descarta com aviso único. */
2698
- attachImage(image3) {
2699
- if (this.attachmentSink) return this.attachmentSink(image3, nowISO());
2700
- if (!this.warnedNoAttachmentSink) {
2701
- this.warnedNoAttachmentSink = true;
2702
- console.warn("[hobots] imagem de step descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
2804
+ clearProgressTimer() {
2805
+ if (this.progressTimer) {
2806
+ clearTimeout(this.progressTimer);
2807
+ this.progressTimer = void 0;
2703
2808
  }
2704
- return void 0;
2809
+ }
2810
+ /** O PATCH terminal sempre leva o snapshot final (o app mostra "87/100" numa run que falhou). */
2811
+ forceFinalProgress() {
2812
+ this.clearProgressTimer();
2813
+ this.progressDirty = this.progressTotal != null;
2814
+ }
2815
+ /** Consome o snapshot pendente para pegar carona no próximo PATCH. */
2816
+ takeProgress() {
2817
+ if (!this.progressDirty || this.progressTotal == null) return void 0;
2818
+ this.progressDirty = false;
2819
+ return { current: this.progressCurrent, total: this.progressTotal };
2705
2820
  }
2706
2821
  /**
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.
2822
+ * Heartbeat de cancelamento: enquanto o handler roda, um PATCH 'running' vazio
2823
+ * periódico faz o servidor responder 409 se a run foi cancelada no app — o que
2824
+ * mata o reporter e dispara ctx.signal, mesmo em handlers que nunca logam.
2709
2825
  */
2710
- setStepGuard(guard) {
2711
- this.stepGuard = guard;
2826
+ startCancelWatch() {
2827
+ if (this.watchTimer) return;
2828
+ this.watchTimer = setInterval(() => {
2829
+ if (this.dead || this.pendingUpdates > 0 || this.buffer.length > 0) return;
2830
+ this.enqueue({ status: "running" });
2831
+ }, this.cancelCheckIntervalMs);
2832
+ unrefTimer(this.watchTimer);
2712
2833
  }
2713
- createStep(parentStepId, op, description) {
2714
- this.stepGuard?.();
2715
- const step = new Step(this, parentStepId, op, description);
2716
- this.steps.push(step);
2717
- this.emit("step", "start", stepLabel(op, description));
2718
- return step;
2834
+ stopCancelWatch() {
2835
+ if (this.watchTimer) {
2836
+ clearInterval(this.watchTimer);
2837
+ this.watchTimer = void 0;
2838
+ }
2719
2839
  }
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);
2840
+ /** Aguarda a fila de PATCHes esvaziar (chame depois do terminal). */
2841
+ finalize() {
2842
+ return this.chain;
2723
2843
  }
2844
+ // ── Itens de execução ────────────────────────────────────────────────────────
2724
2845
  /**
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.
2846
+ * Bufferiza um item (síncrono — a chamada nunca espera rede). Diferente do PATCH
2847
+ * de status, o servidor aceita itens mesmo com a run cancelada (trabalho já feito),
2848
+ * então um reporter morto não descarta: só um kill() de exit path descarta.
2727
2849
  */
2728
- succeeded(name) {
2729
- return this.findOpenStep(name)?.succeeded();
2850
+ addItem(status, message, payload, id, image3) {
2851
+ if (this.itemsClosed || this.itemsUnsupported) return;
2852
+ let scrubbedPayload;
2853
+ if (payload) {
2854
+ scrubbedPayload = scrubPayloadValue(payload, 0);
2855
+ let serializedLength;
2856
+ try {
2857
+ serializedLength = JSON.stringify(scrubbedPayload).length;
2858
+ } catch {
2859
+ }
2860
+ if (serializedLength === void 0 || serializedLength > MAX_ITEM_PAYLOAD_BYTES) {
2861
+ this.logger.warn(
2862
+ `payload do item descartado (${serializedLength === void 0 ? "n\xE3o serializ\xE1vel" : `${serializedLength} bytes > ${MAX_ITEM_PAYLOAD_BYTES}`}) \u2014 o item foi mantido`
2863
+ );
2864
+ scrubbedPayload = void 0;
2865
+ }
2866
+ }
2867
+ const ts = nowISO();
2868
+ const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2869
+ this.itemsBuffer.push({
2870
+ id: id ? truncate(String(id), 255) : uuid4(),
2871
+ seq: this.itemSeq++,
2872
+ status,
2873
+ ...message ? { message: truncate(scrubSecrets(safeString(message)), MAX_ITEM_MESSAGE_CHARS) } : {},
2874
+ ...scrubbedPayload ? { payload: scrubbedPayload } : {},
2875
+ ts,
2876
+ ...attachmentId ? { attachments: [attachmentId] } : {}
2877
+ });
2878
+ if (this.itemsBuffer.length >= ITEMS_FLUSH_AT_BUFFERED) {
2879
+ this.flushItems();
2880
+ } else {
2881
+ this.scheduleItemsFlush();
2882
+ }
2730
2883
  }
2731
2884
  /**
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.
2885
+ * Ids já concluídos (`succeeded`/`occurrence`) neste trabalho — a run e suas reexecuções.
2886
+ * Pagina até o fim. undefined quando a consulta falha (servidor antigo, rede): o `each`
2887
+ * avisa e processa tudo, nunca trava a run por isso.
2735
2888
  */
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;
2889
+ async listDoneItemIds() {
2890
+ const ids = /* @__PURE__ */ new Set();
2891
+ let cursor = null;
2892
+ try {
2893
+ do {
2894
+ const page = await this.api.listDoneItems(this.runId, {
2895
+ limit: MAX_ITEMS_DONE_PER_PAGE,
2896
+ ...cursor ? { cursor } : {}
2897
+ });
2898
+ for (const id of page.ids) ids.add(id);
2899
+ cursor = page.next;
2900
+ } while (cursor);
2901
+ return ids;
2902
+ } catch (error) {
2903
+ this.log("warning", `n\xE3o foi poss\xEDvel consultar os itens j\xE1 conclu\xEDdos deste trabalho \u2014 todos ser\xE3o processados (${thrownMessage(error)})`);
2904
+ return void 0;
2743
2905
  }
2744
- return void 0;
2745
2906
  }
2746
- setTag(key, value) {
2747
- this.tags[key] = value;
2748
- return this;
2907
+ scheduleItemsFlush() {
2908
+ if (this.itemsTimer) return;
2909
+ this.itemsTimer = setTimeout(() => {
2910
+ this.itemsTimer = void 0;
2911
+ this.flushItems();
2912
+ }, this.flushIntervalMs);
2913
+ unrefTimer(this.itemsTimer);
2749
2914
  }
2750
- setStatus(status) {
2751
- this.status = status;
2752
- return this;
2915
+ clearItemsTimer() {
2916
+ if (this.itemsTimer) {
2917
+ clearTimeout(this.itemsTimer);
2918
+ this.itemsTimer = void 0;
2919
+ }
2753
2920
  }
2754
- setName(name) {
2755
- this.name = name;
2756
- return this;
2921
+ /** Envia o buffer de itens em lotes serializados na cadeia própria. */
2922
+ flushItems() {
2923
+ this.clearItemsTimer();
2924
+ if (this.itemsBuffer.length === 0 || this.itemsUnsupported) return;
2925
+ const items = this.itemsBuffer.splice(0, this.itemsBuffer.length);
2926
+ for (let i = 0; i < items.length; i += MAX_ITEMS_PER_REQUEST) {
2927
+ const batch = items.slice(i, i + MAX_ITEMS_PER_REQUEST);
2928
+ this.itemsChain = this.itemsChain.then(() => this.sendItems(batch));
2929
+ }
2930
+ }
2931
+ async sendItems(batch) {
2932
+ if (this.itemsUnsupported) return;
2933
+ try {
2934
+ await this.api.createItems(this.runId, { items: batch });
2935
+ this.itemsStorageFull = false;
2936
+ } catch (error) {
2937
+ if (error instanceof TasksApiError && error.status === 404) {
2938
+ this.itemsUnsupported = true;
2939
+ this.logger.warn("itens de execu\xE7\xE3o n\xE3o s\xE3o suportados por este servidor Hobots \u2014 descartados");
2940
+ return;
2941
+ }
2942
+ if (error instanceof TasksApiError && error.status === 429) {
2943
+ if (!this.itemsQuotaExceeded) {
2944
+ this.itemsQuotaExceeded = true;
2945
+ this.log("warning", "itens descartados: limite de itens por execu\xE7\xE3o atingido no servidor");
2946
+ }
2947
+ return;
2948
+ }
2949
+ if (error instanceof TasksApiError && error.status === 402) {
2950
+ if (!this.itemsStorageFull) {
2951
+ this.itemsStorageFull = true;
2952
+ this.log("warning", "itens descartados: armazenamento do plano cheio \u2014 libere espa\xE7o ou fa\xE7a upgrade em Plano e cobran\xE7a");
2953
+ }
2954
+ return;
2955
+ }
2956
+ if (error instanceof TasksApiError) {
2957
+ this.logger.error(`lote de ${batch.length} item(ns) rejeitado pelo servidor:`, error);
2958
+ this.log("warning", `${batch.length} item(ns) descartado(s): envio rejeitado pelo servidor (HTTP ${error.status})`);
2959
+ return;
2960
+ }
2961
+ this.itemsBuffer.unshift(...batch);
2962
+ this.scheduleItemsFlush();
2963
+ this.logger.error(`lote de ${batch.length} item(ns) n\xE3o enviado (devolvido ao buffer):`, error);
2964
+ }
2757
2965
  }
2758
2966
  /**
2759
- * Envia já as steps raiz finalizadas (e todas as descendentes, fechando as que
2760
- * 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
2762
- * ficam para o próximo flush ou para o `finish()`. É assim que um agente sempre
2763
- * ligado (`start()`), cuja run não termina, entrega as etapas enquanto processa.
2764
- * Devolve quantas steps foram enviadas.
2967
+ * Dreno final de itens antes do PATCH terminal (com teto): garante que os
2968
+ * totalizadores estejam completos quando a run vira succeeded/failed. Como o
2969
+ * servidor aceita itens tardios, perder a corrida aqui não perde dados.
2765
2970
  */
2766
- flush() {
2767
- if (this.finished) return 0;
2768
- const ready = /* @__PURE__ */ new Set();
2769
- for (const step of this.steps) {
2770
- if (step.parentStepId === this.stepId && step.endTimestamp) ready.add(step.stepId);
2971
+ async finalizeItems(timeoutMs = ITEMS_DRAIN_MS) {
2972
+ this.flushItems();
2973
+ await Promise.race([
2974
+ this.itemsChain,
2975
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2976
+ ]);
2977
+ if (this.itemsBuffer.length > 0 && !this.itemsUnsupported && !this.itemsQuotaExceeded) {
2978
+ this.flushItems();
2979
+ await Promise.race([
2980
+ this.itemsChain,
2981
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2982
+ ]);
2771
2983
  }
2772
- if (ready.size === 0) return 0;
2773
- const batch = [];
2774
- const remaining = [];
2775
- for (const step of this.steps) {
2776
- if (ready.has(step.stepId) || ready.has(step.parentStepId)) {
2777
- ready.add(step.stepId);
2778
- batch.push(step);
2779
- } else {
2780
- remaining.push(step);
2984
+ }
2985
+ // ── Anexos ───────────────────────────────────────────────────────────────────
2986
+ /**
2987
+ * Pendura uma imagem num evento da run (log, item ou etapa): faz as checagens que não
2988
+ * dependem de I/O (reporter morto, instalação sem anexos, cota), reserva o id e dispara
2989
+ * o upload em segundo plano. Devolve o id — já referenciável pelo evento — ou undefined
2990
+ * quando a imagem foi descartada (sempre com aviso no log da run). Nunca lança: todo
2991
+ * caminho de erro do upload vira aviso. O hint `attachment` de `hobots.captureException`
2992
+ * também cai aqui via `log()`.
2993
+ */
2994
+ queueAttachment(imageIn, ts) {
2995
+ if (this.dead) return void 0;
2996
+ const image3 = toImageAttachment(imageIn);
2997
+ if (this.attachmentsUnsupported) return void 0;
2998
+ if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
2999
+ if (this.attachmentCount === MAX_ATTACHMENTS_PER_RUN) {
3000
+ this.attachmentCount++;
3001
+ this.log("warning", `imagem descartada: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
2781
3002
  }
3003
+ return void 0;
2782
3004
  }
2783
- for (const step of batch) step.succeeded();
2784
- 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));
3005
+ this.attachmentCount++;
3006
+ const id = uuid4();
3007
+ const rawCaption = image3.caption !== void 0 ? safeString(image3.caption) : void 0;
3008
+ const caption = rawCaption ? truncate(scrubSecrets(rawCaption), MAX_CAPTION_CHARS) : void 0;
3009
+ const work = this.uploadAttachment(id, image3.input, { caption, logTs: ts, contentType: image3.contentType }).then(() => void 0).catch(() => void 0);
3010
+ this.pendingAttachments.add(work);
3011
+ void work.finally(() => this.pendingAttachments.delete(work));
3012
+ return id;
3013
+ }
3014
+ /** Retorna o motivo da falha, ou undefined em sucesso. */
3015
+ async uploadAttachment(id, image3, meta) {
3016
+ let bytes;
3017
+ try {
3018
+ bytes = await toBytes(image3);
3019
+ } catch (error) {
3020
+ this.log("warning", `imagem n\xE3o p\xF4de ser lida: ${String(error)}`);
3021
+ return "read-failed";
3022
+ }
3023
+ const contentType = sniffContentType(bytes) ?? meta.contentType;
3024
+ if (!contentType) {
3025
+ this.log("warning", "imagem descartada: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
3026
+ return "unsupported-format";
3027
+ }
3028
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
3029
+ this.log(
3030
+ "warning",
3031
+ `imagem descartada: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
3032
+ );
3033
+ return "too-large";
3034
+ }
3035
+ const dimensions = readPngDimensions(bytes);
3036
+ const release = await this.acquireUploadSlot();
3037
+ try {
3038
+ if (this.abortController.signal.aborted || this.dead) return "canceled";
3039
+ const created = await this.api.createAttachment(
3040
+ this.runId,
3041
+ {
3042
+ id,
3043
+ content_type: contentType,
3044
+ size_bytes: bytes.byteLength,
3045
+ log_ts: meta.logTs,
3046
+ ...meta.caption ? { caption: meta.caption } : {},
3047
+ ...dimensions ?? {}
3048
+ },
3049
+ this.abortController.signal
3050
+ );
3051
+ await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
3052
+ await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
3053
+ });
3054
+ return void 0;
3055
+ } catch (error) {
3056
+ if (this.isUnsupported(error)) {
3057
+ this.attachmentsUnsupported = true;
3058
+ this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
3059
+ return "unsupported";
3060
+ }
3061
+ if (error instanceof TasksApiError && error.status === 429) {
3062
+ this.log("warning", "imagem descartada: limite de anexos por execu\xE7\xE3o atingido no servidor");
3063
+ return "quota";
2791
3064
  }
3065
+ if (this.abortController.signal.aborted) return "canceled";
3066
+ this.log("warning", `falha ao enviar a imagem: ${String(error)}`);
3067
+ void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
3068
+ });
3069
+ return "upload-failed";
3070
+ } finally {
3071
+ release();
3072
+ }
3073
+ }
3074
+ /** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
3075
+ isUnsupported(error) {
3076
+ return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
3077
+ }
3078
+ /** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
3079
+ acquireUploadSlot() {
3080
+ const release = () => {
3081
+ const next = this.uploadQueue.shift();
3082
+ if (next) next();
3083
+ else this.uploadSlots++;
3084
+ };
3085
+ if (this.uploadSlots > 0) {
3086
+ this.uploadSlots--;
3087
+ return Promise.resolve(release);
2792
3088
  }
2793
- return batch.length;
3089
+ return new Promise((resolve2) => {
3090
+ this.uploadQueue.push(() => resolve2(release));
3091
+ });
2794
3092
  }
2795
- /** Entrega já (com teto) o que está na fila do transport — as transactions parciais inclusive. */
2796
- async drain(timeoutMs) {
2797
- await this.client?.flush(timeoutMs);
3093
+ /**
3094
+ * Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
3095
+ * exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
3096
+ */
3097
+ async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
3098
+ if (this.pendingAttachments.size === 0) return;
3099
+ await Promise.race([
3100
+ Promise.allSettled([...this.pendingAttachments]),
3101
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
3102
+ ]);
2798
3103
  }
2799
- payload(eventId, op, status, startTimestamp, timestamp, steps) {
2800
- return {
2801
- event_id: eventId,
2802
- name: this.name,
2803
- op,
2804
- status,
2805
- start_timestamp: startTimestamp,
2806
- timestamp,
2807
- steps: steps.map((step) => step.toJSON()),
2808
- tags: Object.keys(this.tags).length > 0 ? { ...this.tags } : void 0,
2809
- run_id: this.runId
2810
- };
3104
+ enqueue(updateIn) {
3105
+ if (this.dead) {
3106
+ this.salvageLogs(updateIn.logs);
3107
+ return;
3108
+ }
3109
+ this.clearProgressTimer();
3110
+ const progress = this.takeProgress();
3111
+ const update = progress ? { ...updateIn, progress } : updateIn;
3112
+ this.pendingUpdates++;
3113
+ this.chain = this.chain.then(async () => {
3114
+ if (this.dead) {
3115
+ this.salvageLogs(update.logs);
3116
+ return;
3117
+ }
3118
+ const payload = update.logs && update.logs.length === 0 ? { ...update, logs: void 0 } : update;
3119
+ try {
3120
+ await this.api.updateRun(this.runId, payload);
3121
+ } catch (error) {
3122
+ if (error instanceof TasksApiError && (error.status === 409 || error.status === 404)) {
3123
+ this.dead = true;
3124
+ this.stopCancelWatch();
3125
+ this.abort();
3126
+ const pending = this.buffer.splice(0, this.buffer.length);
3127
+ this.salvageLogs(pending);
3128
+ this.logger.warn(
3129
+ `run ${this.runId} cancelada/finalizada no servidor \u2014 status suprimido` + (pending.length > 0 ? `, ${pending.length} log(s) enviados como log tardio` : "")
3130
+ );
3131
+ return;
3132
+ }
3133
+ if (update.logs && update.logs.length > 0) {
3134
+ this.buffer.unshift(...update.logs);
3135
+ this.scheduleFlush();
3136
+ }
3137
+ if (update.progress && this.progressTotal != null) this.progressDirty = true;
3138
+ this.logger.error(`update da run ${this.runId} perdido:`, error);
3139
+ }
3140
+ }).finally(() => {
3141
+ this.pendingUpdates--;
3142
+ });
2811
3143
  }
2812
- finish() {
2813
- if (this.finished) return;
2814
- this.finished = true;
2815
- for (const step of this.steps) step.succeeded();
2816
- const endTimestamp = nowISO();
2817
- this.emit(
2818
- "transaction",
2819
- "finish",
2820
- this.op ? `${this.name} (${this.op})` : this.name,
2821
- this.startTimestamp,
2822
- endTimestamp,
2823
- this.status
2824
- );
2825
- if (!this.client) return;
2826
- const steps = this.steps;
2827
- let first = 0;
2828
- while (steps.length - first > MAX_STEPS) {
2829
- const chunk = steps.slice(first, first + MAX_STEPS);
2830
- const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2831
- this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, chunk[0].startTimestamp, endTimestamp, chunk));
2832
- first += MAX_STEPS;
3144
+ /**
3145
+ * Best-effort de log tardio: envia logs para uma run já terminal, fora da fila.
3146
+ * O servidor anexa os logs mesmo respondendo 409; erros são engolidos — este é
3147
+ * o último recurso, não um canal confiável.
3148
+ */
3149
+ salvageLogs(logs) {
3150
+ if (!logs || logs.length === 0) return;
3151
+ for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
3152
+ void this.api.updateRunOnce(this.runId, { status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) }).catch(() => {
3153
+ });
2833
3154
  }
2834
- this.client.sendTransaction(this.payload(this.eventId, this.op, this.status, this.startTimestamp, endTimestamp, steps.slice(first)));
2835
3155
  }
2836
3156
  };
2837
3157
 
@@ -2840,7 +3160,7 @@ var DEFAULT_RETRY_DELAY_MS = 5e3;
2840
3160
  async function executeRun(input) {
2841
3161
  const { run, handler, api, client, logger, tracker } = input;
2842
3162
  const options = handler.options;
2843
- const tx = new Transaction(client, "run", "hobots.run", run.id);
3163
+ const tx = new Transaction(client, "run", "hobots.run", run.id, { echo: !client });
2844
3164
  const reporter = new RunReporter(
2845
3165
  api,
2846
3166
  run.id,
@@ -3080,42 +3400,26 @@ var TaskAgent = class {
3080
3400
  }
3081
3401
  };
3082
3402
 
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;
3403
+ // src/tasks/single-run.ts
3404
+ var SingleRunRunner = class {
3405
+ constructor(handler, deps) {
3101
3406
  this.handler = handler;
3102
3407
  this.deps = deps;
3103
3408
  this.done = new Promise((resolve2) => {
3104
3409
  this.resolveDone = resolve2;
3105
3410
  });
3106
3411
  }
3107
- runId;
3108
3412
  handler;
3109
3413
  deps;
3110
3414
  abort = new AbortController();
3111
- /** Resolve quando a run terminou (ou não pôde ser iniciada, ou o runner foi parado antes). */
3415
+ /** Resolve quando a run terminou (ou não pôde começar, ou o runner foi parado antes). */
3112
3416
  done;
3113
3417
  resolveDone;
3114
3418
  async run() {
3115
3419
  try {
3116
- const run = await this.startRun();
3420
+ const run = await this.openRun();
3117
3421
  if (!run) return;
3118
- this.deps.logger.debug(`run ${run.id} iniciada (HOBOTS_RUN_ID)`);
3422
+ this.deps.logger.debug(this.opened(run));
3119
3423
  await executeRun({
3120
3424
  run,
3121
3425
  handler: this.handler,
@@ -3130,11 +3434,37 @@ var AttachedRunner = class {
3130
3434
  this.resolveDone();
3131
3435
  }
3132
3436
  }
3133
- /** Desiste de iniciar a run (close() antes da plataforma responder). Uma run já iniciada segue pelo tracker. */
3437
+ /** Desiste de obter a run (close() antes dela existir). Uma run já em execução segue pelo tracker. */
3134
3438
  stop() {
3135
3439
  this.abort.abort();
3136
3440
  }
3137
- async startRun() {
3441
+ };
3442
+
3443
+ // src/tasks/attached.ts
3444
+ var START_RETRY_DELAYS_MS = [1e3, 4e3, 16e3];
3445
+ function isTransient(error) {
3446
+ if (!(error instanceof TasksApiError)) return true;
3447
+ return error.status >= 500 || error.status === 429;
3448
+ }
3449
+ function describeRejection(runId, error) {
3450
+ if (error instanceof TasksApiError && error.status === 404) {
3451
+ return `run ${runId} n\xE3o existe na plataforma (404) \u2014 nada a executar`;
3452
+ }
3453
+ if (error instanceof TasksApiError && error.status === 409) {
3454
+ return `run ${runId} n\xE3o pode ser iniciada (409: cancelada, j\xE1 iniciada ou de outra inst\xE2ncia) \u2014 nada a executar`;
3455
+ }
3456
+ return `n\xE3o foi poss\xEDvel iniciar a run ${runId} (${String(error)}) \u2014 nada a executar`;
3457
+ }
3458
+ var AttachedRunner = class extends SingleRunRunner {
3459
+ constructor(runId, handler, deps) {
3460
+ super(handler, deps);
3461
+ this.runId = runId;
3462
+ }
3463
+ runId;
3464
+ opened(run) {
3465
+ return `run ${run.id} iniciada (HOBOTS_RUN_ID)`;
3466
+ }
3467
+ async openRun() {
3138
3468
  for (let attempt = 0; ; attempt++) {
3139
3469
  if (this.abort.signal.aborted) return void 0;
3140
3470
  try {
@@ -3197,46 +3527,16 @@ function normalizeItemColumns(input, logger) {
3197
3527
  }
3198
3528
 
3199
3529
  // src/tasks/local.ts
3200
- var LocalRunner = class {
3530
+ var LocalRunner = class extends SingleRunRunner {
3531
+ backoff;
3201
3532
  constructor(handler, deps) {
3202
- this.handler = handler;
3203
- this.deps = deps;
3533
+ super(handler, deps);
3204
3534
  this.backoff = new Backoff(deps.logger, "cria\xE7\xE3o da run");
3205
- this.done = new Promise((resolve2) => {
3206
- this.resolveDone = resolve2;
3207
- });
3208
- }
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
- }
3234
3535
  }
3235
- /** Desiste de abrir a run (close() antes da plataforma responder). Uma run já aberta segue pelo tracker. */
3236
- stop() {
3237
- this.abort.abort();
3536
+ opened(run) {
3537
+ return `run ${run.id} aberta (modo start)`;
3238
3538
  }
3239
- async createRun() {
3539
+ async openRun() {
3240
3540
  for (; ; ) {
3241
3541
  if (this.abort.signal.aborted) return void 0;
3242
3542
  try {
@@ -3249,6 +3549,133 @@ var LocalRunner = class {
3249
3549
  }
3250
3550
  };
3251
3551
 
3552
+ // src/tasks/local-transport.ts
3553
+ var LOCAL_RUN_ID = "local";
3554
+ var ITEM_LINE_PREFIX = "[hobots:item]";
3555
+ var SUMMARY_LINE_PREFIX = "[hobots:summary]";
3556
+ var ATTACHMENTS_DISCARDED_WARNING = "imagens e anexos n\xE3o s\xE3o enviados no modo local (descartados)";
3557
+ function unavailable(name) {
3558
+ return new Error(`arquivo "${name}" enviado pela Central n\xE3o est\xE1 dispon\xEDvel no modo local`);
3559
+ }
3560
+ function cell(value) {
3561
+ return (value ?? "").replace(/\r\n|[\t\r\n]/g, " ");
3562
+ }
3563
+ var LocalRunTransport = class {
3564
+ constructor(params = {}, out = (line) => void process.stdout.write(`${line}
3565
+ `)) {
3566
+ this.params = params;
3567
+ this.out = out;
3568
+ }
3569
+ params;
3570
+ out;
3571
+ counts = { succeeded: 0, occurrence: 0, failed: 0 };
3572
+ warnedAttachments = false;
3573
+ /** Status terminal que o pipeline reportou — `undefined` enquanto a run corre (ou se ela nem reportou). */
3574
+ terminalStatus;
3575
+ async updateRun(_runId, update) {
3576
+ if (update.status === "running") return;
3577
+ this.terminalStatus = update.status;
3578
+ if (update.status === "failed" && update.error && update.error.type !== "RunCanceled") {
3579
+ printRunLog("error", `execu\xE7\xE3o falhou \u2014 ${update.error.type ?? "Error"}: ${update.error.message}`);
3580
+ if (update.error.stack) console.error(update.error.stack);
3581
+ }
3582
+ }
3583
+ async updateRunOnce() {
3584
+ }
3585
+ async createItems(_runId, request) {
3586
+ for (const item of request.items) {
3587
+ this.counts[item.status]++;
3588
+ this.out(`${ITEM_LINE_PREFIX} ${item.status} ${cell(item.id)} ${cell(item.message)}`);
3589
+ }
3590
+ return { accepted: request.items.length, duplicates: 0 };
3591
+ }
3592
+ // modo local não tem trabalho anterior: nada a pular
3593
+ async listDoneItems() {
3594
+ return { ids: [], next: null };
3595
+ }
3596
+ /** Totais por status — chame uma vez, no fim da run, antes da linha de conclusão. */
3597
+ printSummary() {
3598
+ const { succeeded, occurrence, failed } = this.counts;
3599
+ this.out(
3600
+ `${SUMMARY_LINE_PREFIX} total=${succeeded + occurrence + failed} ok=${succeeded} avisos=${occurrence} falhas=${failed} pulados=0`
3601
+ );
3602
+ }
3603
+ async createAttachment(_runId, request) {
3604
+ if (!this.warnedAttachments) {
3605
+ this.warnedAttachments = true;
3606
+ printRunLog("warning", ATTACHMENTS_DISCARDED_WARNING);
3607
+ }
3608
+ return { id: request.id, upload_url: "", headers: {}, expires_in: 0 };
3609
+ }
3610
+ async uploadAttachment() {
3611
+ }
3612
+ async confirmAttachment() {
3613
+ }
3614
+ /** Nome do arquivo pelo id (nos params); sem ele, o próprio id. */
3615
+ fileName(fileId) {
3616
+ return collectFileRefs(this.params).find((ref) => ref.id === fileId)?.name ?? fileId;
3617
+ }
3618
+ async requestFileDownload(_runId, fileId) {
3619
+ throw unavailable(this.fileName(fileId));
3620
+ }
3621
+ async fetchFile(url) {
3622
+ throw unavailable(url);
3623
+ }
3624
+ async confirmFileDownload(_runId, fileId) {
3625
+ throw unavailable(this.fileName(fileId));
3626
+ }
3627
+ };
3628
+
3629
+ // src/tasks/local-run.ts
3630
+ var LOCAL_RUN_EXIT_CODES = { succeeded: 0, failed: 1, canceled: 130 };
3631
+ var LOCAL_RUN_CANCEL_GRACE_MS = 4e3;
3632
+ var LOCAL_RUN_BANNER = "modo local \u2014 execu\xE7\xE3o sem a Central; params de HOBOTS_RUN_PARAMS";
3633
+ var LocalRunRunner = class extends SingleRunRunner {
3634
+ constructor(params, handler, deps) {
3635
+ super(handler, deps);
3636
+ this.params = params;
3637
+ }
3638
+ params;
3639
+ canceled = false;
3640
+ startedAt = Date.now();
3641
+ finished = false;
3642
+ run() {
3643
+ this.startedAt = Date.now();
3644
+ return super.run();
3645
+ }
3646
+ opened() {
3647
+ return "run local iniciada (HOBOTS_LOCAL_RUN)";
3648
+ }
3649
+ async openRun() {
3650
+ if (this.abort.signal.aborted) return void 0;
3651
+ return { id: LOCAL_RUN_ID, params: this.params, form: null, source: "local", created_at: nowISO() };
3652
+ }
3653
+ /** SIGINT/SIGTERM: dispara o `ctx.signal` da run em voo — o handler para no próximo ponto de parada. */
3654
+ cancel() {
3655
+ this.canceled = true;
3656
+ this.deps.tracker.singleActive()?.abort();
3657
+ }
3658
+ /** Desfecho da run: cancelada pelo sinal, senão o status terminal reportado (sem reporte = falha). */
3659
+ get outcome() {
3660
+ if (this.canceled) return "canceled";
3661
+ return this.deps.api.terminalStatus ?? "failed";
3662
+ }
3663
+ /**
3664
+ * Fecha o relato no terminal — `[hobots:summary]` e a linha de conclusão — e devolve o
3665
+ * desfecho. Idempotente: o runtime chama ao fim da run ou quando a graça do cancelamento
3666
+ * esgota com o handler ainda rodando.
3667
+ */
3668
+ finish() {
3669
+ const outcome = this.outcome;
3670
+ if (this.finished) return outcome;
3671
+ this.finished = true;
3672
+ this.deps.api.printSummary();
3673
+ const seconds = Math.round((Date.now() - this.startedAt) / 1e3);
3674
+ printRunLog("info", `execu\xE7\xE3o local conclu\xEDda (${outcome}) em ${seconds}s`);
3675
+ return outcome;
3676
+ }
3677
+ };
3678
+
3252
3679
  // src/tasks/registry.ts
3253
3680
  var RunTracker = class {
3254
3681
  constructor(api) {
@@ -3364,26 +3791,10 @@ function hostname() {
3364
3791
  if (!isNode()) return void 0;
3365
3792
  return process.env.HOSTNAME || process.env.COMPUTERNAME || void 0;
3366
3793
  }
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 = {
3794
+ function baseRuntime(options, handler, logger, api) {
3795
+ return {
3384
3796
  options: { ...options, clientSecret: void 0 },
3385
- config,
3386
- client,
3797
+ client: void 0,
3387
3798
  logger,
3388
3799
  api,
3389
3800
  handler,
@@ -3393,19 +3804,54 @@ function createRuntime(options, handler) {
3393
3804
  agent: void 0,
3394
3805
  local: void 0,
3395
3806
  attached: void 0,
3807
+ localRun: void 0,
3396
3808
  runId: void 0,
3397
3809
  instanceId: resolveInstanceId(options.instanceId),
3398
- environment,
3810
+ environment: void 0,
3399
3811
  pollIntervalMs: void 0,
3400
3812
  booted: false,
3401
3813
  bootScheduled: false,
3402
3814
  bootPromise: void 0,
3403
- uninstallGlobalHandlers: options.captureUnhandled !== false ? installGlobalHandlers(client) : void 0,
3815
+ uninstallGlobalHandlers: void 0,
3404
3816
  uninstallShutdown: void 0
3405
3817
  };
3818
+ }
3819
+ function createRuntime(options, handler, logger) {
3820
+ const config = resolveConfig({ clientSecret: options.clientSecret });
3821
+ const environment = resolveEnvironmentOption(options.environment);
3822
+ const client = new Client(
3823
+ {
3824
+ environment,
3825
+ release: options.release,
3826
+ tags: options.tags,
3827
+ debug: options.debug,
3828
+ captureRunErrors: options.captureUnhandled !== false
3829
+ },
3830
+ config
3831
+ );
3832
+ const api = new TasksApi({ baseUrl: config.runsBaseUrl, key: config.key, logger });
3833
+ const rt = baseRuntime(options, handler, logger, api);
3834
+ rt.client = client;
3835
+ rt.environment = environment;
3836
+ rt.uninstallGlobalHandlers = options.captureUnhandled !== false ? installGlobalHandlers(client) : void 0;
3406
3837
  logger.debug("inicializado", { endpoint: config.ingestEndpoint });
3407
3838
  return rt;
3408
3839
  }
3840
+ function createLocalRunRuntime(options, handler, logger) {
3841
+ let params;
3842
+ try {
3843
+ params = resolveLocalRunParams();
3844
+ } catch (error) {
3845
+ printRunLog("error", error instanceof Error ? error.message : String(error));
3846
+ process.exit(2);
3847
+ throw error;
3848
+ }
3849
+ const transport = new LocalRunTransport(params);
3850
+ const rt = baseRuntime(options, handler, logger, transport);
3851
+ rt.localRun = new LocalRunRunner(params, handler, { api: transport, client: void 0, logger, tracker: rt.tracker });
3852
+ printRunLog("info", LOCAL_RUN_BANNER);
3853
+ return rt;
3854
+ }
3409
3855
  function validateHandler(handler, fn) {
3410
3856
  if (typeof handler !== "function") {
3411
3857
  throw new Error(`[hobots] ${fn}(): o handler precisa ser uma fun\xE7\xE3o \u2014 ${fn}(async (params) => { ... })`);
@@ -3448,9 +3894,12 @@ function instanceInfo(rt) {
3448
3894
  machine: rt.sampler?.sample()
3449
3895
  };
3450
3896
  }
3897
+ function platformApi(rt) {
3898
+ return rt.api;
3899
+ }
3451
3900
  function ensureAgent(rt) {
3452
3901
  rt.agent ??= new TaskAgent({
3453
- api: rt.api,
3902
+ api: platformApi(rt),
3454
3903
  client: rt.client,
3455
3904
  logger: rt.logger,
3456
3905
  handler: rt.handler,
@@ -3462,7 +3911,7 @@ function ensureAgent(rt) {
3462
3911
  }
3463
3912
  function launchLocal(rt) {
3464
3913
  const runner = new LocalRunner(rt.handler, {
3465
- api: rt.api,
3914
+ api: platformApi(rt),
3466
3915
  client: rt.client,
3467
3916
  logger: rt.logger,
3468
3917
  tracker: rt.tracker,
@@ -3473,7 +3922,7 @@ function launchLocal(rt) {
3473
3922
  }
3474
3923
  function launchAttached(rt, runId) {
3475
3924
  const runner = new AttachedRunner(runId, rt.handler, {
3476
- api: rt.api,
3925
+ api: platformApi(rt),
3477
3926
  client: rt.client,
3478
3927
  logger: rt.logger,
3479
3928
  tracker: rt.tracker,
@@ -3485,7 +3934,43 @@ function launchAttached(rt, runId) {
3485
3934
  if (runtime === rt) void close();
3486
3935
  });
3487
3936
  }
3937
+ function launchLocalRun(rt, runner) {
3938
+ let finished = false;
3939
+ const finish = () => {
3940
+ if (finished) return;
3941
+ finished = true;
3942
+ rt.uninstallShutdown?.();
3943
+ rt.uninstallShutdown = void 0;
3944
+ const code = LOCAL_RUN_EXIT_CODES[runner.finish()];
3945
+ if (runtime === rt) void close(0);
3946
+ process.stdout.write("", () => process.exit(code));
3947
+ };
3948
+ if (rt.options.installExitHandlers !== false) {
3949
+ let signaled = false;
3950
+ const onSignal = (signal) => {
3951
+ if (externalListenerCount(signal) > 0 || signaled) return;
3952
+ signaled = true;
3953
+ runner.cancel();
3954
+ void Promise.race([runner.done, sleep(LOCAL_RUN_CANCEL_GRACE_MS)]).then(finish);
3955
+ };
3956
+ const onInt = markInternal(() => onSignal("SIGINT"));
3957
+ const onTerm = markInternal(() => onSignal("SIGTERM"));
3958
+ process.on("SIGINT", onInt);
3959
+ process.on("SIGTERM", onTerm);
3960
+ rt.uninstallShutdown = () => {
3961
+ process.off("SIGINT", onInt);
3962
+ process.off("SIGTERM", onTerm);
3963
+ };
3964
+ }
3965
+ void runner.run();
3966
+ void runner.done.then(finish);
3967
+ }
3488
3968
  async function boot(rt) {
3969
+ if (rt.localRun) {
3970
+ rt.booted = true;
3971
+ launchLocalRun(rt, rt.localRun);
3972
+ return;
3973
+ }
3489
3974
  if (!rt.instanceId) {
3490
3975
  const id = await fallbackInstanceId();
3491
3976
  if (runtime !== rt) return;
@@ -3522,9 +4007,12 @@ function startAgent(fn, mode, handler, options) {
3522
4007
  }
3523
4008
  const logger = makeLogger(options.debug ?? false);
3524
4009
  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);
4010
+ loadDotenv();
4011
+ const rt = isLocalRun() ? createLocalRunRuntime(options, agentHandler, logger) : createRuntime(options, agentHandler, logger);
4012
+ if (!rt.localRun && mode === "remote") {
4013
+ if (options.pollIntervalMs !== void 0) rt.pollIntervalMs = options.pollIntervalMs;
4014
+ rt.runId = resolveRunId(options.runId);
4015
+ }
3528
4016
  runtime = rt;
3529
4017
  scheduleBoot(rt);
3530
4018
  }
@@ -3547,10 +4035,10 @@ function forwardAttachment(rt, attachment, message, type) {
3547
4035
  const reporter = currentReporter();
3548
4036
  if (!reporter) {
3549
4037
  rt.logger.warn("attachment descartado \u2014 s\xF3 \xE9 suportado dentro de uma execu\xE7\xE3o");
3550
- return;
4038
+ return void 0;
3551
4039
  }
3552
4040
  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);
4041
+ return reporter.log(toRunLogLevel(type), message, withMeta);
3554
4042
  }
3555
4043
  function captureException(error, hint) {
3556
4044
  const rt = runtime;
@@ -3561,13 +4049,19 @@ function captureException(error, hint) {
3561
4049
  }
3562
4050
  return void 0;
3563
4051
  }
4052
+ let attachmentId;
3564
4053
  if (hint?.attachment) {
3565
4054
  const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
3566
- forwardAttachment(rt, hint.attachment, message, hint.type ?? "error");
4055
+ attachmentId = forwardAttachment(rt, hint.attachment, message, hint.type ?? "error");
4056
+ }
4057
+ if (!rt.client) {
4058
+ rt.logger.debug("captureException ignorado no modo local (sem Central)");
4059
+ return void 0;
3567
4060
  }
3568
4061
  const runId = currentReporter()?.runId;
3569
4062
  const withRun = runId ? { ...hint, tags: { run_id: runId, ...hint?.tags } } : hint;
3570
- return rt.client.captureException(error, withRun);
4063
+ const contexts = runId && attachmentId ? { run: { id: runId, attachments: [attachmentId] } } : void 0;
4064
+ return rt.client.captureException(error, withRun, contexts);
3571
4065
  }
3572
4066
  async function close(timeoutMs = 3e4) {
3573
4067
  const rt = runtime;
@@ -3577,6 +4071,7 @@ async function close(timeoutMs = 3e4) {
3577
4071
  if (rt.agent) await rt.agent.stop(0);
3578
4072
  rt.local?.stop();
3579
4073
  rt.attached?.stop();
4074
+ rt.localRun?.stop();
3580
4075
  const idle = await rt.tracker.waitIdle(timeoutMs);
3581
4076
  if (!idle) {
3582
4077
  rt.logger.warn(`${rt.tracker.activeCount} run(s) ainda em execu\xE7\xE3o ap\xF3s ${timeoutMs}ms \u2014 abandonando a espera`);
@@ -3585,7 +4080,7 @@ async function close(timeoutMs = 3e4) {
3585
4080
  rt.heartbeat?.stop();
3586
4081
  rt.uninstallShutdown?.();
3587
4082
  rt.uninstallGlobalHandlers?.();
3588
- const flushed = await rt.client.close(timeoutMs);
4083
+ const flushed = rt.client ? await rt.client.close(timeoutMs) : true;
3589
4084
  return idle && flushed;
3590
4085
  }
3591
4086
 
@@ -3614,11 +4109,19 @@ var noop = () => void 0;
3614
4109
  var fallbackEachSink = {
3615
4110
  report: noop,
3616
4111
  startStep: () => void 0,
4112
+ stepScope: (_step, fn) => fn(),
3617
4113
  declareTotal: noop,
3618
4114
  advance: noop,
3619
4115
  checkCanceled: noop,
3620
4116
  isAborted: () => false,
3621
- logError: (message) => printRunLog("error", message)
4117
+ /* v8 ignore start -- fora de uma run não há consulta de concluídos, logo nunca há item pulado para anunciar */
4118
+ logInfo: (message) => printRunLog("info", message),
4119
+ /* v8 ignore stop */
4120
+ logWarning: (message) => printRunLog("warning", message),
4121
+ logError: (message) => printRunLog("error", message),
4122
+ sleep,
4123
+ doneItemIds: () => Promise.resolve(void 0),
4124
+ flush: () => Promise.resolve()
3622
4125
  };
3623
4126
  var fallbackItems = {
3624
4127
  add: noop,
@@ -3637,7 +4140,6 @@ var fallbackFiles = {
3637
4140
  read: outsideRunFiles
3638
4141
  };
3639
4142
  var neverAborts = new AbortController().signal;
3640
- var detachedTransaction;
3641
4143
  function resolve(member) {
3642
4144
  const reporter = currentReporter();
3643
4145
  if (reporter) return reporter.context;
@@ -3658,8 +4160,12 @@ var hobots = Object.defineProperties({}, {
3658
4160
  items: getter("items", () => fallbackItems),
3659
4161
  progress: getter("progress", () => fallbackProgress),
3660
4162
  files: getter("files", () => fallbackFiles),
3661
- transaction: getter("transaction", () => detachedTransaction ??= new Transaction(void 0, "hobots.detached")),
3662
4163
  signal: getter("signal", () => neverAborts),
4164
+ step: method((name, fn, options) => {
4165
+ const ctx = resolve("step");
4166
+ if (ctx) return ctx.step(name, fn, options);
4167
+ return new Transaction(void 0, "hobots.detached").step(name, fn, options);
4168
+ }),
3663
4169
  throwIfCanceled: method(() => resolve("throwIfCanceled")?.throwIfCanceled()),
3664
4170
  sleep: method((ms) => resolve("sleep")?.sleep(ms) ?? sleep(ms)),
3665
4171
  flush: method((timeoutMs) => resolve("flush")?.flush(timeoutMs) ?? Promise.resolve()),