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.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  WIRE_LIMITS
3
- } from "./chunk-HKCDMUA3.js";
3
+ } from "./chunk-6YTOYIZM.js";
4
4
 
5
5
  // src/utils.ts
6
6
  var SDK_NAME = "hobots";
7
- var SDK_VERSION = true ? "4.5.0" : "0.0.0-dev";
7
+ var SDK_VERSION = true ? "5.1.0" : "0.0.0-dev";
8
8
  function isNode() {
9
9
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
10
10
  }
@@ -57,8 +57,13 @@ function abortableSleep(ms, signal) {
57
57
  function jitter(baseMs) {
58
58
  return Math.floor(baseMs / 2 + Math.random() * (baseMs / 2));
59
59
  }
60
+ var TRUNCATION_SUFFIX = "\u2026 [truncado]";
60
61
  function truncate(value, max) {
61
- return value.length > max ? `${value.slice(0, max)}\u2026 [truncado]` : value;
62
+ return value.length > max ? `${value.slice(0, max)}${TRUNCATION_SUFFIX}` : value;
63
+ }
64
+ function clip(value, max) {
65
+ if (value.length <= max) return value;
66
+ return `${value.slice(0, Math.max(0, max - TRUNCATION_SUFFIX.length))}${TRUNCATION_SUFFIX}`;
62
67
  }
63
68
  function safeString(value) {
64
69
  if (typeof value === "string") return value;
@@ -156,6 +161,8 @@ function colorize(text, color) {
156
161
  var INSTANCE_ID_ENV = "HOBOTS_INSTANCE_ID";
157
162
  var ENVIRONMENT_ENV = "HOBOTS_ENVIRONMENT";
158
163
  var RUN_ID_ENV = "HOBOTS_RUN_ID";
164
+ var LOCAL_RUN_ENV = "HOBOTS_LOCAL_RUN";
165
+ var RUN_PARAMS_ENV = "HOBOTS_RUN_PARAMS";
159
166
  var dotenvLoaded = false;
160
167
  function loadDotenv() {
161
168
  if (dotenvLoaded) return;
@@ -181,6 +188,24 @@ function resolveRunId(option) {
181
188
  if (typeof process === "undefined" || !process.env) return void 0;
182
189
  return process.env[RUN_ID_ENV]?.trim() || void 0;
183
190
  }
191
+ function isLocalRun() {
192
+ if (typeof process === "undefined" || !process.env) return false;
193
+ return process.env[LOCAL_RUN_ENV]?.trim() === "1";
194
+ }
195
+ function resolveLocalRunParams() {
196
+ const raw = process.env[RUN_PARAMS_ENV]?.trim();
197
+ if (!raw) return {};
198
+ let parsed;
199
+ try {
200
+ parsed = JSON.parse(raw);
201
+ } catch {
202
+ throw new Error(`${RUN_PARAMS_ENV} inv\xE1lido: n\xE3o \xE9 JSON v\xE1lido`);
203
+ }
204
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
205
+ throw new Error(`${RUN_PARAMS_ENV} inv\xE1lido: esperado um objeto JSON`);
206
+ }
207
+ return parsed;
208
+ }
184
209
  function resolveEnvironmentOption(option) {
185
210
  const fromOption = option?.trim();
186
211
  if (fromOption) return fromOption;
@@ -394,9 +419,18 @@ var MAX_FINGERPRINT_ITEMS = 10;
394
419
  var MAX_FINGERPRINT_ITEM_CHARS = 255;
395
420
  var MAX_TRANSACTION_NAME_CHARS = 500;
396
421
  var MAX_STEPS = 200;
422
+ var MAX_STEP_DESCRIPTION_CHARS = 2048;
423
+ var MAX_STEP_OP_CHARS = 255;
397
424
  function clipTags(tags) {
398
425
  return Object.fromEntries(Object.entries(tags).map(([key, value]) => [key, truncate(String(value), MAX_TAG_VALUE_CHARS)]));
399
426
  }
427
+ function clipStep(step) {
428
+ return {
429
+ ...step,
430
+ ...step.op ? { op: clip(step.op, MAX_STEP_OP_CHARS) } : {},
431
+ ...step.description ? { description: clip(step.description, MAX_STEP_DESCRIPTION_CHARS) } : {}
432
+ };
433
+ }
400
434
  function clipFingerprint(fingerprint) {
401
435
  if (fingerprint === void 0) return void 0;
402
436
  return fingerprint.slice(0, MAX_FINGERPRINT_ITEMS).map((item) => truncate(String(item), MAX_FINGERPRINT_ITEM_CHARS));
@@ -455,12 +489,13 @@ var Client = class {
455
489
  [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
456
490
  return `Client { baseUrl: '${this.#config.baseUrl}' }`;
457
491
  }
458
- captureException(error, hint) {
492
+ /** `contexts` é interno ao SDK (ex.: `contexts.run` com o print do captureException) — o evento ainda pode sobrescrever. */
493
+ captureException(error, hint, contexts) {
459
494
  if (typeof error === "object" && error !== null) captured.add(error);
460
- return this.captureEvent(eventFromException(error, hint?.type ?? "error"), hint);
495
+ return this.captureEvent(eventFromException(error, hint?.type ?? "error"), hint, contexts);
461
496
  }
462
- captureEvent(event, hint) {
463
- const final = this.prepareEvent(event, hint);
497
+ captureEvent(event, hint, contexts) {
498
+ const final = this.prepareEvent(contexts ? { ...event, contexts: { ...contexts, ...event.contexts } } : event, hint);
464
499
  this.transport.enqueue({ type: "event", payload: final });
465
500
  return final.event_id;
466
501
  }
@@ -483,14 +518,15 @@ var Client = class {
483
518
  sendTransaction(payload) {
484
519
  let steps = payload.steps;
485
520
  if (steps.length > MAX_STEPS) {
486
- this.logger.warn(`transaction com ${steps.length} steps \u2014 enviando as primeiras ${MAX_STEPS}`);
521
+ this.logger.warn(`transaction com ${steps.length} etapas \u2014 enviando as primeiras ${MAX_STEPS}`);
487
522
  steps = steps.slice(0, MAX_STEPS);
488
523
  }
524
+ steps = steps.map(clipStep);
489
525
  this.transport.enqueue({
490
526
  type: "transaction",
491
527
  payload: {
492
528
  ...payload,
493
- name: truncate(payload.name || "(sem nome)", MAX_TRANSACTION_NAME_CHARS),
529
+ name: clip(payload.name || "(sem nome)", MAX_TRANSACTION_NAME_CHARS),
494
530
  steps,
495
531
  release: payload.release ?? this.options.release,
496
532
  environment: this.resolveEnvironment(payload.environment),
@@ -1063,6 +1099,36 @@ var TasksApi = class {
1063
1099
  duplicates: typeof body?.duplicates === "number" ? body.duplicates : 0
1064
1100
  };
1065
1101
  }
1102
+ /**
1103
+ * Uma página dos ids já concluídos neste trabalho (`items.each` pula). Retry em falha
1104
+ * de rede/5xx; 4xx é definitivo (404 = servidor sem o recurso).
1105
+ */
1106
+ async listDoneItems(runId, request) {
1107
+ let response;
1108
+ try {
1109
+ response = await fetchWithRetry(
1110
+ () => fetch(`${this.baseUrl}/${encodeURIComponent(runId)}/items/done`, {
1111
+ redirect: "error",
1112
+ method: "POST",
1113
+ headers: this.headers(),
1114
+ body: JSON.stringify(request),
1115
+ signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
1116
+ }),
1117
+ { logger: this.logger, label: `itens conclu\xEDdos da run ${runId}` }
1118
+ );
1119
+ } catch (error) {
1120
+ throw new Error(`consulta de itens conclu\xEDdos da run ${runId} descartada ap\xF3s retries: ${String(error)}`);
1121
+ }
1122
+ if (!response.ok) {
1123
+ throw new TasksApiError(`consulta de itens conclu\xEDdos rejeitada (HTTP ${response.status})`, response.status);
1124
+ }
1125
+ const body = await response.json().catch(() => null);
1126
+ if (!Array.isArray(body?.ids)) throw new TasksApiError("consulta de itens conclu\xEDdos: resposta malformada", 0);
1127
+ return {
1128
+ ids: body.ids.map((id) => String(id)),
1129
+ next: typeof body.next === "string" && body.next.length > 0 ? body.next : null
1130
+ };
1131
+ }
1066
1132
  /**
1067
1133
  * Registra o anexo e obtém a URL presignada de upload. 404/501/503 significam
1068
1134
  * "esta instalação não tem anexos" — o RunReporter degrada em silêncio a partir daí.
@@ -1249,6 +1315,20 @@ var Backoff = class {
1249
1315
  };
1250
1316
 
1251
1317
  // src/tasks/errors.ts
1318
+ var Occurrence = class extends Error {
1319
+ payload;
1320
+ /** Print do item (bytes/caminho ou `hobots.image(...)`) — o `each` pendura no item. */
1321
+ image;
1322
+ constructor(message, payload, options) {
1323
+ super(message);
1324
+ this.name = "Occurrence";
1325
+ this.payload = payload;
1326
+ this.image = options?.image;
1327
+ }
1328
+ };
1329
+ function isOccurrence(error) {
1330
+ return error instanceof Error && error.name === "Occurrence";
1331
+ }
1252
1332
  function runCanceledError() {
1253
1333
  return Object.assign(new Error("execu\xE7\xE3o cancelada"), { name: "RunCanceled" });
1254
1334
  }
@@ -1265,1507 +1345,1745 @@ function isCancellation(error) {
1265
1345
  return error instanceof Error && (error.name === "RunCanceled" || error.name === "AbortError");
1266
1346
  }
1267
1347
 
1268
- // src/tasks/each.ts
1269
- function thrownMessage(error) {
1270
- if (error instanceof Error) return error.message || error.name;
1271
- return safeString(error);
1272
- }
1273
- var ItemHandle = class {
1274
- constructor(index, id, step, basePayload, sink) {
1275
- this.index = index;
1276
- this.id = id;
1277
- this.step = step;
1278
- this.basePayload = basePayload;
1279
- this.sink = sink;
1280
- }
1281
- index;
1282
- id;
1283
- step;
1284
- basePayload;
1285
- sink;
1286
- reported = false;
1287
- status;
1288
- succeeded(payload, options) {
1289
- this.record("succeeded", void 0, payload, options?.image);
1290
- }
1291
- failed(message, payload, options) {
1292
- this.record("failed", message, payload, options?.image);
1293
- if (this.step && this.step.status !== "error") this.step.setStatus("error", message);
1294
- }
1295
- occurrence(message, payload, options) {
1296
- this.record("occurrence", message, payload, options?.image);
1297
- if (this.step && this.step.status === "ok") this.step.setStatus("warning", message);
1298
- }
1299
- record(status, message, payload, image3) {
1300
- this.reported = true;
1301
- this.status = status;
1302
- const merged = { ...this.basePayload ?? {}, ...payload ?? {} };
1303
- this.sink.report(status, message, Object.keys(merged).length > 0 ? merged : void 0, this.id, image3);
1348
+ // src/tasks/attachments.ts
1349
+ var MAX_ATTACHMENT_BYTES = WIRE_LIMITS.MAX_ATTACHMENT_BYTES;
1350
+ var MAX_ATTACHMENTS_PER_RUN = WIRE_LIMITS.MAX_ATTACHMENTS_PER_RUN;
1351
+ var MAX_ATTACHMENTS_PER_LOG_ENTRY = WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY;
1352
+ var MAX_CONCURRENT_UPLOADS = 3;
1353
+ var MAX_CAPTION_CHARS = WIRE_LIMITS.MAX_CAPTION_CHARS;
1354
+ var ATTACHMENT_DRAIN_MS = 15e3;
1355
+ var IMAGE_BRAND = /* @__PURE__ */ Symbol.for("hobots.image");
1356
+ var ImageAttachment = class {
1357
+ constructor(input, caption, contentType) {
1358
+ this.input = input;
1359
+ this.caption = caption;
1360
+ this.contentType = contentType;
1304
1361
  }
1362
+ input;
1363
+ caption;
1364
+ contentType;
1365
+ [IMAGE_BRAND] = true;
1305
1366
  };
1306
- function resolveId(options, element, index) {
1307
- const raw = options.id?.(element, index);
1308
- if (raw === null || raw === void 0) return void 0;
1309
- const text = safeString(raw);
1310
- return text.length > 0 ? text : void 0;
1367
+ function image(input, options = {}) {
1368
+ return new ImageAttachment(input, options.caption, options.contentType);
1311
1369
  }
1312
- function knownLength(list) {
1313
- const length = list?.length;
1314
- return typeof length === "number" && Number.isInteger(length) && length >= 1 ? length : void 0;
1370
+ function isImageAttachment(value) {
1371
+ return typeof value === "object" && value !== null && value[IMAGE_BRAND] === true;
1315
1372
  }
1316
- async function eachItem(list, handler, options, sink) {
1317
- const result = { total: 0, succeeded: 0, failed: 0, occurrence: 0 };
1318
- const length = options.progress === false ? void 0 : knownLength(list);
1319
- if (length !== void 0) sink.declareTotal(length);
1320
- let index = 0;
1321
- for await (const element of list) {
1322
- sink.checkCanceled();
1323
- const id = resolveId(options, element, index);
1324
- const description = options.description?.(element, index) ?? id ?? `#${index + 1}`;
1325
- const step = sink.startStep(options.op ?? "item", description);
1326
- const handle = new ItemHandle(index, id, step, options.payload?.(element, index), sink);
1327
- try {
1328
- await handler(element, handle, index);
1329
- if (!handle.reported) handle.succeeded();
1330
- } catch (error) {
1331
- if (isTerminalError(error) || sink.isAborted()) {
1332
- step?.setStatus("cancelled").succeeded();
1333
- throw error;
1334
- }
1335
- const message = thrownMessage(error);
1336
- if (!handle.reported) handle.failed(message);
1337
- step?.failed(error);
1338
- sink.logError(`item ${description} falhou: ${message}`);
1339
- }
1340
- step?.succeeded();
1341
- if (options.progress !== false) sink.advance();
1342
- result.total += 1;
1343
- result[handle.status ?? "succeeded"] += 1;
1344
- index += 1;
1373
+ function toImageAttachment(value) {
1374
+ return isImageAttachment(value) ? value : new ImageAttachment(value);
1375
+ }
1376
+ async function readImageFile(path) {
1377
+ if (!isNode()) {
1378
+ throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
1345
1379
  }
1346
- return result;
1380
+ const { readFile } = await import("fs/promises");
1381
+ return new Uint8Array(await readFile(path));
1347
1382
  }
1348
-
1349
- // src/tasks/files.ts
1350
- var LOCAL_FILES_DIR_ENV = "HOBOTS_LOCAL_FILES_DIR";
1351
- var FILE_DOWNLOAD_IDLE_TIMEOUT_MS = 6e4;
1352
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1353
- function isFileRef(value) {
1354
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1355
- const ref = value;
1356
- return typeof ref.id === "string" && typeof ref.name === "string" && typeof ref.size === "number" && typeof ref.contentType === "string" && (ref.storage === "s3" || ref.storage === "local");
1383
+ function startsWith(bytes, signature, offset = 0) {
1384
+ if (bytes.length < offset + signature.length) return false;
1385
+ return signature.every((byte, index) => bytes[offset + index] === byte);
1357
1386
  }
1358
- function fileField(params, field) {
1359
- if (!Object.prototype.hasOwnProperty.call(params, field)) return void 0;
1360
- const value = params[field];
1361
- if (isFileRef(value)) return value;
1362
- if (Array.isArray(value) && value.every(isFileRef)) return value;
1387
+ var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
1388
+ var MAX_PNG_DIMENSION = WIRE_LIMITS.MAX_IMAGE_DIMENSION_PX;
1389
+ var JPEG_MAGIC = [255, 216, 255];
1390
+ var RIFF_MAGIC = [82, 73, 70, 70];
1391
+ var WEBP_MAGIC = [87, 69, 66, 80];
1392
+ function sniffContentType(bytes) {
1393
+ if (startsWith(bytes, PNG_MAGIC)) return "image/png";
1394
+ if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
1395
+ if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
1363
1396
  return void 0;
1364
1397
  }
1365
- function collectFileRefs(params) {
1366
- const refs = [];
1367
- for (const field of Object.keys(params)) {
1368
- const value = fileField(params, field);
1369
- if (value !== void 0) refs.push(...Array.isArray(value) ? value : [value]);
1370
- }
1371
- return refs;
1398
+ function readPngDimensions(bytes) {
1399
+ if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
1400
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1401
+ const width = view.getUint32(16);
1402
+ const height = view.getUint32(20);
1403
+ if (width === 0 || height === 0) return void 0;
1404
+ if (width > MAX_PNG_DIMENSION || height > MAX_PNG_DIMENSION) return void 0;
1405
+ return { width, height };
1372
1406
  }
1373
- function safeFileName(name, fallback) {
1374
- const cleaned = name.replace(/^.*[\\/]/, "").replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
1375
- return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
1407
+ async function toBytes(input) {
1408
+ if (typeof input === "string") return readImageFile(input);
1409
+ if (input instanceof Uint8Array) return input;
1410
+ return new Uint8Array(input);
1376
1411
  }
1377
- function isInsideDir(root, candidate, sep) {
1378
- const base = root.endsWith(sep) ? root : root + sep;
1379
- return candidate.startsWith(base) && candidate.length > base.length;
1412
+
1413
+ // src/tasks/run-context-store.ts
1414
+ var storage;
1415
+ var stepStorage;
1416
+ var initPromise;
1417
+ async function ensureStorage() {
1418
+ if (storage || !isNode()) return;
1419
+ initPromise ??= import("async_hooks").then(({ AsyncLocalStorage: ALS }) => {
1420
+ storage = new ALS();
1421
+ stepStorage = new ALS();
1422
+ });
1423
+ await initPromise;
1380
1424
  }
1381
- function unavailableLocal(ref) {
1382
- return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
1425
+ async function runWithRunContext(store, fn) {
1426
+ await ensureStorage();
1427
+ return storage ? storage.run(store, fn) : fn();
1383
1428
  }
1384
- function errorCode(error) {
1385
- return error?.code;
1429
+ function currentRunReporter() {
1430
+ return storage?.getStore()?.reporter;
1386
1431
  }
1387
- var RunFileStore = class {
1388
- constructor(deps) {
1389
- this.deps = deps;
1390
- this.files = {
1391
- list: () => collectFileRefs(deps.params),
1392
- get: (field) => fileField(deps.params, field),
1393
- // async: erro de uso (campo sem arquivo, valor inválido) chega como Promise rejeitada, nunca síncrono
1394
- download: async (refOrField, options) => this.download(this.single(refOrField), options?.dest),
1395
- downloadAll: async (field, options) => this.downloadAll(field, options?.dest),
1396
- read: async (refOrField) => this.read(this.single(refOrField))
1397
- };
1398
- }
1399
- deps;
1400
- files;
1401
- tempDir;
1402
- /** Apaga a pasta temporária da execução (cópias que o bot não levou para `dest`). Nunca lança. */
1403
- async cleanup() {
1404
- const pending = this.tempDir;
1405
- this.tempDir = void 0;
1406
- if (!pending) return;
1432
+ async function runWithStepScope(scope, fn) {
1433
+ await ensureStorage();
1434
+ return stepStorage ? stepStorage.run(scope, fn) : fn();
1435
+ }
1436
+ function currentStep(transaction) {
1437
+ const scope = stepStorage?.getStore();
1438
+ return scope?.transaction === transaction ? scope.step : void 0;
1439
+ }
1440
+
1441
+ // src/retry.ts
1442
+ var DEFAULT_RETRIES = 3;
1443
+ var DEFAULT_DELAY_MS = 1e3;
1444
+ function resolveDelay(delay, attempt, fallbackMs) {
1445
+ const value = typeof delay === "function" ? delay(attempt) : delay ?? fallbackMs;
1446
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1447
+ }
1448
+ async function retryWithBackoff(fn, options = {}) {
1449
+ const requested = options.retries ?? DEFAULT_RETRIES;
1450
+ const retries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_RETRIES;
1451
+ const wait = options.wait ?? sleep;
1452
+ for (let attempt = 1; ; attempt++) {
1453
+ if (options.signal?.aborted) throw runCanceledError();
1407
1454
  try {
1408
- const { rm } = await import("fs/promises");
1409
- await rm(await pending, { recursive: true, force: true });
1410
- } catch {
1411
- }
1412
- }
1413
- field(field) {
1414
- const value = fileField(this.deps.params, field);
1415
- if (value === void 0) throw new Error(`campo "${field}" n\xE3o tem arquivo nos params`);
1416
- return value;
1417
- }
1418
- single(refOrField) {
1419
- if (typeof refOrField === "string") {
1420
- const value = this.field(refOrField);
1421
- if (Array.isArray(value)) {
1422
- throw new Error(`campo "${refOrField}" tem v\xE1rios arquivos \u2014 use downloadAll("${refOrField}") ou passe um item da lista`);
1423
- }
1424
- return value;
1455
+ return await fn(attempt);
1456
+ } catch (error) {
1457
+ const exhausted = attempt > retries;
1458
+ const retriable = !isTerminalError(error) && !options.signal?.aborted && (options.shouldRetry?.(error) ?? true);
1459
+ if (exhausted || !retriable) throw error;
1460
+ const delay = resolveDelay(options.delayMs, attempt, DEFAULT_DELAY_MS);
1461
+ options.onRetry?.(error, attempt, delay);
1462
+ await wait(delay);
1425
1463
  }
1426
- if (!isFileRef(refOrField)) throw new TypeError("esperado um FileRef ou o nome de um campo de arquivo");
1427
- return refOrField;
1428
- }
1429
- async downloadAll(field, dest) {
1430
- const value = this.field(field);
1431
- const refs = Array.isArray(value) ? value : [value];
1432
- const paths = [];
1433
- for (const ref of refs) paths.push(await this.download(ref, dest, true));
1434
- return paths;
1435
1464
  }
1436
- async read(ref) {
1437
- const { readFile, rm } = await import("fs/promises");
1438
- const { dirname } = await import("path");
1439
- const path = await this.download(ref, void 0);
1440
- try {
1441
- return await readFile(path);
1442
- } finally {
1443
- await rm(dirname(path), { recursive: true, force: true });
1444
- }
1465
+ }
1466
+
1467
+ // src/transaction.ts
1468
+ var FLUSH_TRANSACTION_OP = "hobots.run.flush";
1469
+ var DEFAULT_STEP_RETRIES = 1;
1470
+ var DEFAULT_STEP_RETRY_DELAY_MS = 5e3;
1471
+ var MAX_STEP_MESSAGE_CHARS = 2e3;
1472
+ var MAX_BACKLOG = 100;
1473
+ function stepMessage(text) {
1474
+ return clip(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
1475
+ }
1476
+ function stepId() {
1477
+ return uuid4().slice(0, 16);
1478
+ }
1479
+ function stepLabel(op, description) {
1480
+ return [op, description].filter(Boolean).join(" \u2014 ") || "(sem nome)";
1481
+ }
1482
+ function errorText(error) {
1483
+ if (error instanceof Error) return error.message || error.name;
1484
+ return safeString(error);
1485
+ }
1486
+ function errorReason(error) {
1487
+ return error instanceof Error ? `${error.name}: ${errorText(error)}` : errorText(error);
1488
+ }
1489
+ function levelForStatus(status) {
1490
+ switch (status) {
1491
+ case "warning":
1492
+ return "warning";
1493
+ case "error":
1494
+ return "error";
1495
+ case "cancelled":
1496
+ return "warning";
1497
+ default:
1498
+ return "success";
1445
1499
  }
1446
- async download(ref, dest, destIsDir = false) {
1447
- this.checkCanceled();
1448
- const target = await this.resolveTarget(ref, dest, destIsDir);
1449
- if (ref.storage === "local") await this.copyLocal(ref, target);
1450
- else await this.downloadS3(ref, target);
1451
- return target;
1500
+ }
1501
+ function colorForStatus(status) {
1502
+ switch (status) {
1503
+ case "warning":
1504
+ return "yellow";
1505
+ case "error":
1506
+ return "red";
1507
+ case "cancelled":
1508
+ return "gray";
1509
+ default:
1510
+ return "green";
1452
1511
  }
1453
- checkCanceled() {
1454
- if (this.deps.signal.aborted) throw runCanceledError();
1512
+ }
1513
+ var Step = class {
1514
+ constructor(transaction, parentStepId, op, description) {
1515
+ this.transaction = transaction;
1516
+ this.parentStepId = parentStepId;
1517
+ this.op = op;
1518
+ this.description = description;
1455
1519
  }
1456
- runTempDir() {
1457
- this.tempDir ??= (async () => {
1458
- const { mkdtemp } = await import("fs/promises");
1459
- const { tmpdir } = await import("os");
1460
- const { join } = await import("path");
1461
- return mkdtemp(join(tmpdir(), "hobots-files-"));
1462
- })();
1463
- return this.tempDir;
1520
+ transaction;
1521
+ parentStepId;
1522
+ op;
1523
+ description;
1524
+ stepId = stepId();
1525
+ startTimestamp = nowISO();
1526
+ status = "ok";
1527
+ endTimestamp;
1528
+ _data;
1529
+ // ── API do bot ────────────────────────────────────────────────────────────
1530
+ warning(message, options) {
1531
+ if (this.status !== "error") this._setStatus("warning", message);
1532
+ if (options?.image !== void 0) this.attachImage(options.image);
1533
+ return this;
1464
1534
  }
1465
- async resolveTarget(ref, dest, destIsDir) {
1466
- const { mkdir, mkdtemp, stat } = await import("fs/promises");
1467
- const { dirname, join, resolve: resolve2 } = await import("path");
1468
- const name = safeFileName(ref.name, "arquivo");
1469
- if (dest === void 0) {
1470
- return join(await mkdtemp(join(await this.runTempDir(), "f-")), name);
1471
- }
1472
- const isDir = destIsDir || dest.endsWith("/") || dest.endsWith("\\") || (await stat(dest).catch(() => void 0))?.isDirectory() === true;
1473
- if (isDir) {
1474
- await mkdir(dest, { recursive: true });
1475
- return resolve2(dest, name);
1535
+ image(image3) {
1536
+ this.attachImage(image3);
1537
+ return this;
1538
+ }
1539
+ data(key, value) {
1540
+ if (key === "attachments") return this;
1541
+ this._setData(key, value);
1542
+ return this;
1543
+ }
1544
+ // ── Internos (chamados pelo SDK) ──────────────────────────────────────────
1545
+ /** `data` da etapa como vai no wire (undefined quando vazio). */
1546
+ get payload() {
1547
+ return this._data;
1548
+ }
1549
+ _setStatus(status, message) {
1550
+ this.status = status;
1551
+ if (message) this._setData("message", stepMessage(safeString(message)));
1552
+ return this;
1553
+ }
1554
+ /**
1555
+ * Marca `error` guardando `{ type, message }` em `data.error` e fecha a etapa. Aceita qualquer
1556
+ * valor lançado; `message` sobrescreve a mensagem do erro. Numa etapa já fechada não faz nada.
1557
+ */
1558
+ _failed(error, message) {
1559
+ if (this.endTimestamp) return this;
1560
+ this.status = "error";
1561
+ const type = error instanceof Error ? error.name : typeof error === "object" && error !== null ? "Error" : void 0;
1562
+ const raw = message ?? errorText(error);
1563
+ this._setData("error", { ...type ? { type } : {}, message: stepMessage(safeString(raw)) });
1564
+ this._close();
1565
+ return this;
1566
+ }
1567
+ _setData(key, value) {
1568
+ this._data = { ...this._data, [key]: value };
1569
+ }
1570
+ _deleteData(key) {
1571
+ if (!this._data || !(key in this._data)) return;
1572
+ const rest = { ...this._data };
1573
+ delete rest[key];
1574
+ this._data = Object.keys(rest).length > 0 ? rest : void 0;
1575
+ }
1576
+ /**
1577
+ * Dispara o upload pela run (sink do RunReporter) e referencia o anexo em
1578
+ * `data.attachments` (chave reservada; teto igual ao das linhas de log). Fora de uma
1579
+ * run não há onde pendurar a imagem — descarta com aviso único.
1580
+ */
1581
+ attachImage(image3) {
1582
+ const current = Array.isArray(this._data?.attachments) ? this._data.attachments : [];
1583
+ if (current.length >= MAX_ATTACHMENTS_PER_LOG_ENTRY) return;
1584
+ const id = this.transaction.attachImage(image3);
1585
+ if (!id) return;
1586
+ this._setData("attachments", [...current, id]);
1587
+ }
1588
+ /** Motivo registrado (warning/erro) — usado no log de FINISH. */
1589
+ reason() {
1590
+ const error = this._data?.error;
1591
+ const text = typeof error?.message === "string" ? error.message : this._data?.message;
1592
+ return typeof text === "string" && text ? text : void 0;
1593
+ }
1594
+ /** Fecha a etapa mantendo o status que estiver. Idempotente. */
1595
+ _close() {
1596
+ if (this.endTimestamp) return;
1597
+ this.endTimestamp = nowISO();
1598
+ this.transaction.emit(
1599
+ "step",
1600
+ "finish",
1601
+ stepLabel(this.op, this.description),
1602
+ this.startTimestamp,
1603
+ this.endTimestamp,
1604
+ this.status,
1605
+ this.reason()
1606
+ );
1607
+ }
1608
+ toJSON() {
1609
+ return {
1610
+ step_id: this.stepId,
1611
+ ...this.parentStepId ? { parent_step_id: this.parentStepId } : {},
1612
+ ...this.op ? { op: clip(this.op, MAX_STEP_OP_CHARS) } : {},
1613
+ ...this.description ? { description: clip(this.description, MAX_STEP_DESCRIPTION_CHARS) } : {},
1614
+ status: this.status,
1615
+ start_timestamp: this.startTimestamp,
1616
+ timestamp: this.endTimestamp ?? nowISO(),
1617
+ ...this._data ? { data: this._data } : {}
1618
+ };
1619
+ }
1620
+ };
1621
+ var Transaction = class {
1622
+ constructor(client, name, op, runId, options = {}) {
1623
+ this.client = client;
1624
+ this.name = name;
1625
+ this.op = op;
1626
+ this.runId = runId;
1627
+ this.echo = options.echo ?? false;
1628
+ this.emit("transaction", "start", op ? `${name} (${op})` : name);
1629
+ }
1630
+ client;
1631
+ name;
1632
+ op;
1633
+ runId;
1634
+ eventId = uuid4();
1635
+ stepId = stepId();
1636
+ startTimestamp = nowISO();
1637
+ status = "ok";
1638
+ steps = [];
1639
+ finished = false;
1640
+ lifecycleSink;
1641
+ attachmentSink;
1642
+ warnedNoAttachmentSink = false;
1643
+ // instalado pelo RunReporter: abrir uma etapa é um ponto de parada cooperativo (lança RunCanceled)
1644
+ stepGuard;
1645
+ // modo local (sem client): o FINISH de cada etapa sai no terminal, como `[hobots:item]`
1646
+ echo;
1647
+ // eventos emitidos antes do sink existir (o START da transaction sai no construtor,
1648
+ // antes do RunReporter conectar) — replay no setLifecycleSink
1649
+ lifecycleBacklog = [];
1650
+ // ── Lifecycle / eco ───────────────────────────────────────────────────────
1651
+ /**
1652
+ * Registra o início/fim de uma etapa ou da transaction: espelha a linha no sink de
1653
+ * lifecycle, se conectado (é assim que os eventos de performance aparecem nos logs da
1654
+ * run no app), e ecoa no terminal com `debug: true` — ou, no modo local, só o FINISH das
1655
+ * etapas (`[hobots:step] FINISH …`), para o Studio e o dev verem.
1656
+ */
1657
+ emit(kind, phase, label, startTs, endTs, status, reason) {
1658
+ const durationMs = startTs && endTs ? new Date(endTs).getTime() - new Date(startTs).getTime() : void 0;
1659
+ const suffix = (durationMs != null ? ` (${durationMs}ms)` : "") + (reason ? `: ${reason}` : "");
1660
+ const localFinish = this.echo && kind === "step" && phase === "finish";
1661
+ if (this.client?.debug || localFinish) {
1662
+ const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
1663
+ console.log(`${tag} ${label}${suffix}`);
1476
1664
  }
1477
- await mkdir(dirname(resolve2(dest)), { recursive: true });
1478
- return resolve2(dest);
1665
+ const level = phase === "finish" ? levelForStatus(status ?? "ok") : "info";
1666
+ const message = `[${kind}] ${phase.toUpperCase()} ${label}${suffix}`;
1667
+ if (this.lifecycleSink) this.lifecycleSink(level, message);
1668
+ else if (this.lifecycleBacklog.length < MAX_BACKLOG) this.lifecycleBacklog.push([level, message]);
1479
1669
  }
1480
- /** storage 's3': URL pré-assinada → GET em streaming → confere o tamanho → só então DELETE. */
1481
- async downloadS3(ref, target) {
1482
- const { api, runId, signal } = this.deps;
1483
- let expected;
1484
- let url;
1485
- try {
1486
- const info = await api.requestFileDownload(runId, ref.id, signal);
1487
- expected = info.size >= 0 ? info.size : ref.size;
1488
- url = info.url;
1489
- } catch (error) {
1490
- throw this.translate(error, ref);
1670
+ /** Conecta o espelho de lifecycle (RunReporter) e faz replay do backlog em ordem. */
1671
+ setLifecycleSink(sink) {
1672
+ this.lifecycleSink = sink;
1673
+ const backlog = this.lifecycleBacklog;
1674
+ this.lifecycleBacklog = [];
1675
+ for (const [level, message] of backlog) sink(level, message);
1676
+ }
1677
+ /** Conecta o pipeline de anexos da run — é o que dá imagem às etapas. */
1678
+ setAttachmentSink(sink) {
1679
+ this.attachmentSink = sink;
1680
+ }
1681
+ /** Imagem de uma etapa → id do anexo pela run; sem run, descarta com aviso único. */
1682
+ attachImage(image3) {
1683
+ if (this.attachmentSink) return this.attachmentSink(image3, nowISO());
1684
+ if (!this.warnedNoAttachmentSink) {
1685
+ this.warnedNoAttachmentSink = true;
1686
+ console.warn("[hobots] imagem de etapa descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
1491
1687
  }
1492
- const { open, rename, rm } = await import("fs/promises");
1493
- const part = `${target}.part`;
1494
- const idleTimeoutMs = this.deps.idleTimeoutMs ?? FILE_DOWNLOAD_IDLE_TIMEOUT_MS;
1495
- const controller = new AbortController();
1496
- let timedOut = false;
1497
- let timer;
1498
- const arm = () => {
1499
- clearTimeout(timer);
1500
- timer = setTimeout(() => {
1501
- timedOut = true;
1502
- controller.abort();
1503
- }, idleTimeoutMs);
1504
- };
1505
- const onCancel = () => controller.abort();
1506
- signal.addEventListener("abort", onCancel, { once: true });
1688
+ return void 0;
1689
+ }
1690
+ // ── Etapas ────────────────────────────────────────────────────────────────
1691
+ /**
1692
+ * Conecta a checagem de cancelamento chamada antes de cada etapa nova (inclusive antes de
1693
+ * cada nova tentativa) — é assim que `hobots.step()` para uma run cancelada.
1694
+ */
1695
+ setStepGuard(guard) {
1696
+ this.stepGuard = guard;
1697
+ }
1698
+ /** A etapa aberta por `step()` (ou `items.each`) no contexto async atual, se houver. */
1699
+ currentStep() {
1700
+ return currentStep(this);
1701
+ }
1702
+ /** Faz de `step` a etapa corrente durante `fn` (filhas aninham nela). Sem etapa, só roda `fn`. */
1703
+ scope(step, fn) {
1704
+ if (!step) return fn();
1705
+ return runWithStepScope({ transaction: this, step }, fn);
1706
+ }
1707
+ /** Abre uma etapa sob a corrente (ou direta da transaction). Interno: o bot usa `step()`. */
1708
+ start(op, description) {
1709
+ this.stepGuard?.();
1710
+ const parent = this.currentStep();
1711
+ const step = new Step(this, parent ? parent.stepId : this.stepId, op, description);
1712
+ this.steps.push(step);
1713
+ this.emit("step", "start", stepLabel(op, description));
1714
+ return step;
1715
+ }
1716
+ /**
1717
+ * Registra a etapa `name` executando `fn(etapa)` até dar certo ou esgotar `retries`
1718
+ * tentativas extras (default 1), esperando `retryDelayMs` entre elas (default 5 000).
1719
+ * Resolve com o retorno de `fn`.
1720
+ *
1721
+ * Uma etapa só no waterfall, cobrindo todas as tentativas: `data.attempts` quando houve
1722
+ * mais de uma; `warning` com o motivo quando a última deu certo; `error` (e a exceção
1723
+ * sobe) quando esgotou; `cancelled` em cancelamento/timeout, que nunca repetem.
1724
+ */
1725
+ async step(name, fn, options = {}) {
1726
+ const requested = options.retries ?? DEFAULT_STEP_RETRIES;
1727
+ const totalRetries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_STEP_RETRIES;
1728
+ const step = this.start(void 0, safeString(name));
1729
+ let attempts = 0;
1730
+ let lastFailure;
1507
1731
  try {
1508
- arm();
1509
- const response = await api.fetchFile(url, controller.signal);
1510
- const handle = await open(part, "w");
1511
- let written = 0;
1512
- try {
1513
- const reader = response.body?.getReader();
1514
- const stopReading = () => void reader?.cancel().catch(() => void 0);
1515
- controller.signal.addEventListener("abort", stopReading, { once: true });
1516
- for (; ; ) {
1517
- const chunk = reader ? await reader.read() : { done: true, value: void 0 };
1518
- if (chunk.done) break;
1519
- arm();
1520
- written += chunk.value.byteLength;
1521
- if (written > expected) {
1522
- stopReading();
1523
- break;
1732
+ const result = await retryWithBackoff(
1733
+ (attempt) => {
1734
+ attempts = attempt;
1735
+ return this.scope(step, async () => fn(step));
1736
+ },
1737
+ {
1738
+ retries: totalRetries,
1739
+ delayMs: (attempt) => resolveDelay(options.retryDelayMs, attempt, DEFAULT_STEP_RETRY_DELAY_MS),
1740
+ signal: options.signal,
1741
+ wait: options.wait,
1742
+ onRetry: (error, attempt, delayMs) => {
1743
+ lastFailure = { error };
1744
+ options.warn?.(
1745
+ `etapa "${step.description}": tentativa ${attempt}/${totalRetries + 1} falhou: ${truncate(errorReason(error), 500)} \u2014 nova tentativa em ${delayMs}ms`
1746
+ );
1524
1747
  }
1525
- await handle.write(chunk.value);
1526
1748
  }
1527
- } finally {
1528
- await handle.close();
1529
- }
1530
- if (written !== expected) {
1531
- throw new Error(`tamanho divergente no download de "${ref.name}": recebidos ${written} bytes, esperados ${expected}`);
1749
+ );
1750
+ this.noteAttempts(step, attempts);
1751
+ if (lastFailure && step.status === "ok") {
1752
+ step._setStatus("warning", `tentativa ${attempts - 1}/${totalRetries + 1} falhou: ${errorReason(lastFailure.error)}`);
1532
1753
  }
1533
- await rename(part, target);
1534
- } catch (error) {
1535
- await rm(part, { force: true });
1536
- if (signal.aborted) throw runCanceledError();
1537
- if (timedOut) {
1538
- throw new Error(`download de "${ref.name}" parado h\xE1 ${idleTimeoutMs / 1e3}s \u2014 tente de novo`);
1539
- }
1540
- throw this.translate(error, ref);
1541
- } finally {
1542
- clearTimeout(timer);
1543
- signal.removeEventListener("abort", onCancel);
1544
- }
1545
- try {
1546
- await api.confirmFileDownload(runId, ref.id);
1754
+ step._close();
1755
+ return result;
1547
1756
  } catch (error) {
1548
- this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
1757
+ this.noteAttempts(step, attempts);
1758
+ if (isTerminalError(error)) step._setStatus("cancelled")._close();
1759
+ else step._failed(error);
1760
+ throw error;
1549
1761
  }
1550
1762
  }
1551
- /** storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de origem. */
1552
- async copyLocal(ref, target) {
1553
- const base = process.env[LOCAL_FILES_DIR_ENV];
1554
- if (!base) throw unavailableLocal(ref);
1555
- const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
1556
- const { join, resolve: resolve2, sep } = await import("path");
1557
- const root = resolve2(base);
1558
- const folder = resolve2(root, ref.id);
1559
- if (!isInsideDir(root, folder, sep) || !UUID_RE.test(ref.id)) {
1560
- throw new Error(`id de arquivo local inv\xE1lido: ${JSON.stringify(ref.id)}`);
1561
- }
1562
- let names;
1563
- try {
1564
- names = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
1565
- } catch (error) {
1566
- if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") throw unavailableLocal(ref);
1567
- throw error;
1763
+ noteAttempts(step, attempts) {
1764
+ if (attempts > 1) step._setData("attempts", attempts);
1765
+ }
1766
+ // ── Metadados ─────────────────────────────────────────────────────────────
1767
+ setStatus(status) {
1768
+ this.status = status;
1769
+ return this;
1770
+ }
1771
+ // ── Encerramento ──────────────────────────────────────────────────────────
1772
+ /**
1773
+ * Envia já as etapas raiz finalizadas (e todas as descendentes, fechando as que
1774
+ * ficaram abertas) como transactions parciais da mesma run (`op` hobots.run.flush,
1775
+ * `event_id` novo, até 200 etapas cada) e as tira da memória. Etapas raiz ainda abertas
1776
+ * ficam para o próximo flush ou para o `finish()`. É assim que um agente sempre
1777
+ * ligado (`start()`), cuja run não termina, entrega as etapas enquanto processa.
1778
+ * Devolve quantas etapas foram enviadas (0 sem client, sem nada terminado ou depois do `finish()`).
1779
+ */
1780
+ flush() {
1781
+ if (!this.client || this.finished) return 0;
1782
+ const ready = /* @__PURE__ */ new Set();
1783
+ for (const step of this.steps) {
1784
+ if (step.parentStepId === this.stepId && step.endTimestamp) ready.add(step.stepId);
1568
1785
  }
1569
- if (names.length === 0) throw unavailableLocal(ref);
1570
- if (names.length > 1) throw new Error(`pasta do arquivo "${ref.name}" tem ${names.length} arquivos \u2014 esperado 1`);
1571
- this.checkCanceled();
1572
- const part = `${target}.part`;
1573
- try {
1574
- await copyFile(join(folder, names[0]), part);
1575
- const { size } = await stat(part);
1576
- if (size !== ref.size) {
1577
- throw new Error(`tamanho divergente na c\xF3pia de "${ref.name}": copiados ${size} bytes, esperados ${ref.size}`);
1786
+ if (ready.size === 0) return 0;
1787
+ const batch = [];
1788
+ const remaining = [];
1789
+ for (const step of this.steps) {
1790
+ if (ready.has(step.stepId) || ready.has(step.parentStepId)) {
1791
+ ready.add(step.stepId);
1792
+ batch.push(step);
1793
+ } else {
1794
+ remaining.push(step);
1578
1795
  }
1579
- await rename(part, target);
1580
- } catch (error) {
1581
- await rm(part, { force: true });
1582
- throw error;
1583
1796
  }
1584
- await rm(folder, { recursive: true, force: true });
1585
- }
1586
- translate(error, ref) {
1587
- if (this.deps.signal.aborted) return runCanceledError();
1588
- if (error instanceof TasksApiError && error.status === 410) {
1589
- return new TasksApiError(`arquivo "${ref.name}" j\xE1 baixado ou expirado`, 410);
1797
+ for (const step of batch) step._close();
1798
+ this.steps.splice(0, this.steps.length, ...remaining);
1799
+ for (let i = 0; i < batch.length; i += MAX_STEPS) {
1800
+ const chunk = batch.slice(i, i + MAX_STEPS);
1801
+ const startTimestamp = chunk.reduce((min, step) => step.startTimestamp < min ? step.startTimestamp : min, chunk[0].startTimestamp);
1802
+ const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
1803
+ this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, startTimestamp, nowISO(), chunk));
1590
1804
  }
1591
- if (error instanceof TasksApiError && error.status === 404) {
1592
- return new TasksApiError(`arquivo "${ref.name}" n\xE3o encontrado nesta execu\xE7\xE3o`, 404);
1805
+ return batch.length;
1806
+ }
1807
+ /** Entrega já (com teto) o que está na fila do transport — as transactions parciais inclusive. */
1808
+ async drain(timeoutMs) {
1809
+ await this.client?.flush(timeoutMs);
1810
+ }
1811
+ payload(eventId, op, status, startTimestamp, timestamp, steps) {
1812
+ return {
1813
+ event_id: eventId,
1814
+ name: this.name,
1815
+ op,
1816
+ status,
1817
+ start_timestamp: startTimestamp,
1818
+ timestamp,
1819
+ steps: steps.map((step) => step.toJSON()),
1820
+ // sem tags próprias: o client mescla as do processo
1821
+ tags: void 0,
1822
+ run_id: this.runId
1823
+ };
1824
+ }
1825
+ finish() {
1826
+ if (this.finished) return;
1827
+ this.finished = true;
1828
+ for (const step of this.steps) step._close();
1829
+ const endTimestamp = nowISO();
1830
+ this.emit(
1831
+ "transaction",
1832
+ "finish",
1833
+ this.op ? `${this.name} (${this.op})` : this.name,
1834
+ this.startTimestamp,
1835
+ endTimestamp,
1836
+ this.status
1837
+ );
1838
+ if (!this.client) return;
1839
+ const steps = this.steps;
1840
+ let first = 0;
1841
+ while (steps.length - first > MAX_STEPS) {
1842
+ const chunk = steps.slice(first, first + MAX_STEPS);
1843
+ const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
1844
+ this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, chunk[0].startTimestamp, endTimestamp, chunk));
1845
+ first += MAX_STEPS;
1593
1846
  }
1594
- return error;
1847
+ this.client.sendTransaction(this.payload(this.eventId, this.op, this.status, this.startTimestamp, endTimestamp, steps.slice(first)));
1595
1848
  }
1596
1849
  };
1597
1850
 
1598
- // src/retry.ts
1599
- var DEFAULT_RETRIES = 3;
1600
- var DEFAULT_DELAY_MS = 1e3;
1601
- function resolveDelay(delay, attempt, fallbackMs) {
1602
- const value = typeof delay === "function" ? delay(attempt) : delay ?? fallbackMs;
1603
- return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1851
+ // src/tasks/each.ts
1852
+ var DEFAULT_ITEM_ID_FIELD = "id";
1853
+ var DEFAULT_EACH_RETRIES = 1;
1854
+ var ID_SEPARATOR = "|";
1855
+ function thrownMessage(error) {
1856
+ if (error instanceof Error) return error.message || error.name;
1857
+ return safeString(error);
1604
1858
  }
1605
- async function retryWithBackoff(fn, options = {}) {
1606
- const requested = options.retries ?? DEFAULT_RETRIES;
1607
- const retries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_RETRIES;
1608
- const wait = options.wait ?? sleep;
1609
- for (let attempt = 1; ; attempt++) {
1610
- if (options.signal?.aborted) throw runCanceledError();
1611
- try {
1612
- return await fn(attempt);
1613
- } catch (error) {
1614
- const exhausted = attempt > retries;
1615
- const retriable = !isTerminalError(error) && !options.signal?.aborted && (options.shouldRetry?.(error) ?? true);
1616
- if (exhausted || !retriable) throw error;
1617
- const delay = resolveDelay(options.delayMs, attempt, DEFAULT_DELAY_MS);
1618
- options.onRetry?.(error, attempt, delay);
1619
- await wait(delay);
1859
+ var ItemHandle = class {
1860
+ constructor(index, id, step, basePayload, sink) {
1861
+ this.index = index;
1862
+ this.step = step;
1863
+ this.basePayload = basePayload;
1864
+ this.sink = sink;
1865
+ this.id = id ?? uuid4();
1866
+ }
1867
+ index;
1868
+ step;
1869
+ basePayload;
1870
+ sink;
1871
+ /** A identidade resolvida por `options.id` ou, sem ela, uma gerada — a mesma em todas as tentativas. */
1872
+ id;
1873
+ reported = false;
1874
+ status;
1875
+ attempt = 1;
1876
+ succeeded(payload, options) {
1877
+ this.record("succeeded", void 0, payload, options?.image);
1878
+ }
1879
+ failed(message, payload, options) {
1880
+ this.record("failed", message, payload, options?.image);
1881
+ if (this.step && this.step.status !== "error") this.step._setStatus("error", message);
1882
+ }
1883
+ occurrence(message, payload, options) {
1884
+ this.record("occurrence", message, payload, options?.image);
1885
+ if (this.step && this.step.status === "ok") this.step._setStatus("warning", message);
1886
+ }
1887
+ /** Nova tentativa: o desfecho da anterior não vale (o id é o mesmo, o último reporte vence). */
1888
+ _nextAttempt(attempt) {
1889
+ this.attempt = attempt;
1890
+ this.reported = false;
1891
+ this.status = void 0;
1892
+ if (this.step && (this.step.status === "error" || this.step.status === "warning")) {
1893
+ this.step._setStatus("ok");
1894
+ this.step._deleteData("message");
1620
1895
  }
1621
1896
  }
1622
- }
1623
-
1624
- // src/tasks/attachments.ts
1625
- var MAX_ATTACHMENT_BYTES = WIRE_LIMITS.MAX_ATTACHMENT_BYTES;
1626
- var MAX_ATTACHMENTS_PER_RUN = WIRE_LIMITS.MAX_ATTACHMENTS_PER_RUN;
1627
- var MAX_ATTACHMENTS_PER_LOG_ENTRY = WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY;
1628
- var MAX_CONCURRENT_UPLOADS = 3;
1629
- var MAX_CAPTION_CHARS = WIRE_LIMITS.MAX_CAPTION_CHARS;
1630
- var ATTACHMENT_DRAIN_MS = 15e3;
1631
- var IMAGE_BRAND = /* @__PURE__ */ Symbol.for("hobots.image");
1632
- var ImageAttachment = class {
1633
- constructor(input, caption, contentType) {
1634
- this.input = input;
1635
- this.caption = caption;
1636
- this.contentType = contentType;
1897
+ record(status, message, payload, image3) {
1898
+ this.reported = true;
1899
+ this.status = status;
1900
+ const merged = { ...this.basePayload ?? {}, ...payload ?? {} };
1901
+ this.sink.report(status, message, Object.keys(merged).length > 0 ? merged : void 0, this.id, image3);
1637
1902
  }
1638
- input;
1639
- caption;
1640
- contentType;
1641
- [IMAGE_BRAND] = true;
1642
1903
  };
1643
- function image(input, options = {}) {
1644
- return new ImageAttachment(input, options.caption, options.contentType);
1904
+ function field(element, name) {
1905
+ if (typeof element !== "object" || element === null) return { present: false, value: void 0 };
1906
+ const record = element;
1907
+ return { present: name in record, value: record[name] };
1645
1908
  }
1646
- function isImageAttachment(value) {
1647
- return typeof value === "object" && value !== null && value[IMAGE_BRAND] === true;
1909
+ function resolveId(spec, element, index, explicit) {
1910
+ if (spec === null) return void 0;
1911
+ let raw;
1912
+ if (typeof spec === "function") {
1913
+ raw = spec(element, index);
1914
+ } else {
1915
+ const fields = spec === void 0 ? [DEFAULT_ITEM_ID_FIELD] : typeof spec === "string" ? [spec] : spec;
1916
+ const parts = [];
1917
+ for (const name of fields) {
1918
+ const { present, value } = field(element, name);
1919
+ if (!present || value === null || value === void 0) {
1920
+ if (explicit) {
1921
+ throw new Error(
1922
+ `items.each: elemento #${index + 1} n\xE3o tem o campo "${name}" informado em id \u2014 passe o nome certo, uma lista de campos ou uma fun\xE7\xE3o (elemento, \xEDndice) => id`
1923
+ );
1924
+ }
1925
+ return void 0;
1926
+ }
1927
+ parts.push(safeString(value));
1928
+ }
1929
+ raw = parts.join(ID_SEPARATOR);
1930
+ }
1931
+ if (raw === null || raw === void 0) return void 0;
1932
+ const text = safeString(raw);
1933
+ return text.length > 0 ? text : void 0;
1648
1934
  }
1649
- function toImageAttachment(value) {
1650
- return isImageAttachment(value) ? value : new ImageAttachment(value);
1935
+ function knownLength(list) {
1936
+ const length = list?.length;
1937
+ return typeof length === "number" && Number.isInteger(length) && length >= 1 ? length : void 0;
1651
1938
  }
1652
- async function readImageFile(path) {
1653
- if (!isNode()) {
1654
- throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
1939
+ function skippedMessage(count) {
1940
+ if (count === 1) return "1 item pulado: j\xE1 conclu\xEDdo neste trabalho numa execu\xE7\xE3o anterior";
1941
+ return `${count} itens pulados: j\xE1 conclu\xEDdos neste trabalho numa execu\xE7\xE3o anterior`;
1942
+ }
1943
+ async function eachItem(list, handler, options, sink) {
1944
+ const result = { total: 0, succeeded: 0, failed: 0, occurrence: 0, skipped: 0 };
1945
+ const requested = options.retries ?? DEFAULT_EACH_RETRIES;
1946
+ const totalRetries = Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : DEFAULT_EACH_RETRIES;
1947
+ const explicitId = options.id !== void 0;
1948
+ const length = options.progress === false ? void 0 : knownLength(list);
1949
+ if (length !== void 0) sink.declareTotal(length);
1950
+ const done = options.skipDone === false ? void 0 : await sink.doneItemIds();
1951
+ let index = 0;
1952
+ for await (const element of list) {
1953
+ sink.checkCanceled();
1954
+ const id = resolveId(options.id, element, index, explicitId);
1955
+ if (done?.size && id !== void 0 && done.has(id)) {
1956
+ result.total += 1;
1957
+ result.skipped += 1;
1958
+ if (options.progress !== false) sink.advance();
1959
+ index += 1;
1960
+ continue;
1961
+ }
1962
+ const description = options.description?.(element, index) ?? id ?? `#${index + 1}`;
1963
+ const step = sink.startStep(options.op ?? "item", description);
1964
+ const handle = new ItemHandle(index, id, step, options.payload?.(element, index), sink);
1965
+ const current = index;
1966
+ let attempts = 0;
1967
+ let lastFailure;
1968
+ try {
1969
+ await retryWithBackoff(
1970
+ (attempt) => {
1971
+ attempts = attempt;
1972
+ if (attempt > 1) handle._nextAttempt(attempt);
1973
+ return sink.stepScope(step, async () => handler(element, handle, current));
1974
+ },
1975
+ {
1976
+ retries: totalRetries,
1977
+ delayMs: (attempt) => resolveDelay(options.retryDelayMs, attempt, DEFAULT_STEP_RETRY_DELAY_MS),
1978
+ // sem nova tentativa: ocorrência (regra de negócio), desfecho já registrado pelo
1979
+ // handler (ele decidiu) ou run abortada (o erro é efeito do cancelamento)
1980
+ shouldRetry: (error) => !isOccurrence(error) && !handle.reported && !sink.isAborted(),
1981
+ wait: (ms) => sink.sleep(ms),
1982
+ onRetry: (error, attempt, delayMs) => {
1983
+ lastFailure = { error };
1984
+ sink.logWarning(
1985
+ `item ${description}: tentativa ${attempt}/${totalRetries + 1} falhou: ${truncate(errorReason(error), 500)} \u2014 nova tentativa em ${delayMs}ms`
1986
+ );
1987
+ }
1988
+ }
1989
+ );
1990
+ if (!handle.reported) handle.succeeded();
1991
+ if (step) {
1992
+ noteAttempts(step, attempts);
1993
+ if (lastFailure && step.status === "ok") {
1994
+ step._setStatus("warning", `tentativa ${attempts - 1}/${totalRetries + 1} falhou: ${errorReason(lastFailure.error)}`);
1995
+ }
1996
+ }
1997
+ } catch (error) {
1998
+ if (isTerminalError(error) || sink.isAborted()) {
1999
+ step?._setStatus("cancelled")._close();
2000
+ throw error;
2001
+ }
2002
+ if (step) noteAttempts(step, attempts);
2003
+ if (isOccurrence(error)) {
2004
+ const occurrence = error;
2005
+ if (!handle.reported) handle.occurrence(occurrence.message, occurrence.payload, { image: occurrence.image });
2006
+ } else {
2007
+ const message = thrownMessage(error);
2008
+ if (!handle.reported) handle.failed(message);
2009
+ step?._failed(error);
2010
+ sink.logError(`item ${description} falhou: ${message}`);
2011
+ }
2012
+ }
2013
+ step?._close();
2014
+ if (options.progress !== false) sink.advance();
2015
+ result.total += 1;
2016
+ result[handle.status ?? "succeeded"] += 1;
2017
+ index += 1;
2018
+ if (options.flush !== false) await sink.flush();
1655
2019
  }
1656
- const { readFile } = await import("fs/promises");
1657
- return new Uint8Array(await readFile(path));
2020
+ if (result.skipped > 0) sink.logInfo(skippedMessage(result.skipped));
2021
+ return result;
1658
2022
  }
1659
- function startsWith(bytes, signature, offset = 0) {
1660
- if (bytes.length < offset + signature.length) return false;
1661
- return signature.every((byte, index) => bytes[offset + index] === byte);
2023
+ function noteAttempts(step, attempts) {
2024
+ if (attempts > 1) step._setData("attempts", attempts);
1662
2025
  }
1663
- var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
1664
- var MAX_PNG_DIMENSION = WIRE_LIMITS.MAX_IMAGE_DIMENSION_PX;
1665
- var JPEG_MAGIC = [255, 216, 255];
1666
- var RIFF_MAGIC = [82, 73, 70, 70];
1667
- var WEBP_MAGIC = [87, 69, 66, 80];
1668
- function sniffContentType(bytes) {
1669
- if (startsWith(bytes, PNG_MAGIC)) return "image/png";
1670
- if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
1671
- if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
1672
- return void 0;
2026
+
2027
+ // src/tasks/files.ts
2028
+ var LOCAL_FILES_DIR_ENV = "HOBOTS_LOCAL_FILES_DIR";
2029
+ var FILE_DOWNLOAD_IDLE_TIMEOUT_MS = 6e4;
2030
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2031
+ function isFileRef(value) {
2032
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2033
+ const ref = value;
2034
+ return typeof ref.id === "string" && typeof ref.name === "string" && typeof ref.size === "number" && typeof ref.contentType === "string" && (ref.storage === "s3" || ref.storage === "local");
1673
2035
  }
1674
- function readPngDimensions(bytes) {
1675
- if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
1676
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1677
- const width = view.getUint32(16);
1678
- const height = view.getUint32(20);
1679
- if (width === 0 || height === 0) return void 0;
1680
- if (width > MAX_PNG_DIMENSION || height > MAX_PNG_DIMENSION) return void 0;
1681
- return { width, height };
2036
+ function fileField(params, field2) {
2037
+ if (!Object.prototype.hasOwnProperty.call(params, field2)) return void 0;
2038
+ const value = params[field2];
2039
+ if (isFileRef(value)) return value;
2040
+ if (Array.isArray(value) && value.every(isFileRef)) return value;
2041
+ return void 0;
1682
2042
  }
1683
- async function toBytes(input) {
1684
- if (typeof input === "string") return readImageFile(input);
1685
- if (input instanceof Uint8Array) return input;
1686
- return new Uint8Array(input);
2043
+ function collectFileRefs(params) {
2044
+ const refs = [];
2045
+ for (const field2 of Object.keys(params)) {
2046
+ const value = fileField(params, field2);
2047
+ if (value !== void 0) refs.push(...Array.isArray(value) ? value : [value]);
2048
+ }
2049
+ return refs;
1687
2050
  }
1688
-
1689
- // src/tasks/context.ts
1690
- var LOG_LEVEL_COLOR = {
1691
- debug: "gray",
1692
- info: "cyan",
1693
- warning: "yellow",
1694
- error: "red",
1695
- success: "green"
1696
- };
1697
- function joinLogArgs(message, rest) {
1698
- if (rest.length === 0) return safeString(message);
1699
- return [message, ...rest].map(safeString).join(" ");
2051
+ function safeFileName(name, fallback) {
2052
+ const cleaned = name.replace(/^.*[\\/]/, "").replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
2053
+ return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
1700
2054
  }
1701
- function splitLogArgs(message, rest) {
1702
- const args = [message, ...rest];
1703
- const last = args[args.length - 1];
1704
- if (!isImageAttachment(last)) return { text: joinLogArgs(message, rest) };
1705
- const text = args.slice(0, -1);
1706
- if (text.length === 0) return { text: last.caption !== void 0 ? safeString(last.caption) : "captura de tela", image: last };
1707
- return { text: joinLogArgs(text[0], text.slice(1)), image: last };
2055
+ function isInsideDir(root, candidate, sep) {
2056
+ const base = root.endsWith(sep) ? root : root + sep;
2057
+ return candidate.startsWith(base) && candidate.length > base.length;
1708
2058
  }
1709
- function printRunLog(level, message) {
1710
- const line = colorize(`[hobots] ${level.toUpperCase()}`, LOG_LEVEL_COLOR[level]);
1711
- const method2 = level === "warning" ? "warn" : level === "success" ? "info" : level;
1712
- console[method2](line, message);
2059
+ function unavailableLocal(ref) {
2060
+ return new Error(`arquivo "${ref.name}" enviado pelo Studio de outro computador ou j\xE1 consumido`);
1713
2061
  }
1714
- function cancelableSleep(ms, signal) {
1715
- return new Promise((resolve2, reject) => {
1716
- if (signal.aborted) {
1717
- reject(runCanceledError());
1718
- return;
1719
- }
1720
- const onAbort = () => {
1721
- clearTimeout(timer);
1722
- reject(runCanceledError());
1723
- };
1724
- const timer = setTimeout(() => {
1725
- signal.removeEventListener("abort", onAbort);
1726
- resolve2();
1727
- }, ms);
1728
- signal.addEventListener("abort", onAbort, { once: true });
1729
- });
2062
+ function localRunPath(ref) {
2063
+ if (!isLocalRun()) return void 0;
2064
+ const path = ref.path;
2065
+ return typeof path === "string" && path.trim() !== "" ? path : void 0;
1730
2066
  }
1731
- var DEFAULT_LOG_FLUSH_INTERVAL_MS = 2e3;
1732
- var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
1733
- var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
1734
- var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
1735
- var MAX_LOG_MESSAGE_CHARS = WIRE_LIMITS.MAX_LOG_MESSAGE_CHARS;
1736
- var MAX_LOGS_PER_PATCH = WIRE_LIMITS.MAX_LOGS_PER_PATCH;
1737
- var FLUSH_AT_BUFFERED = 20;
1738
- var MAX_ITEMS_PER_REQUEST = WIRE_LIMITS.MAX_ITEMS_PER_REQUEST;
1739
- var MAX_ITEM_MESSAGE_CHARS = WIRE_LIMITS.MAX_ITEM_MESSAGE_CHARS;
1740
- var MAX_ITEM_PAYLOAD_BYTES = WIRE_LIMITS.MAX_ITEM_PAYLOAD_BYTES;
1741
- var ITEMS_FLUSH_AT_BUFFERED = 100;
1742
- var ITEMS_DRAIN_MS = 15e3;
1743
- var PROGRESS_FLUSH_MS = 1e3;
1744
- function scrubPayloadValue(value, depth) {
1745
- if (typeof value === "string") return scrubSecrets(value);
1746
- if (depth >= 4 || value === null || typeof value !== "object") return value;
1747
- if (Array.isArray(value)) return value.map((entry) => scrubPayloadValue(entry, depth + 1));
1748
- const out = {};
1749
- for (const [key, entry] of Object.entries(value)) {
1750
- out[key] = scrubPayloadValue(entry, depth + 1);
1751
- }
1752
- return out;
2067
+ function errorCode(error) {
2068
+ return error?.code;
1753
2069
  }
1754
- var RunReporter = class {
1755
- constructor(api, runId, logger, tx, flushIntervalMs, cancelCheckIntervalMs, params = {}) {
1756
- this.api = api;
1757
- this.runId = runId;
1758
- this.logger = logger;
1759
- this.tx = tx;
1760
- this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
1761
- this.cancelCheckIntervalMs = Math.min(
1762
- MAX_CANCEL_CHECK_INTERVAL_MS,
1763
- Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
1764
- );
1765
- const logAt = (level) => (message, ...rest) => {
1766
- const { text, image: image3 } = splitLogArgs(message, rest);
1767
- this.log(level, text, image3);
1768
- };
1769
- const log = {
1770
- debug: logAt("debug"),
1771
- info: logAt("info"),
1772
- warn: logAt("warning"),
1773
- error: logAt("error"),
1774
- success: logAt("success")
1775
- };
1776
- tx.setLifecycleSink((level, message) => this.push(level, message));
1777
- tx.setAttachmentSink((image3, ts) => this.queueAttachment(image3, ts));
1778
- const checkCanceled = () => this.checkCanceled();
1779
- tx.setStepGuard(checkCanceled);
1780
- const eachSink = {
1781
- report: (status, message, payload, id, image3) => this.addItem(status, message, payload, id, image3),
1782
- startStep: (op, description) => tx.start(op, description),
1783
- declareTotal: (total) => {
1784
- if (this.progressTotal == null) this.setProgress(this.progressCurrent, total);
1785
- },
1786
- advance: () => this.setProgress(this.progressCurrent + 1, this.progressTotal),
1787
- checkCanceled,
1788
- isAborted: () => this.abortController.signal.aborted,
1789
- logError: (message) => this.log("error", message)
1790
- };
1791
- const items = {
1792
- add: (item) => {
1793
- checkCanceled();
1794
- this.addItem(item.status, item.message, item.payload, item.id, item.image);
1795
- },
1796
- succeeded: (payload, options) => {
1797
- checkCanceled();
1798
- this.addItem("succeeded", void 0, payload, options?.id, options?.image);
1799
- },
1800
- failed: (message, payload, options) => {
1801
- checkCanceled();
1802
- this.addItem("failed", message, payload, options?.id, options?.image);
1803
- },
1804
- occurrence: (message, payload, options) => {
1805
- checkCanceled();
1806
- this.addItem("occurrence", message, payload, options?.id, options?.image);
1807
- },
1808
- each: (list, handler, options = {}) => eachItem(list, handler, options, eachSink)
2070
+ var RunFileStore = class {
2071
+ constructor(deps) {
2072
+ this.deps = deps;
2073
+ this.files = {
2074
+ list: () => collectFileRefs(deps.params),
2075
+ get: (field2) => fileField(deps.params, field2),
2076
+ // async: erro de uso (campo sem arquivo, valor inválido) chega como Promise rejeitada, nunca síncrono
2077
+ download: async (refOrField, options) => this.download(this.single(refOrField), options?.dest),
2078
+ downloadAll: async (field2, options) => this.downloadAll(field2, options?.dest),
2079
+ read: async (refOrField) => this.read(this.single(refOrField))
1809
2080
  };
1810
- const progress = Object.defineProperties(
1811
- {
1812
- total: (total) => {
1813
- checkCanceled();
1814
- this.setProgress(this.progressCurrent, total);
1815
- },
1816
- advance: (count = 1) => {
1817
- checkCanceled();
1818
- this.setProgress(this.progressCurrent + count, this.progressTotal);
1819
- },
1820
- set: (current, total) => {
1821
- checkCanceled();
1822
- this.setProgress(current, total ?? this.progressTotal);
1823
- }
1824
- },
1825
- {
1826
- current: { enumerable: true, get: () => this.progressCurrent },
1827
- snapshot: {
1828
- enumerable: true,
1829
- get: () => this.progressTotal == null ? null : { current: this.progressCurrent, total: this.progressTotal }
1830
- }
2081
+ }
2082
+ deps;
2083
+ files;
2084
+ tempDir;
2085
+ /** Apaga a pasta temporária da execução (cópias que o bot não levou para `dest`). Nunca lança. */
2086
+ async cleanup() {
2087
+ const pending = this.tempDir;
2088
+ this.tempDir = void 0;
2089
+ if (!pending) return;
2090
+ try {
2091
+ const { rm } = await import("fs/promises");
2092
+ await rm(await pending, { recursive: true, force: true });
2093
+ } catch {
2094
+ }
2095
+ }
2096
+ field(field2) {
2097
+ const value = fileField(this.deps.params, field2);
2098
+ if (value === void 0) throw new Error(`campo "${field2}" n\xE3o tem arquivo nos params`);
2099
+ return value;
2100
+ }
2101
+ single(refOrField) {
2102
+ if (typeof refOrField === "string") {
2103
+ const value = this.field(refOrField);
2104
+ if (Array.isArray(value)) {
2105
+ throw new Error(`campo "${refOrField}" tem v\xE1rios arquivos \u2014 use downloadAll("${refOrField}") ou passe um item da lista`);
1831
2106
  }
1832
- // defineProperties não tipa os getters adicionados — a interface é a fonte de verdade
1833
- );
1834
- const signal = this.abortController.signal;
1835
- const sleep2 = (ms) => cancelableSleep(ms, signal);
1836
- this.fileStore = new RunFileStore({ api, runId, params, signal, warn: (message) => this.log("warning", message) });
1837
- this.context = {
1838
- runId,
1839
- params,
1840
- log,
1841
- items,
1842
- progress,
1843
- files: this.fileStore.files,
1844
- transaction: tx,
1845
- flush: (timeoutMs) => this.flush(timeoutMs),
1846
- signal,
1847
- throwIfCanceled: checkCanceled,
1848
- sleep: sleep2,
1849
- retry: (fn, options) => retryWithBackoff(fn, { ...options, signal, wait: sleep2 })
1850
- };
2107
+ return value;
2108
+ }
2109
+ if (!isFileRef(refOrField)) throw new TypeError("esperado um FileRef ou o nome de um campo de arquivo");
2110
+ return refOrField;
1851
2111
  }
1852
- api;
1853
- runId;
1854
- logger;
1855
- tx;
1856
- context;
1857
- buffer = [];
1858
- dead = false;
1859
- chain = Promise.resolve();
1860
- pendingUpdates = 0;
1861
- timer;
1862
- watchTimer;
1863
- abortController = new AbortController();
1864
- flushIntervalMs;
1865
- cancelCheckIntervalMs;
1866
- // Itens de execução vivem FORA de `this.chain` (status/liveness não pode ficar
1867
- // atrás de milhares de itens), mas têm cadeia própria serializada — ordem de
1868
- // envio preservada e sem lotes sobrepostos.
1869
- itemsBuffer = [];
1870
- itemsChain = Promise.resolve();
1871
- itemsTimer;
1872
- itemSeq = 0;
1873
- /** Servidor sem o recurso (404) — para de tentar (e de avisar) nesta run. */
1874
- itemsUnsupported = false;
1875
- /**
1876
- * Cota de itens por run do servidor (429, legado — hoje a api não tem teto de itens) —
1877
- * degrada com um aviso só.
1878
- */
1879
- itemsQuotaExceeded = false;
1880
- /**
1881
- * Armazenamento do plano cheio (402 `storage_limit`): o lote é descartado e o aviso sai
1882
- * uma vez por episódio. Os envios continuam — num agente `start()` o espaço pode ser
1883
- * liberado (ou o plano trocado) sem reiniciar; um lote aceito rearma o aviso.
1884
- */
1885
- itemsStorageFull = false;
1886
- /**
1887
- * Só o kill() do exit path fecha os itens — `dead` (run cancelada/finalizada no
1888
- * servidor) NÃO fecha: o servidor aceita itens de run terminal (trabalho já feito).
1889
- */
1890
- itemsClosed = false;
1891
- // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
1892
- // status/logs e não pode ficar bloqueada esperando um binário subir.
1893
- pendingAttachments = /* @__PURE__ */ new Set();
1894
- attachmentCount = 0;
1895
- uploadSlots = MAX_CONCURRENT_UPLOADS;
1896
- uploadQueue = [];
1897
- /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
1898
- attachmentsUnsupported = false;
1899
- // Progresso (ctx.progress): snapshot absoluto, enviado com throttle via piggyback
1900
- // em `enqueue()` — nunca gera tráfego próprio enquanto logs/heartbeat já fluem.
1901
- progressCurrent = 0;
1902
- progressTotal = null;
1903
- /** Snapshot ainda não entregue ao servidor. */
1904
- progressDirty = false;
1905
- progressTimer;
1906
- progressWarned = false;
1907
- /** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
1908
- fileStore;
1909
- /** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
1910
- cleanupFiles() {
1911
- return this.fileStore.cleanup();
2112
+ async downloadAll(field2, dest) {
2113
+ const value = this.field(field2);
2114
+ const refs = Array.isArray(value) ? value : [value];
2115
+ const paths = [];
2116
+ for (const ref of refs) paths.push(await this.download(ref, dest, true));
2117
+ return paths;
1912
2118
  }
1913
- get isDead() {
1914
- return this.dead;
2119
+ async read(ref) {
2120
+ const { readFile, rm } = await import("fs/promises");
2121
+ const { dirname } = await import("path");
2122
+ const path = await this.download(ref, void 0);
2123
+ try {
2124
+ return await readFile(path);
2125
+ } finally {
2126
+ await rm(dirname(path), { recursive: true, force: true });
2127
+ }
2128
+ }
2129
+ async download(ref, dest, destIsDir = false) {
2130
+ this.checkCanceled();
2131
+ const target = await this.resolveTarget(ref, dest, destIsDir);
2132
+ if (ref.storage === "local") await this.copyLocal(ref, target);
2133
+ else await this.downloadS3(ref, target);
2134
+ return target;
1915
2135
  }
1916
- /** Lança `RunCanceled` se o signal desta run já disparou. */
1917
2136
  checkCanceled() {
1918
- if (this.abortController.signal.aborted) throw runCanceledError();
2137
+ if (this.deps.signal.aborted) throw runCanceledError();
1919
2138
  }
1920
- /** Aborta o ctx.signal sem matar o reporter (timeout/shutdown ainda reportam o terminal). */
1921
- abort() {
1922
- this.stopCancelWatch();
1923
- if (!this.abortController.signal.aborted) this.abortController.abort();
2139
+ runTempDir() {
2140
+ this.tempDir ??= (async () => {
2141
+ const { mkdtemp } = await import("fs/promises");
2142
+ const { tmpdir } = await import("os");
2143
+ const { join } = await import("path");
2144
+ return mkdtemp(join(tmpdir(), "hobots-files-"));
2145
+ })();
2146
+ return this.tempDir;
1924
2147
  }
1925
- /**
1926
- * Mata o reporter de vez: aborta o signal e suprime todo reporte futuro.
1927
- * Usado no exit path — depois que o agente reportou AgentTerminated por fora
1928
- * da fila, um handler zumbi não pode reenviar status por cima.
1929
- */
1930
- kill() {
1931
- this.abort();
1932
- this.dead = true;
1933
- this.clearFlushTimer();
1934
- this.clearProgressTimer();
1935
- this.buffer = [];
1936
- this.itemsClosed = true;
1937
- this.clearItemsTimer();
1938
- if (this.itemsBuffer.length > 0) {
1939
- this.logger.warn(`${this.itemsBuffer.length} item(ns) de execu\xE7\xE3o descartados (processo encerrando)`);
1940
- this.itemsBuffer = [];
2148
+ async resolveTarget(ref, dest, destIsDir) {
2149
+ const { mkdir, mkdtemp, stat } = await import("fs/promises");
2150
+ const { dirname, join, resolve: resolve2 } = await import("path");
2151
+ const name = safeFileName(ref.name, "arquivo");
2152
+ if (dest === void 0) {
2153
+ return join(await mkdtemp(join(await this.runTempDir(), "f-")), name);
2154
+ }
2155
+ const isDir = destIsDir || dest.endsWith("/") || dest.endsWith("\\") || (await stat(dest).catch(() => void 0))?.isDirectory() === true;
2156
+ if (isDir) {
2157
+ await mkdir(dest, { recursive: true });
2158
+ return resolve2(dest, name);
2159
+ }
2160
+ await mkdir(dirname(resolve2(dest)), { recursive: true });
2161
+ return resolve2(dest);
2162
+ }
2163
+ /** storage 's3': URL pré-assinada → GET em streaming → confere o tamanho → só então DELETE. */
2164
+ async downloadS3(ref, target) {
2165
+ const { api, runId, signal } = this.deps;
2166
+ let expected;
2167
+ let url;
2168
+ try {
2169
+ const info = await api.requestFileDownload(runId, ref.id, signal);
2170
+ expected = info.size >= 0 ? info.size : ref.size;
2171
+ url = info.url;
2172
+ } catch (error) {
2173
+ throw this.translate(error, ref);
2174
+ }
2175
+ const { open, rename, rm } = await import("fs/promises");
2176
+ const part = `${target}.part`;
2177
+ const idleTimeoutMs = this.deps.idleTimeoutMs ?? FILE_DOWNLOAD_IDLE_TIMEOUT_MS;
2178
+ const controller = new AbortController();
2179
+ let timedOut = false;
2180
+ let timer;
2181
+ const arm = () => {
2182
+ clearTimeout(timer);
2183
+ timer = setTimeout(() => {
2184
+ timedOut = true;
2185
+ controller.abort();
2186
+ }, idleTimeoutMs);
2187
+ };
2188
+ const onCancel = () => controller.abort();
2189
+ signal.addEventListener("abort", onCancel, { once: true });
2190
+ try {
2191
+ arm();
2192
+ const response = await api.fetchFile(url, controller.signal);
2193
+ const handle = await open(part, "w");
2194
+ let written = 0;
2195
+ try {
2196
+ const reader = response.body?.getReader();
2197
+ const stopReading = () => void reader?.cancel().catch(() => void 0);
2198
+ controller.signal.addEventListener("abort", stopReading, { once: true });
2199
+ for (; ; ) {
2200
+ const chunk = reader ? await reader.read() : { done: true, value: void 0 };
2201
+ if (chunk.done) break;
2202
+ arm();
2203
+ written += chunk.value.byteLength;
2204
+ if (written > expected) {
2205
+ stopReading();
2206
+ break;
2207
+ }
2208
+ await handle.write(chunk.value);
2209
+ }
2210
+ } finally {
2211
+ await handle.close();
2212
+ }
2213
+ if (written !== expected) {
2214
+ throw new Error(`tamanho divergente no download de "${ref.name}": recebidos ${written} bytes, esperados ${expected}`);
2215
+ }
2216
+ await rename(part, target);
2217
+ } catch (error) {
2218
+ await rm(part, { force: true });
2219
+ if (signal.aborted) throw runCanceledError();
2220
+ if (timedOut) {
2221
+ throw new Error(`download de "${ref.name}" parado h\xE1 ${idleTimeoutMs / 1e3}s \u2014 tente de novo`);
2222
+ }
2223
+ throw this.translate(error, ref);
2224
+ } finally {
2225
+ clearTimeout(timer);
2226
+ signal.removeEventListener("abort", onCancel);
2227
+ }
2228
+ try {
2229
+ await api.confirmFileDownload(runId, ref.id);
2230
+ } catch (error) {
2231
+ this.deps.warn(`arquivo "${ref.name}" baixado, mas a confirma\xE7\xE3o ao servidor falhou: ${String(error)}`);
1941
2232
  }
1942
- const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
1943
- for (const resume of waiting) resume();
1944
2233
  }
1945
2234
  /**
1946
- * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
1947
- * que descarta o buffer). Capado em um chunk — o exit path não tem retry nem fila,
1948
- * então preserva os logs mais recentes, os mais valiosos na morte do processo.
2235
+ * storage 'local': copia o único arquivo de `$HOBOTS_LOCAL_FILES_DIR/<id>/` e apaga a pasta de
2236
+ * origem. No modo local, um FileRef com `path` copia direto desse caminho, sem apagar a origem.
1949
2237
  */
1950
- drainLogs() {
1951
- this.clearFlushTimer();
1952
- return this.takeLogs().slice(-MAX_LOGS_PER_PATCH);
2238
+ async copyLocal(ref, target) {
2239
+ const direct = localRunPath(ref);
2240
+ if (direct !== void 0) {
2241
+ await this.copyFromPath(ref, direct, target);
2242
+ return;
2243
+ }
2244
+ const base = process.env[LOCAL_FILES_DIR_ENV];
2245
+ if (!base) throw unavailableLocal(ref);
2246
+ const { copyFile, readdir, rename, rm, stat } = await import("fs/promises");
2247
+ const { join, resolve: resolve2, sep } = await import("path");
2248
+ const root = resolve2(base);
2249
+ const folder = resolve2(root, ref.id);
2250
+ if (!isInsideDir(root, folder, sep) || !UUID_RE.test(ref.id)) {
2251
+ throw new Error(`id de arquivo local inv\xE1lido: ${JSON.stringify(ref.id)}`);
2252
+ }
2253
+ let names;
2254
+ try {
2255
+ names = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
2256
+ } catch (error) {
2257
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") throw unavailableLocal(ref);
2258
+ throw error;
2259
+ }
2260
+ if (names.length === 0) throw unavailableLocal(ref);
2261
+ if (names.length > 1) throw new Error(`pasta do arquivo "${ref.name}" tem ${names.length} arquivos \u2014 esperado 1`);
2262
+ this.checkCanceled();
2263
+ const part = `${target}.part`;
2264
+ try {
2265
+ await copyFile(join(folder, names[0]), part);
2266
+ const { size } = await stat(part);
2267
+ if (size !== ref.size) {
2268
+ throw new Error(`tamanho divergente na c\xF3pia de "${ref.name}": copiados ${size} bytes, esperados ${ref.size}`);
2269
+ }
2270
+ await rename(part, target);
2271
+ } catch (error) {
2272
+ await rm(part, { force: true });
2273
+ throw error;
2274
+ }
2275
+ await rm(folder, { recursive: true, force: true });
1953
2276
  }
1954
2277
  /**
1955
- * Linha de log da run com eco no terminal. `image` (opcional) vira o anexo da linha:
1956
- * o id entra na entrada ANTES de qualquer I/O — é isso que mantém a ordem dos logs
1957
- * intacta e o handler livre da latência do upload.
2278
+ * Modo local: cópia do arquivo apontado por `path` (teste roda várias vezes — a fonte fica).
2279
+ * Sem conferência de tamanho: o `size` do FileRef é informativo aqui.
1958
2280
  */
1959
- log(level, message, image3) {
1960
- if (this.dead) return;
1961
- const scrubbed = scrubSecrets(safeString(message));
1962
- const ts = nowISO();
1963
- const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
1964
- printRunLog(level, attachmentId ? `${scrubbed} [imagem]` : scrubbed);
1965
- this.push(level, scrubbed, true, attachmentId ? [attachmentId] : void 0, ts);
2281
+ async copyFromPath(ref, path, target) {
2282
+ const { copyFile, rename, rm, stat } = await import("fs/promises");
2283
+ const { resolve: resolve2 } = await import("path");
2284
+ const source = resolve2(path);
2285
+ const info = await stat(source).catch(() => void 0);
2286
+ if (!info?.isFile()) throw new Error(`arquivo "${ref.name}" n\xE3o encontrado em ${path}`);
2287
+ this.checkCanceled();
2288
+ const part = `${target}.part`;
2289
+ try {
2290
+ await copyFile(source, part);
2291
+ await rename(part, target);
2292
+ } catch (error) {
2293
+ await rm(part, { force: true });
2294
+ throw error;
2295
+ }
1966
2296
  }
1967
- /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
1968
- push(level, message, alreadyScrubbed = false, attachments, ts = nowISO()) {
1969
- if (this.dead) return ts;
1970
- const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
1971
- this.buffer.push({
1972
- ts,
1973
- level,
1974
- message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
1975
- ...attachments && attachments.length > 0 ? { attachments: attachments.slice(0, WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY) } : {}
1976
- });
1977
- if (this.buffer.length >= FLUSH_AT_BUFFERED) {
1978
- this.flushLogs();
1979
- } else {
1980
- this.scheduleFlush();
2297
+ translate(error, ref) {
2298
+ if (this.deps.signal.aborted) return runCanceledError();
2299
+ if (error instanceof TasksApiError && error.status === 410) {
2300
+ return new TasksApiError(`arquivo "${ref.name}" j\xE1 baixado ou expirado`, 410);
1981
2301
  }
1982
- return ts;
2302
+ if (error instanceof TasksApiError && error.status === 404) {
2303
+ return new TasksApiError(`arquivo "${ref.name}" n\xE3o encontrado nesta execu\xE7\xE3o`, 404);
2304
+ }
2305
+ return error;
1983
2306
  }
1984
- scheduleFlush() {
1985
- if (this.timer) return;
1986
- this.timer = setTimeout(() => {
1987
- this.timer = void 0;
1988
- this.flushLogs();
1989
- }, this.flushIntervalMs);
1990
- unrefTimer(this.timer);
1991
- }
1992
- clearFlushTimer() {
1993
- if (this.timer) {
1994
- clearTimeout(this.timer);
1995
- this.timer = void 0;
1996
- }
1997
- }
1998
- takeLogs() {
1999
- return this.buffer.splice(0, this.buffer.length);
2000
- }
2001
- /** Flush incremental: envia o buffer como status 'running' (em chunks). */
2002
- flushLogs() {
2003
- this.clearFlushTimer();
2004
- if (this.dead || this.buffer.length === 0) return;
2005
- const logs = this.buffer.splice(0, this.buffer.length);
2006
- for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
2007
- this.enqueue({ status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) });
2008
- }
2009
- }
2010
- /**
2011
- * `hobots.flush()`: o bot decide quando entregar o que já fez — steps raiz finalizadas
2012
- * viram transactions parciais da run, e itens, logs e progresso pendentes saem já, sem
2013
- * esperar o throttle. Aguarda a entrega com teto. Nunca lança (pode ir num `finally`).
2014
- */
2015
- async flush(timeoutMs = ITEMS_DRAIN_MS) {
2016
- this.tx.flush();
2017
- this.flushItems();
2018
- if (this.progressDirty) this.flushProgress();
2019
- else this.flushLogs();
2020
- await Promise.race([
2021
- Promise.all([this.itemsChain, this.chain, this.tx.drain(timeoutMs)]).catch(() => void 0),
2022
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2023
- ]);
2024
- }
2025
- /** Primeiro PATCH (claimed → running). Retorna false se a run já morreu (cancelada). */
2026
- async reportRunning() {
2027
- this.enqueue({ status: "running" });
2028
- await this.chain;
2029
- if (!this.dead) this.startCancelWatch();
2030
- return !this.dead;
2031
- }
2032
- reportSuccess() {
2033
- this.stopCancelWatch();
2034
- this.clearFlushTimer();
2035
- this.forceFinalProgress();
2036
- this.enqueue({ status: "succeeded", logs: this.takeLogsForTerminal() });
2037
- }
2038
- reportFailure(error) {
2039
- this.stopCancelWatch();
2040
- this.clearFlushTimer();
2041
- this.forceFinalProgress();
2042
- this.enqueue({ status: "failed", error, logs: this.takeLogsForTerminal() });
2043
- }
2044
- /**
2045
- * Esvazia o buffer para o PATCH terminal respeitando o teto de 100 logs por PATCH:
2046
- * o excedente sai antes, em PATCHes `running` na mesma cadeia (a ordem se mantém).
2047
- */
2048
- takeLogsForTerminal() {
2049
- let logs = this.takeLogs();
2050
- while (logs.length > MAX_LOGS_PER_PATCH) {
2051
- this.enqueue({ status: "running", logs: logs.slice(0, MAX_LOGS_PER_PATCH) });
2052
- logs = logs.slice(MAX_LOGS_PER_PATCH);
2053
- }
2054
- return logs;
2055
- }
2056
- // ── Progresso ────────────────────────────────────────────────────────────────
2057
- /** Valida, clampa e marca o snapshot como pendente; agenda flush só se nada mais vai sair. */
2058
- setProgress(current, total) {
2059
- if (this.dead) return;
2060
- if (total != null && (!Number.isInteger(total) || total < 1)) {
2061
- if (!this.progressWarned) {
2062
- this.progressWarned = true;
2063
- this.logger.warn(`ctx.progress: total inv\xE1lido (${String(total)}) \u2014 precisa ser inteiro >= 1; ignorado`);
2064
- }
2307
+ };
2308
+
2309
+ // src/tasks/context.ts
2310
+ var LOG_LEVEL_COLOR = {
2311
+ debug: "gray",
2312
+ info: "cyan",
2313
+ warning: "yellow",
2314
+ error: "red",
2315
+ success: "green"
2316
+ };
2317
+ function joinLogArgs(message, rest) {
2318
+ if (rest.length === 0) return safeString(message);
2319
+ return [message, ...rest].map(safeString).join(" ");
2320
+ }
2321
+ function splitLogArgs(message, rest) {
2322
+ const args = [message, ...rest];
2323
+ const last = args[args.length - 1];
2324
+ if (!isImageAttachment(last)) return { text: joinLogArgs(message, rest) };
2325
+ const text = args.slice(0, -1);
2326
+ if (text.length === 0) return { text: last.caption !== void 0 ? safeString(last.caption) : "captura de tela", image: last };
2327
+ return { text: joinLogArgs(text[0], text.slice(1)), image: last };
2328
+ }
2329
+ function printRunLog(level, message) {
2330
+ const line = colorize(`[hobots] ${level.toUpperCase()}`, LOG_LEVEL_COLOR[level]);
2331
+ const method2 = level === "warning" ? "warn" : level === "success" ? "info" : level;
2332
+ console[method2](line, message);
2333
+ }
2334
+ var PROGRESS_LINE_PREFIX = "[hobots:progress]";
2335
+ var PROGRESS_PRINT_THROTTLE_MS = 250;
2336
+ function printRunProgress(current, total) {
2337
+ console.log(`${PROGRESS_LINE_PREFIX} ${current}/${total}`);
2338
+ }
2339
+ function cancelableSleep(ms, signal) {
2340
+ return new Promise((resolve2, reject) => {
2341
+ if (signal.aborted) {
2342
+ reject(runCanceledError());
2065
2343
  return;
2066
2344
  }
2067
- const safeCurrent = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : this.progressCurrent;
2068
- const next = total == null ? safeCurrent : Math.min(safeCurrent, total);
2069
- if (next === this.progressCurrent && total === this.progressTotal) return;
2070
- this.progressCurrent = next;
2071
- this.progressTotal = total;
2072
- if (total == null) return;
2073
- this.progressDirty = true;
2074
- if (this.progressTimer) return;
2075
- this.progressTimer = setTimeout(() => {
2076
- this.progressTimer = void 0;
2077
- this.flushProgress();
2078
- }, PROGRESS_FLUSH_MS);
2079
- unrefTimer(this.progressTimer);
2080
- }
2081
- flushProgress() {
2082
- this.clearProgressTimer();
2083
- if (this.dead || !this.progressDirty) return;
2084
- if (this.buffer.length > 0) this.flushLogs();
2085
- else this.enqueue({ status: "running" });
2086
- }
2087
- clearProgressTimer() {
2088
- if (this.progressTimer) {
2089
- clearTimeout(this.progressTimer);
2090
- this.progressTimer = void 0;
2091
- }
2092
- }
2093
- /** O PATCH terminal sempre leva o snapshot final (o app mostra "87/100" numa run que falhou). */
2094
- forceFinalProgress() {
2095
- this.clearProgressTimer();
2096
- this.progressDirty = this.progressTotal != null;
2097
- }
2098
- /** Consome o snapshot pendente para pegar carona no próximo PATCH. */
2099
- takeProgress() {
2100
- if (!this.progressDirty || this.progressTotal == null) return void 0;
2101
- this.progressDirty = false;
2102
- return { current: this.progressCurrent, total: this.progressTotal };
2103
- }
2104
- /**
2105
- * Heartbeat de cancelamento: enquanto o handler roda, um PATCH 'running' vazio
2106
- * periódico faz o servidor responder 409 se a run foi cancelada no app — o que
2107
- * mata o reporter e dispara ctx.signal, mesmo em handlers que nunca logam.
2108
- */
2109
- startCancelWatch() {
2110
- if (this.watchTimer) return;
2111
- this.watchTimer = setInterval(() => {
2112
- if (this.dead || this.pendingUpdates > 0 || this.buffer.length > 0) return;
2113
- this.enqueue({ status: "running" });
2114
- }, this.cancelCheckIntervalMs);
2115
- unrefTimer(this.watchTimer);
2116
- }
2117
- stopCancelWatch() {
2118
- if (this.watchTimer) {
2119
- clearInterval(this.watchTimer);
2120
- this.watchTimer = void 0;
2121
- }
2122
- }
2123
- /** Aguarda a fila de PATCHes esvaziar (chame depois do terminal). */
2124
- finalize() {
2125
- return this.chain;
2126
- }
2127
- // ── Itens de execução ────────────────────────────────────────────────────────
2128
- /**
2129
- * Bufferiza um item (síncrono — a chamada nunca espera rede). Diferente do PATCH
2130
- * de status, o servidor aceita itens mesmo com a run cancelada (trabalho já feito),
2131
- * então um reporter morto não descarta: só um kill() de exit path descarta.
2132
- */
2133
- addItem(status, message, payload, id, image3) {
2134
- if (this.itemsClosed || this.itemsUnsupported) return;
2135
- let scrubbedPayload;
2136
- if (payload) {
2137
- scrubbedPayload = scrubPayloadValue(payload, 0);
2138
- let serializedLength;
2139
- try {
2140
- serializedLength = JSON.stringify(scrubbedPayload).length;
2141
- } catch {
2142
- }
2143
- if (serializedLength === void 0 || serializedLength > MAX_ITEM_PAYLOAD_BYTES) {
2144
- this.logger.warn(
2145
- `payload do item descartado (${serializedLength === void 0 ? "n\xE3o serializ\xE1vel" : `${serializedLength} bytes > ${MAX_ITEM_PAYLOAD_BYTES}`}) \u2014 o item foi mantido`
2146
- );
2147
- scrubbedPayload = void 0;
2148
- }
2149
- }
2150
- const ts = nowISO();
2151
- const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2152
- this.itemsBuffer.push({
2153
- id: id ? truncate(String(id), 255) : uuid4(),
2154
- seq: this.itemSeq++,
2155
- status,
2156
- ...message ? { message: truncate(scrubSecrets(safeString(message)), MAX_ITEM_MESSAGE_CHARS) } : {},
2157
- ...scrubbedPayload ? { payload: scrubbedPayload } : {},
2158
- ts,
2159
- ...attachmentId ? { attachments: [attachmentId] } : {}
2160
- });
2161
- if (this.itemsBuffer.length >= ITEMS_FLUSH_AT_BUFFERED) {
2162
- this.flushItems();
2163
- } else {
2164
- this.scheduleItemsFlush();
2165
- }
2166
- }
2167
- scheduleItemsFlush() {
2168
- if (this.itemsTimer) return;
2169
- this.itemsTimer = setTimeout(() => {
2170
- this.itemsTimer = void 0;
2171
- this.flushItems();
2172
- }, this.flushIntervalMs);
2173
- unrefTimer(this.itemsTimer);
2174
- }
2175
- clearItemsTimer() {
2176
- if (this.itemsTimer) {
2177
- clearTimeout(this.itemsTimer);
2178
- this.itemsTimer = void 0;
2179
- }
2180
- }
2181
- /** Envia o buffer de itens em lotes serializados na cadeia própria. */
2182
- flushItems() {
2183
- this.clearItemsTimer();
2184
- if (this.itemsBuffer.length === 0 || this.itemsUnsupported) return;
2185
- const items = this.itemsBuffer.splice(0, this.itemsBuffer.length);
2186
- for (let i = 0; i < items.length; i += MAX_ITEMS_PER_REQUEST) {
2187
- const batch = items.slice(i, i + MAX_ITEMS_PER_REQUEST);
2188
- this.itemsChain = this.itemsChain.then(() => this.sendItems(batch));
2189
- }
2190
- }
2191
- async sendItems(batch) {
2192
- if (this.itemsUnsupported) return;
2193
- try {
2194
- await this.api.createItems(this.runId, { items: batch });
2195
- this.itemsStorageFull = false;
2196
- } catch (error) {
2197
- if (error instanceof TasksApiError && error.status === 404) {
2198
- this.itemsUnsupported = true;
2199
- this.logger.warn("itens de execu\xE7\xE3o n\xE3o s\xE3o suportados por este servidor Hobots \u2014 descartados");
2200
- return;
2201
- }
2202
- if (error instanceof TasksApiError && error.status === 429) {
2203
- if (!this.itemsQuotaExceeded) {
2204
- this.itemsQuotaExceeded = true;
2205
- this.log("warning", "itens descartados: limite de itens por execu\xE7\xE3o atingido no servidor");
2206
- }
2207
- return;
2208
- }
2209
- if (error instanceof TasksApiError && error.status === 402) {
2210
- if (!this.itemsStorageFull) {
2211
- this.itemsStorageFull = true;
2212
- this.log("warning", "itens descartados: armazenamento do plano cheio \u2014 libere espa\xE7o ou fa\xE7a upgrade em Plano e cobran\xE7a");
2213
- }
2214
- return;
2215
- }
2216
- if (error instanceof TasksApiError) {
2217
- this.logger.error(`lote de ${batch.length} item(ns) rejeitado pelo servidor:`, error);
2218
- this.log("warning", `${batch.length} item(ns) descartado(s): envio rejeitado pelo servidor (HTTP ${error.status})`);
2219
- return;
2220
- }
2221
- this.itemsBuffer.unshift(...batch);
2222
- this.scheduleItemsFlush();
2223
- this.logger.error(`lote de ${batch.length} item(ns) n\xE3o enviado (devolvido ao buffer):`, error);
2224
- }
2225
- }
2226
- /**
2227
- * Dreno final de itens antes do PATCH terminal (com teto): garante que os
2228
- * totalizadores estejam completos quando a run vira succeeded/failed. Como o
2229
- * servidor aceita itens tardios, perder a corrida aqui não perde dados.
2230
- */
2231
- async finalizeItems(timeoutMs = ITEMS_DRAIN_MS) {
2232
- this.flushItems();
2233
- await Promise.race([
2234
- this.itemsChain,
2235
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2236
- ]);
2237
- if (this.itemsBuffer.length > 0 && !this.itemsUnsupported && !this.itemsQuotaExceeded) {
2238
- this.flushItems();
2239
- await Promise.race([
2240
- this.itemsChain,
2241
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2242
- ]);
2243
- }
2244
- }
2245
- // ── Anexos ───────────────────────────────────────────────────────────────────
2246
- /**
2247
- * Pendura uma imagem num evento da run (log, item ou step): faz as checagens que não
2248
- * dependem de I/O (reporter morto, instalação sem anexos, cota), reserva o id e dispara
2249
- * o upload em segundo plano. Devolve o id — já referenciável pelo evento — ou undefined
2250
- * quando a imagem foi descartada (sempre com aviso no log da run). Nunca lança: todo
2251
- * caminho de erro do upload vira aviso. O hint `attachment` de `hobots.captureException`
2252
- * também cai aqui via `log()`.
2253
- */
2254
- queueAttachment(imageIn, ts) {
2255
- if (this.dead) return void 0;
2256
- const image3 = toImageAttachment(imageIn);
2257
- if (this.attachmentsUnsupported) return void 0;
2258
- if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
2259
- if (this.attachmentCount === MAX_ATTACHMENTS_PER_RUN) {
2260
- this.attachmentCount++;
2261
- this.log("warning", `imagem descartada: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
2262
- }
2263
- return void 0;
2264
- }
2265
- this.attachmentCount++;
2266
- const id = uuid4();
2267
- const rawCaption = image3.caption !== void 0 ? safeString(image3.caption) : void 0;
2268
- const caption = rawCaption ? truncate(scrubSecrets(rawCaption), MAX_CAPTION_CHARS) : void 0;
2269
- const work = this.uploadAttachment(id, image3.input, { caption, logTs: ts, contentType: image3.contentType }).then(() => void 0).catch(() => void 0);
2270
- this.pendingAttachments.add(work);
2271
- void work.finally(() => this.pendingAttachments.delete(work));
2272
- return id;
2345
+ const onAbort = () => {
2346
+ clearTimeout(timer);
2347
+ reject(runCanceledError());
2348
+ };
2349
+ const timer = setTimeout(() => {
2350
+ signal.removeEventListener("abort", onAbort);
2351
+ resolve2();
2352
+ }, ms);
2353
+ signal.addEventListener("abort", onAbort, { once: true });
2354
+ });
2355
+ }
2356
+ var DEFAULT_LOG_FLUSH_INTERVAL_MS = 2e3;
2357
+ var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
2358
+ var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
2359
+ var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
2360
+ var MAX_LOG_MESSAGE_CHARS = WIRE_LIMITS.MAX_LOG_MESSAGE_CHARS;
2361
+ var MAX_LOGS_PER_PATCH = WIRE_LIMITS.MAX_LOGS_PER_PATCH;
2362
+ var FLUSH_AT_BUFFERED = 20;
2363
+ var MAX_ITEMS_PER_REQUEST = WIRE_LIMITS.MAX_ITEMS_PER_REQUEST;
2364
+ var MAX_ITEMS_DONE_PER_PAGE = WIRE_LIMITS.MAX_ITEMS_DONE_PER_PAGE;
2365
+ var MAX_ITEM_MESSAGE_CHARS = WIRE_LIMITS.MAX_ITEM_MESSAGE_CHARS;
2366
+ var MAX_ITEM_PAYLOAD_BYTES = WIRE_LIMITS.MAX_ITEM_PAYLOAD_BYTES;
2367
+ var ITEMS_FLUSH_AT_BUFFERED = 100;
2368
+ var ITEMS_DRAIN_MS = 15e3;
2369
+ var PROGRESS_FLUSH_MS = 1e3;
2370
+ function scrubPayloadValue(value, depth) {
2371
+ if (typeof value === "string") return scrubSecrets(value);
2372
+ if (depth >= 4 || value === null || typeof value !== "object") return value;
2373
+ if (Array.isArray(value)) return value.map((entry) => scrubPayloadValue(entry, depth + 1));
2374
+ const out = {};
2375
+ for (const [key, entry] of Object.entries(value)) {
2376
+ out[key] = scrubPayloadValue(entry, depth + 1);
2273
2377
  }
2274
- /** Retorna o motivo da falha, ou undefined em sucesso. */
2275
- async uploadAttachment(id, image3, meta) {
2276
- let bytes;
2277
- try {
2278
- bytes = await toBytes(image3);
2279
- } catch (error) {
2280
- this.log("warning", `imagem n\xE3o p\xF4de ser lida: ${String(error)}`);
2281
- return "read-failed";
2282
- }
2283
- const contentType = sniffContentType(bytes) ?? meta.contentType;
2284
- if (!contentType) {
2285
- this.log("warning", "imagem descartada: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
2286
- return "unsupported-format";
2287
- }
2288
- if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
2289
- this.log(
2290
- "warning",
2291
- `imagem descartada: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
2292
- );
2293
- return "too-large";
2294
- }
2295
- const dimensions = readPngDimensions(bytes);
2296
- const release = await this.acquireUploadSlot();
2297
- try {
2298
- if (this.abortController.signal.aborted || this.dead) return "canceled";
2299
- const created = await this.api.createAttachment(
2300
- this.runId,
2301
- {
2302
- id,
2303
- content_type: contentType,
2304
- size_bytes: bytes.byteLength,
2305
- log_ts: meta.logTs,
2306
- ...meta.caption ? { caption: meta.caption } : {},
2307
- ...dimensions ?? {}
2378
+ return out;
2379
+ }
2380
+ var RunReporter = class {
2381
+ constructor(api, runId, logger, tx, flushIntervalMs, cancelCheckIntervalMs, params = {}) {
2382
+ this.api = api;
2383
+ this.runId = runId;
2384
+ this.logger = logger;
2385
+ this.tx = tx;
2386
+ this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
2387
+ this.cancelCheckIntervalMs = Math.min(
2388
+ MAX_CANCEL_CHECK_INTERVAL_MS,
2389
+ Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
2390
+ );
2391
+ const logAt = (level) => (message, ...rest) => {
2392
+ const { text, image: image3 } = splitLogArgs(message, rest);
2393
+ this.log(level, text, image3);
2394
+ };
2395
+ const log = {
2396
+ debug: logAt("debug"),
2397
+ info: logAt("info"),
2398
+ warn: logAt("warning"),
2399
+ error: logAt("error"),
2400
+ success: logAt("success")
2401
+ };
2402
+ tx.setLifecycleSink((level, message) => this.push(level, message));
2403
+ tx.setAttachmentSink((image3, ts) => this.queueAttachment(image3, ts));
2404
+ const checkCanceled = () => this.checkCanceled();
2405
+ tx.setStepGuard(checkCanceled);
2406
+ const eachSink = {
2407
+ report: (status, message, payload, id, image3) => this.addItem(status, message, payload, id, image3),
2408
+ startStep: (op, description) => tx.start(op, description),
2409
+ stepScope: (step, fn) => tx.scope(step, fn),
2410
+ declareTotal: (total) => {
2411
+ if (this.progressTotal == null) this.setProgress(this.progressCurrent, total);
2412
+ },
2413
+ advance: () => this.setProgress(this.progressCurrent + 1, this.progressTotal),
2414
+ checkCanceled,
2415
+ isAborted: () => this.abortController.signal.aborted,
2416
+ logInfo: (message) => this.log("info", message),
2417
+ logWarning: (message) => this.log("warning", message),
2418
+ logError: (message) => this.log("error", message),
2419
+ sleep: (ms) => cancelableSleep(ms, this.abortController.signal),
2420
+ doneItemIds: () => this.listDoneItemIds(),
2421
+ flush: () => this.flush()
2422
+ };
2423
+ const items = {
2424
+ add: (item) => {
2425
+ checkCanceled();
2426
+ this.addItem(item.status, item.message, item.payload, item.id, item.image);
2427
+ },
2428
+ succeeded: (payload, options) => {
2429
+ checkCanceled();
2430
+ this.addItem("succeeded", void 0, payload, options?.id, options?.image);
2431
+ },
2432
+ failed: (message, payload, options) => {
2433
+ checkCanceled();
2434
+ this.addItem("failed", message, payload, options?.id, options?.image);
2435
+ },
2436
+ occurrence: (message, payload, options) => {
2437
+ checkCanceled();
2438
+ this.addItem("occurrence", message, payload, options?.id, options?.image);
2439
+ },
2440
+ each: (list, handler, options = {}) => eachItem(list, handler, options, eachSink)
2441
+ };
2442
+ const progress = Object.defineProperties(
2443
+ {
2444
+ total: (total) => {
2445
+ checkCanceled();
2446
+ this.setProgress(this.progressCurrent, total);
2308
2447
  },
2309
- this.abortController.signal
2310
- );
2311
- await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
2312
- await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
2313
- });
2314
- return void 0;
2315
- } catch (error) {
2316
- if (this.isUnsupported(error)) {
2317
- this.attachmentsUnsupported = true;
2318
- this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
2319
- return "unsupported";
2320
- }
2321
- if (error instanceof TasksApiError && error.status === 429) {
2322
- this.log("warning", "imagem descartada: limite de anexos por execu\xE7\xE3o atingido no servidor");
2323
- return "quota";
2448
+ advance: (count = 1) => {
2449
+ checkCanceled();
2450
+ this.setProgress(this.progressCurrent + count, this.progressTotal);
2451
+ },
2452
+ set: (current, total) => {
2453
+ checkCanceled();
2454
+ this.setProgress(current, total ?? this.progressTotal);
2455
+ }
2456
+ },
2457
+ {
2458
+ current: { enumerable: true, get: () => this.progressCurrent },
2459
+ snapshot: {
2460
+ enumerable: true,
2461
+ get: () => this.progressTotal == null ? null : { current: this.progressCurrent, total: this.progressTotal }
2462
+ }
2324
2463
  }
2325
- if (this.abortController.signal.aborted) return "canceled";
2326
- this.log("warning", `falha ao enviar a imagem: ${String(error)}`);
2327
- void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
2328
- });
2329
- return "upload-failed";
2330
- } finally {
2331
- release();
2332
- }
2333
- }
2334
- /** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
2335
- isUnsupported(error) {
2336
- return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
2337
- }
2338
- /** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
2339
- acquireUploadSlot() {
2340
- const release = () => {
2341
- const next = this.uploadQueue.shift();
2342
- if (next) next();
2343
- else this.uploadSlots++;
2464
+ // defineProperties não tipa os getters adicionados — a interface é a fonte de verdade
2465
+ );
2466
+ const signal = this.abortController.signal;
2467
+ const sleep2 = (ms) => cancelableSleep(ms, signal);
2468
+ this.fileStore = new RunFileStore({ api, runId, params, signal, warn: (message) => this.log("warning", message) });
2469
+ this.context = {
2470
+ runId,
2471
+ params,
2472
+ log,
2473
+ items,
2474
+ progress,
2475
+ files: this.fileStore.files,
2476
+ step: async (name, fn, options) => {
2477
+ const root = tx.currentStep() === void 0;
2478
+ try {
2479
+ return await tx.step(name, fn, { ...options, signal, wait: sleep2, warn: (message) => this.log("warning", message) });
2480
+ } finally {
2481
+ if (options?.flush !== false && root && !signal.aborted) await this.flush();
2482
+ }
2483
+ },
2484
+ flush: (timeoutMs) => this.flush(timeoutMs),
2485
+ signal,
2486
+ throwIfCanceled: checkCanceled,
2487
+ sleep: sleep2,
2488
+ retry: (fn, options) => retryWithBackoff(fn, { ...options, signal, wait: sleep2 })
2344
2489
  };
2345
- if (this.uploadSlots > 0) {
2346
- this.uploadSlots--;
2347
- return Promise.resolve(release);
2348
- }
2349
- return new Promise((resolve2) => {
2350
- this.uploadQueue.push(() => resolve2(release));
2351
- });
2352
2490
  }
2491
+ api;
2492
+ runId;
2493
+ logger;
2494
+ tx;
2495
+ context;
2496
+ buffer = [];
2497
+ dead = false;
2498
+ chain = Promise.resolve();
2499
+ pendingUpdates = 0;
2500
+ timer;
2501
+ watchTimer;
2502
+ abortController = new AbortController();
2503
+ flushIntervalMs;
2504
+ cancelCheckIntervalMs;
2505
+ // Itens de execução vivem FORA de `this.chain` (status/liveness não pode ficar
2506
+ // atrás de milhares de itens), mas têm cadeia própria serializada — ordem de
2507
+ // envio preservada e sem lotes sobrepostos.
2508
+ itemsBuffer = [];
2509
+ itemsChain = Promise.resolve();
2510
+ itemsTimer;
2511
+ itemSeq = 0;
2512
+ /** Servidor sem o recurso (404) — para de tentar (e de avisar) nesta run. */
2513
+ itemsUnsupported = false;
2353
2514
  /**
2354
- * Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
2355
- * exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
2515
+ * Cota de itens por run do servidor (429, legado — hoje a api não tem teto de itens) —
2516
+ * degrada com um aviso só.
2356
2517
  */
2357
- async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
2358
- if (this.pendingAttachments.size === 0) return;
2359
- await Promise.race([
2360
- Promise.allSettled([...this.pendingAttachments]),
2361
- new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2362
- ]);
2363
- }
2364
- enqueue(updateIn) {
2365
- if (this.dead) {
2366
- this.salvageLogs(updateIn.logs);
2367
- return;
2368
- }
2369
- this.clearProgressTimer();
2370
- const progress = this.takeProgress();
2371
- const update = progress ? { ...updateIn, progress } : updateIn;
2372
- this.pendingUpdates++;
2373
- this.chain = this.chain.then(async () => {
2374
- if (this.dead) {
2375
- this.salvageLogs(update.logs);
2376
- return;
2377
- }
2378
- const payload = update.logs && update.logs.length === 0 ? { ...update, logs: void 0 } : update;
2379
- try {
2380
- await this.api.updateRun(this.runId, payload);
2381
- } catch (error) {
2382
- if (error instanceof TasksApiError && (error.status === 409 || error.status === 404)) {
2383
- this.dead = true;
2384
- this.stopCancelWatch();
2385
- this.abort();
2386
- const pending = this.buffer.splice(0, this.buffer.length);
2387
- this.salvageLogs(pending);
2388
- this.logger.warn(
2389
- `run ${this.runId} cancelada/finalizada no servidor \u2014 status suprimido` + (pending.length > 0 ? `, ${pending.length} log(s) enviados como log tardio` : "")
2390
- );
2391
- return;
2392
- }
2393
- if (update.logs && update.logs.length > 0) {
2394
- this.buffer.unshift(...update.logs);
2395
- this.scheduleFlush();
2396
- }
2397
- if (update.progress && this.progressTotal != null) this.progressDirty = true;
2398
- this.logger.error(`update da run ${this.runId} perdido:`, error);
2399
- }
2400
- }).finally(() => {
2401
- this.pendingUpdates--;
2402
- });
2403
- }
2518
+ itemsQuotaExceeded = false;
2404
2519
  /**
2405
- * Best-effort de log tardio: envia logs para uma run já terminal, fora da fila.
2406
- * O servidor anexa os logs mesmo respondendo 409; erros são engolidos — este é
2407
- * o último recurso, não um canal confiável.
2520
+ * Armazenamento do plano cheio (402 `storage_limit`): o lote é descartado e o aviso sai
2521
+ * uma vez por episódio. Os envios continuam — num agente `start()` o espaço pode ser
2522
+ * liberado (ou o plano trocado) sem reiniciar; um lote aceito rearma o aviso.
2408
2523
  */
2409
- salvageLogs(logs) {
2410
- if (!logs || logs.length === 0) return;
2411
- for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
2412
- void this.api.updateRunOnce(this.runId, { status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) }).catch(() => {
2413
- });
2414
- }
2415
- }
2416
- };
2417
-
2418
- // src/tasks/run-context-store.ts
2419
- var storage;
2420
- var initPromise;
2421
- async function ensureStorage() {
2422
- if (storage || !isNode()) return;
2423
- initPromise ??= import("async_hooks").then(({ AsyncLocalStorage: ALS }) => {
2424
- storage = new ALS();
2425
- });
2426
- await initPromise;
2427
- }
2428
- async function runWithRunContext(store, fn) {
2429
- await ensureStorage();
2430
- return storage ? storage.run(store, fn) : fn();
2431
- }
2432
- function currentRunReporter() {
2433
- return storage?.getStore()?.reporter;
2434
- }
2435
-
2436
- // src/transaction.ts
2437
- var FLUSH_TRANSACTION_OP = "hobots.run.flush";
2438
- var MAX_STEP_MESSAGE_CHARS = 2e3;
2439
- function stepMessage(text) {
2440
- return truncate(scrubSecrets(text), MAX_STEP_MESSAGE_CHARS);
2441
- }
2442
- function stepId() {
2443
- return uuid4().slice(0, 16);
2444
- }
2445
- function stepLabel(op, description) {
2446
- return [op, description].filter(Boolean).join(" \u2014 ") || "(sem nome)";
2447
- }
2448
- function levelForStatus(status) {
2449
- switch (status) {
2450
- case "warning":
2451
- return "warning";
2452
- case "error":
2453
- return "error";
2454
- case "cancelled":
2455
- return "warning";
2456
- default:
2457
- return "success";
2458
- }
2459
- }
2460
- function colorForStatus(status) {
2461
- switch (status) {
2462
- case "warning":
2463
- return "yellow";
2464
- case "error":
2465
- return "red";
2466
- case "cancelled":
2467
- return "gray";
2468
- default:
2469
- return "green";
2524
+ itemsStorageFull = false;
2525
+ /**
2526
+ * Só o kill() do exit path fecha os itens — `dead` (run cancelada/finalizada no
2527
+ * servidor) NÃO fecha: o servidor aceita itens de run terminal (trabalho já feito).
2528
+ */
2529
+ itemsClosed = false;
2530
+ // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
2531
+ // status/logs e não pode ficar bloqueada esperando um binário subir.
2532
+ pendingAttachments = /* @__PURE__ */ new Set();
2533
+ attachmentCount = 0;
2534
+ uploadSlots = MAX_CONCURRENT_UPLOADS;
2535
+ uploadQueue = [];
2536
+ /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
2537
+ attachmentsUnsupported = false;
2538
+ // Progresso (ctx.progress): snapshot absoluto, enviado com throttle via piggyback
2539
+ // em `enqueue()` — nunca gera tráfego próprio enquanto logs/heartbeat já fluem.
2540
+ progressCurrent = 0;
2541
+ progressTotal = null;
2542
+ /** Snapshot ainda não entregue ao servidor. */
2543
+ progressDirty = false;
2544
+ progressTimer;
2545
+ progressWarned = false;
2546
+ /** Relógio do último eco `[hobots:progress]` no terminal (throttle do eco, não do wire). */
2547
+ progressLastPrint = 0;
2548
+ /** Arquivos do formulário desta run (`ctx.files`) — a pasta temporária é apagada no fim. */
2549
+ fileStore;
2550
+ /** Apaga as cópias de arquivo que ficaram na pasta temporária da run. Nunca lança. */
2551
+ cleanupFiles() {
2552
+ return this.fileStore.cleanup();
2470
2553
  }
2471
- }
2472
- var Step = class {
2473
- constructor(transaction, parentStepId, op, description) {
2474
- this.transaction = transaction;
2475
- this.parentStepId = parentStepId;
2476
- this.op = op;
2477
- this.description = description;
2554
+ get isDead() {
2555
+ return this.dead;
2556
+ }
2557
+ /** Lança `RunCanceled` se o signal desta run já disparou. */
2558
+ checkCanceled() {
2559
+ if (this.abortController.signal.aborted) throw runCanceledError();
2560
+ }
2561
+ /** Aborta o ctx.signal sem matar o reporter (timeout/shutdown ainda reportam o terminal). */
2562
+ abort() {
2563
+ this.stopCancelWatch();
2564
+ if (!this.abortController.signal.aborted) this.abortController.abort();
2478
2565
  }
2479
- transaction;
2480
- parentStepId;
2481
- op;
2482
- description;
2483
- stepId = stepId();
2484
- startTimestamp = nowISO();
2485
- status = "ok";
2486
- endTimestamp;
2487
- data;
2488
2566
  /**
2489
- * Desfecho da step. `message` (opcional) é o motivo legível — vai em `data.message`
2490
- * e aparece na Central (performance e banner da execução) e no log de FINISH.
2491
- * `options.image` pendura um print do estado da tela em `data.attachments`.
2567
+ * Mata o reporter de vez: aborta o signal e suprime todo reporte futuro.
2568
+ * Usado no exit path — depois que o agente reportou AgentTerminated por fora
2569
+ * da fila, um handler zumbi não pode reenviar status por cima.
2492
2570
  */
2493
- setStatus(status, message, options) {
2494
- this.status = status;
2495
- if (message) this.setData("message", stepMessage(message));
2496
- if (options?.image !== void 0) this.attachImage(options.image);
2497
- return this;
2571
+ kill() {
2572
+ this.abort();
2573
+ this.dead = true;
2574
+ this.clearFlushTimer();
2575
+ this.clearProgressTimer();
2576
+ this.buffer = [];
2577
+ this.itemsClosed = true;
2578
+ this.clearItemsTimer();
2579
+ if (this.itemsBuffer.length > 0) {
2580
+ this.logger.warn(`${this.itemsBuffer.length} item(ns) de execu\xE7\xE3o descartados (processo encerrando)`);
2581
+ this.itemsBuffer = [];
2582
+ }
2583
+ const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
2584
+ for (const resume of waiting) resume();
2498
2585
  }
2499
2586
  /**
2500
- * Marca a step como `error` guardando o motivo em `data.error` ({ type, message }) e a
2501
- * fecha. Aceita qualquer valor lançado; `message` sobrescreve a mensagem do erro.
2502
- * `options.image` pendura um print do estado da tela em `data.attachments`.
2503
- * Numa step já fechada não faz nada.
2587
+ * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
2588
+ * que descarta o buffer). Capado em um chunk — o exit path não tem retry nem fila,
2589
+ * então preserva os logs mais recentes, os mais valiosos na morte do processo.
2504
2590
  */
2505
- failed(error, message, options) {
2506
- if (this.endTimestamp) return this;
2507
- this.status = "error";
2508
- const type = error instanceof Error ? error.name : typeof error === "object" && error !== null ? "Error" : void 0;
2509
- const raw = message ?? (error instanceof Error ? error.message || error.name : String(error));
2510
- this.setData("error", { ...type ? { type } : {}, message: stepMessage(raw) });
2511
- if (options?.image !== void 0) this.attachImage(options.image);
2512
- this.close();
2513
- return this;
2591
+ drainLogs() {
2592
+ this.clearFlushTimer();
2593
+ return this.takeLogs().slice(-MAX_LOGS_PER_PATCH);
2514
2594
  }
2515
2595
  /**
2516
- * Dispara o upload pela run (sink do RunReporter) e referencia o anexo em
2517
- * `data.attachments` (chave reservada; teto igual ao das linhas de log). Fora de uma
2518
- * run não há onde pendurar a imagem — descarta com aviso único.
2596
+ * Linha de log da run com eco no terminal. `image` (opcional) vira o anexo da linha:
2597
+ * o id entra na entrada ANTES de qualquer I/O — é isso que mantém a ordem dos logs
2598
+ * intacta e o handler livre da latência do upload. Devolve o id do anexo (quando houve
2599
+ * imagem e ela não foi descartada) — o `captureException` o leva ao evento da issue.
2519
2600
  */
2520
- attachImage(image3) {
2521
- const current = Array.isArray(this.data?.attachments) ? this.data.attachments : [];
2522
- if (current.length >= MAX_ATTACHMENTS_PER_LOG_ENTRY) return;
2523
- const id = this.transaction.attachImage(image3);
2524
- if (!id) return;
2525
- this.setData("attachments", [...current, id]);
2601
+ log(level, message, image3) {
2602
+ if (this.dead) return void 0;
2603
+ const scrubbed = scrubSecrets(safeString(message));
2604
+ const ts = nowISO();
2605
+ const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2606
+ printRunLog(level, attachmentId ? `${scrubbed} [imagem]` : scrubbed);
2607
+ this.push(level, scrubbed, true, attachmentId ? [attachmentId] : void 0, ts);
2608
+ return attachmentId;
2526
2609
  }
2527
- /** Motivo registrado via setStatus(…, message) ou failed — usado no log de FINISH. */
2528
- reason() {
2529
- const error = this.data?.error;
2530
- const text = typeof error?.message === "string" ? error.message : this.data?.message;
2531
- return typeof text === "string" && text ? text : void 0;
2610
+ /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
2611
+ push(level, message, alreadyScrubbed = false, attachments, ts = nowISO()) {
2612
+ if (this.dead) return ts;
2613
+ const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
2614
+ this.buffer.push({
2615
+ ts,
2616
+ level,
2617
+ message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
2618
+ ...attachments && attachments.length > 0 ? { attachments: attachments.slice(0, WIRE_LIMITS.MAX_ATTACHMENTS_PER_LOG_ENTRY) } : {}
2619
+ });
2620
+ if (this.buffer.length >= FLUSH_AT_BUFFERED) {
2621
+ this.flushLogs();
2622
+ } else {
2623
+ this.scheduleFlush();
2624
+ }
2625
+ return ts;
2532
2626
  }
2533
- setData(key, value) {
2534
- this.data = { ...this.data, [key]: value };
2535
- return this;
2627
+ scheduleFlush() {
2628
+ if (this.timer) return;
2629
+ this.timer = setTimeout(() => {
2630
+ this.timer = void 0;
2631
+ this.flushLogs();
2632
+ }, this.flushIntervalMs);
2633
+ unrefTimer(this.timer);
2536
2634
  }
2537
- /** Step aninhada (filha desta step). */
2538
- start(op, description) {
2539
- return this.transaction.createStep(this.stepId, op, description);
2635
+ clearFlushTimer() {
2636
+ if (this.timer) {
2637
+ clearTimeout(this.timer);
2638
+ this.timer = void 0;
2639
+ }
2640
+ }
2641
+ takeLogs() {
2642
+ return this.buffer.splice(0, this.buffer.length);
2643
+ }
2644
+ /** Flush incremental: envia o buffer como status 'running' (em chunks). */
2645
+ flushLogs() {
2646
+ this.clearFlushTimer();
2647
+ if (this.dead || this.buffer.length === 0) return;
2648
+ const logs = this.buffer.splice(0, this.buffer.length);
2649
+ for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
2650
+ this.enqueue({ status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) });
2651
+ }
2540
2652
  }
2541
2653
  /**
2542
- * Fecha a step. O status fica o que estiver (`ok` por padrão, ou o de `setStatus`).
2543
- * Idempotente; a transaction fecha no fim da run o que ficou aberto.
2654
+ * `hobots.flush()`: o bot decide quando entregar o que já fez — etapas raiz finalizadas
2655
+ * viram transactions parciais da run, e itens, logs e progresso pendentes saem já, sem
2656
+ * esperar o throttle. Aguarda a entrega com teto. Nunca lança (pode ir num `finally`).
2544
2657
  */
2545
- succeeded() {
2546
- this.close();
2547
- return this;
2658
+ async flush(timeoutMs = ITEMS_DRAIN_MS) {
2659
+ this.tx.flush();
2660
+ this.flushItems();
2661
+ if (this.progressDirty) this.flushProgress();
2662
+ else this.flushLogs();
2663
+ await Promise.race([
2664
+ Promise.all([this.itemsChain, this.chain, this.tx.drain(timeoutMs)]).catch(() => void 0),
2665
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2666
+ ]);
2548
2667
  }
2549
- close() {
2550
- if (this.endTimestamp) return;
2551
- this.endTimestamp = nowISO();
2552
- this.transaction.emit(
2553
- "step",
2554
- "finish",
2555
- stepLabel(this.op, this.description),
2556
- this.startTimestamp,
2557
- this.endTimestamp,
2558
- this.status,
2559
- this.reason()
2560
- );
2668
+ /** Primeiro PATCH (claimed → running). Retorna false se a run já morreu (cancelada). */
2669
+ async reportRunning() {
2670
+ this.enqueue({ status: "running" });
2671
+ await this.chain;
2672
+ if (!this.dead) this.startCancelWatch();
2673
+ return !this.dead;
2561
2674
  }
2562
- toJSON() {
2563
- return {
2564
- step_id: this.stepId,
2565
- parent_step_id: this.parentStepId,
2566
- op: this.op,
2567
- description: this.description,
2568
- status: this.status,
2569
- start_timestamp: this.startTimestamp,
2570
- timestamp: this.endTimestamp ?? nowISO(),
2571
- data: this.data
2572
- };
2675
+ reportSuccess() {
2676
+ this.stopCancelWatch();
2677
+ this.clearFlushTimer();
2678
+ this.forceFinalProgress();
2679
+ this.enqueue({ status: "succeeded", logs: this.takeLogsForTerminal() });
2573
2680
  }
2574
- };
2575
- var Transaction = class {
2576
- constructor(client, name, op, runId) {
2577
- this.client = client;
2578
- this.name = name;
2579
- this.op = op;
2580
- this.runId = runId;
2581
- this.emit("transaction", "start", op ? `${name} (${op})` : name);
2681
+ reportFailure(error) {
2682
+ this.stopCancelWatch();
2683
+ this.clearFlushTimer();
2684
+ this.forceFinalProgress();
2685
+ this.enqueue({ status: "failed", error, logs: this.takeLogsForTerminal() });
2582
2686
  }
2583
- client;
2584
- name;
2585
- op;
2586
- runId;
2587
- eventId = uuid4();
2588
- stepId = stepId();
2589
- startTimestamp = nowISO();
2590
- status = "ok";
2591
- steps = [];
2592
- tags = {};
2593
- finished = false;
2594
- lifecycleSink;
2595
- attachmentSink;
2596
- warnedNoAttachmentSink = false;
2597
- // instalado pelo RunReporter: abrir uma step é um ponto de parada cooperativo (lança RunCanceled)
2598
- stepGuard;
2599
- // eventos emitidos antes do sink existir (o START da transaction sai no construtor,
2600
- // antes do RunReporter conectar) — replay no setLifecycleSink
2601
- lifecycleBacklog = [];
2602
2687
  /**
2603
- * Registra o início/fim de uma step ou transaction: espelha a linha no sink de
2604
- * lifecycle, se conectado (é assim que os eventos de performance aparecem nos
2605
- * logs da run no app), e ecoa no terminal apenas com `debug: true` — o SDK não
2606
- * escreve no stdout do processo do usuário sem permissão.
2688
+ * Esvazia o buffer para o PATCH terminal respeitando o teto de 100 logs por PATCH:
2689
+ * o excedente sai antes, em PATCHes `running` na mesma cadeia (a ordem se mantém).
2607
2690
  */
2608
- emit(kind, phase, label, startTs, endTs, status, reason) {
2609
- const durationMs = startTs && endTs ? new Date(endTs).getTime() - new Date(startTs).getTime() : void 0;
2610
- const suffix = (durationMs != null ? ` (${durationMs}ms)` : "") + (reason ? `: ${reason}` : "");
2611
- if (this.client?.debug) {
2612
- const tag = colorize(`[hobots:${kind}] ${phase.toUpperCase()}`, phase === "finish" ? colorForStatus(status ?? "ok") : "cyan");
2613
- console.log(`${tag} ${label}${suffix}`);
2691
+ takeLogsForTerminal() {
2692
+ let logs = this.takeLogs();
2693
+ while (logs.length > MAX_LOGS_PER_PATCH) {
2694
+ this.enqueue({ status: "running", logs: logs.slice(0, MAX_LOGS_PER_PATCH) });
2695
+ logs = logs.slice(MAX_LOGS_PER_PATCH);
2614
2696
  }
2615
- const level = phase === "finish" ? levelForStatus(status ?? "ok") : "info";
2616
- const message = `[${kind}] ${phase.toUpperCase()} ${label}${suffix}`;
2617
- if (this.lifecycleSink) this.lifecycleSink(level, message);
2618
- else if (this.lifecycleBacklog.length < 100) this.lifecycleBacklog.push([level, message]);
2697
+ return logs;
2619
2698
  }
2620
- /** Conecta o espelho de lifecycle (RunReporter) e faz replay do backlog em ordem. */
2621
- setLifecycleSink(sink) {
2622
- this.lifecycleSink = sink;
2623
- const backlog = this.lifecycleBacklog;
2624
- this.lifecycleBacklog = [];
2625
- for (const [level, message] of backlog) sink(level, message);
2699
+ // ── Progresso ────────────────────────────────────────────────────────────────
2700
+ /** Valida, clampa e marca o snapshot como pendente; agenda flush só se nada mais vai sair. */
2701
+ setProgress(current, total) {
2702
+ if (this.dead) return;
2703
+ if (total != null && (!Number.isInteger(total) || total < 1)) {
2704
+ if (!this.progressWarned) {
2705
+ this.progressWarned = true;
2706
+ this.logger.warn(`ctx.progress: total inv\xE1lido (${String(total)}) \u2014 precisa ser inteiro >= 1; ignorado`);
2707
+ }
2708
+ return;
2709
+ }
2710
+ const safeCurrent = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : this.progressCurrent;
2711
+ const next = total == null ? safeCurrent : Math.min(safeCurrent, total);
2712
+ const totalChanged = total !== this.progressTotal;
2713
+ if (next === this.progressCurrent && !totalChanged) return;
2714
+ this.progressCurrent = next;
2715
+ this.progressTotal = total;
2716
+ if (total == null) return;
2717
+ const now = Date.now();
2718
+ if (totalChanged || next >= total || now - this.progressLastPrint >= PROGRESS_PRINT_THROTTLE_MS) {
2719
+ this.progressLastPrint = now;
2720
+ printRunProgress(next, total);
2721
+ }
2722
+ this.progressDirty = true;
2723
+ if (this.progressTimer) return;
2724
+ this.progressTimer = setTimeout(() => {
2725
+ this.progressTimer = void 0;
2726
+ this.flushProgress();
2727
+ }, PROGRESS_FLUSH_MS);
2728
+ unrefTimer(this.progressTimer);
2626
2729
  }
2627
- /** Conecta o pipeline de anexos da run — é o que dá imagem às steps. */
2628
- setAttachmentSink(sink) {
2629
- this.attachmentSink = sink;
2730
+ flushProgress() {
2731
+ this.clearProgressTimer();
2732
+ if (this.dead || !this.progressDirty) return;
2733
+ if (this.buffer.length > 0) this.flushLogs();
2734
+ else this.enqueue({ status: "running" });
2630
2735
  }
2631
- /** Imagem de uma step → id do anexo pela run; sem run, descarta com aviso único. */
2632
- attachImage(image3) {
2633
- if (this.attachmentSink) return this.attachmentSink(image3, nowISO());
2634
- if (!this.warnedNoAttachmentSink) {
2635
- this.warnedNoAttachmentSink = true;
2636
- console.warn("[hobots] imagem de step descartada \u2014 s\xF3 \xE9 suportada dentro de uma execu\xE7\xE3o");
2736
+ clearProgressTimer() {
2737
+ if (this.progressTimer) {
2738
+ clearTimeout(this.progressTimer);
2739
+ this.progressTimer = void 0;
2637
2740
  }
2638
- return void 0;
2741
+ }
2742
+ /** O PATCH terminal sempre leva o snapshot final (o app mostra "87/100" numa run que falhou). */
2743
+ forceFinalProgress() {
2744
+ this.clearProgressTimer();
2745
+ this.progressDirty = this.progressTotal != null;
2746
+ }
2747
+ /** Consome o snapshot pendente para pegar carona no próximo PATCH. */
2748
+ takeProgress() {
2749
+ if (!this.progressDirty || this.progressTotal == null) return void 0;
2750
+ this.progressDirty = false;
2751
+ return { current: this.progressCurrent, total: this.progressTotal };
2639
2752
  }
2640
2753
  /**
2641
- * Conecta a checagem de cancelamento chamada antes de cada step nova — é assim que
2642
- * `hobots.transaction.start()` (e `step.start()`) param uma run cancelada.
2754
+ * Heartbeat de cancelamento: enquanto o handler roda, um PATCH 'running' vazio
2755
+ * periódico faz o servidor responder 409 se a run foi cancelada no app — o que
2756
+ * mata o reporter e dispara ctx.signal, mesmo em handlers que nunca logam.
2643
2757
  */
2644
- setStepGuard(guard) {
2645
- this.stepGuard = guard;
2758
+ startCancelWatch() {
2759
+ if (this.watchTimer) return;
2760
+ this.watchTimer = setInterval(() => {
2761
+ if (this.dead || this.pendingUpdates > 0 || this.buffer.length > 0) return;
2762
+ this.enqueue({ status: "running" });
2763
+ }, this.cancelCheckIntervalMs);
2764
+ unrefTimer(this.watchTimer);
2646
2765
  }
2647
- createStep(parentStepId, op, description) {
2648
- this.stepGuard?.();
2649
- const step = new Step(this, parentStepId, op, description);
2650
- this.steps.push(step);
2651
- this.emit("step", "start", stepLabel(op, description));
2652
- return step;
2766
+ stopCancelWatch() {
2767
+ if (this.watchTimer) {
2768
+ clearInterval(this.watchTimer);
2769
+ this.watchTimer = void 0;
2770
+ }
2653
2771
  }
2654
- /** Step direta da transaction. `op` é o nome usado por `succeeded(name)`/`failed(error, name)`. */
2655
- start(op, description) {
2656
- return this.createStep(this.stepId, op, description);
2772
+ /** Aguarda a fila de PATCHes esvaziar (chame depois do terminal). */
2773
+ finalize() {
2774
+ return this.chain;
2657
2775
  }
2776
+ // ── Itens de execução ────────────────────────────────────────────────────────
2658
2777
  /**
2659
- * Fecha a step aberta mais recente — com `name`, a mais recente aberta com esse `op`.
2660
- * Sem step aberta (ou sem nenhuma com esse nome), não faz nada e devolve undefined.
2778
+ * Bufferiza um item (síncrono — a chamada nunca espera rede). Diferente do PATCH
2779
+ * de status, o servidor aceita itens mesmo com a run cancelada (trabalho já feito),
2780
+ * então um reporter morto não descarta: só um kill() de exit path descarta.
2661
2781
  */
2662
- succeeded(name) {
2663
- return this.findOpenStep(name)?.succeeded();
2782
+ addItem(status, message, payload, id, image3) {
2783
+ if (this.itemsClosed || this.itemsUnsupported) return;
2784
+ let scrubbedPayload;
2785
+ if (payload) {
2786
+ scrubbedPayload = scrubPayloadValue(payload, 0);
2787
+ let serializedLength;
2788
+ try {
2789
+ serializedLength = JSON.stringify(scrubbedPayload).length;
2790
+ } catch {
2791
+ }
2792
+ if (serializedLength === void 0 || serializedLength > MAX_ITEM_PAYLOAD_BYTES) {
2793
+ this.logger.warn(
2794
+ `payload do item descartado (${serializedLength === void 0 ? "n\xE3o serializ\xE1vel" : `${serializedLength} bytes > ${MAX_ITEM_PAYLOAD_BYTES}`}) \u2014 o item foi mantido`
2795
+ );
2796
+ scrubbedPayload = void 0;
2797
+ }
2798
+ }
2799
+ const ts = nowISO();
2800
+ const attachmentId = image3 === void 0 ? void 0 : this.queueAttachment(image3, ts);
2801
+ this.itemsBuffer.push({
2802
+ id: id ? truncate(String(id), 255) : uuid4(),
2803
+ seq: this.itemSeq++,
2804
+ status,
2805
+ ...message ? { message: truncate(scrubSecrets(safeString(message)), MAX_ITEM_MESSAGE_CHARS) } : {},
2806
+ ...scrubbedPayload ? { payload: scrubbedPayload } : {},
2807
+ ts,
2808
+ ...attachmentId ? { attachments: [attachmentId] } : {}
2809
+ });
2810
+ if (this.itemsBuffer.length >= ITEMS_FLUSH_AT_BUFFERED) {
2811
+ this.flushItems();
2812
+ } else {
2813
+ this.scheduleItemsFlush();
2814
+ }
2664
2815
  }
2665
2816
  /**
2666
- * Marca como `error` e fecha a step aberta mais recente — com `name`, a mais recente
2667
- * aberta com esse `op`. `options.message` sobrescreve a mensagem do erro e
2668
- * `options.image` pendura um print. Sem step aberta, não faz nada e devolve undefined.
2817
+ * Ids já concluídos (`succeeded`/`occurrence`) neste trabalho — a run e suas reexecuções.
2818
+ * Pagina até o fim. undefined quando a consulta falha (servidor antigo, rede): o `each`
2819
+ * avisa e processa tudo, nunca trava a run por isso.
2669
2820
  */
2670
- failed(error, name, options) {
2671
- return this.findOpenStep(name)?.failed(error, options?.message, options);
2672
- }
2673
- findOpenStep(name) {
2674
- for (let i = this.steps.length - 1; i >= 0; i--) {
2675
- const step = this.steps[i];
2676
- if (!step.endTimestamp && (name === void 0 || step.op === name)) return step;
2821
+ async listDoneItemIds() {
2822
+ const ids = /* @__PURE__ */ new Set();
2823
+ let cursor = null;
2824
+ try {
2825
+ do {
2826
+ const page = await this.api.listDoneItems(this.runId, {
2827
+ limit: MAX_ITEMS_DONE_PER_PAGE,
2828
+ ...cursor ? { cursor } : {}
2829
+ });
2830
+ for (const id of page.ids) ids.add(id);
2831
+ cursor = page.next;
2832
+ } while (cursor);
2833
+ return ids;
2834
+ } catch (error) {
2835
+ this.log("warning", `n\xE3o foi poss\xEDvel consultar os itens j\xE1 conclu\xEDdos deste trabalho \u2014 todos ser\xE3o processados (${thrownMessage(error)})`);
2836
+ return void 0;
2677
2837
  }
2678
- return void 0;
2679
2838
  }
2680
- setTag(key, value) {
2681
- this.tags[key] = value;
2682
- return this;
2839
+ scheduleItemsFlush() {
2840
+ if (this.itemsTimer) return;
2841
+ this.itemsTimer = setTimeout(() => {
2842
+ this.itemsTimer = void 0;
2843
+ this.flushItems();
2844
+ }, this.flushIntervalMs);
2845
+ unrefTimer(this.itemsTimer);
2683
2846
  }
2684
- setStatus(status) {
2685
- this.status = status;
2686
- return this;
2847
+ clearItemsTimer() {
2848
+ if (this.itemsTimer) {
2849
+ clearTimeout(this.itemsTimer);
2850
+ this.itemsTimer = void 0;
2851
+ }
2687
2852
  }
2688
- setName(name) {
2689
- this.name = name;
2690
- return this;
2853
+ /** Envia o buffer de itens em lotes serializados na cadeia própria. */
2854
+ flushItems() {
2855
+ this.clearItemsTimer();
2856
+ if (this.itemsBuffer.length === 0 || this.itemsUnsupported) return;
2857
+ const items = this.itemsBuffer.splice(0, this.itemsBuffer.length);
2858
+ for (let i = 0; i < items.length; i += MAX_ITEMS_PER_REQUEST) {
2859
+ const batch = items.slice(i, i + MAX_ITEMS_PER_REQUEST);
2860
+ this.itemsChain = this.itemsChain.then(() => this.sendItems(batch));
2861
+ }
2862
+ }
2863
+ async sendItems(batch) {
2864
+ if (this.itemsUnsupported) return;
2865
+ try {
2866
+ await this.api.createItems(this.runId, { items: batch });
2867
+ this.itemsStorageFull = false;
2868
+ } catch (error) {
2869
+ if (error instanceof TasksApiError && error.status === 404) {
2870
+ this.itemsUnsupported = true;
2871
+ this.logger.warn("itens de execu\xE7\xE3o n\xE3o s\xE3o suportados por este servidor Hobots \u2014 descartados");
2872
+ return;
2873
+ }
2874
+ if (error instanceof TasksApiError && error.status === 429) {
2875
+ if (!this.itemsQuotaExceeded) {
2876
+ this.itemsQuotaExceeded = true;
2877
+ this.log("warning", "itens descartados: limite de itens por execu\xE7\xE3o atingido no servidor");
2878
+ }
2879
+ return;
2880
+ }
2881
+ if (error instanceof TasksApiError && error.status === 402) {
2882
+ if (!this.itemsStorageFull) {
2883
+ this.itemsStorageFull = true;
2884
+ this.log("warning", "itens descartados: armazenamento do plano cheio \u2014 libere espa\xE7o ou fa\xE7a upgrade em Plano e cobran\xE7a");
2885
+ }
2886
+ return;
2887
+ }
2888
+ if (error instanceof TasksApiError) {
2889
+ this.logger.error(`lote de ${batch.length} item(ns) rejeitado pelo servidor:`, error);
2890
+ this.log("warning", `${batch.length} item(ns) descartado(s): envio rejeitado pelo servidor (HTTP ${error.status})`);
2891
+ return;
2892
+ }
2893
+ this.itemsBuffer.unshift(...batch);
2894
+ this.scheduleItemsFlush();
2895
+ this.logger.error(`lote de ${batch.length} item(ns) n\xE3o enviado (devolvido ao buffer):`, error);
2896
+ }
2691
2897
  }
2692
2898
  /**
2693
- * Envia já as steps raiz finalizadas (e todas as descendentes, fechando as que
2694
- * ficaram abertas) como transactions parciais da mesma run (`op` hobots.run.flush,
2695
- * `event_id` novo, até 200 steps cada) e as tira da memória. Steps raiz ainda abertas
2696
- * ficam para o próximo flush ou para o `finish()`. É assim que um agente sempre
2697
- * ligado (`start()`), cuja run não termina, entrega as etapas enquanto processa.
2698
- * Devolve quantas steps foram enviadas.
2899
+ * Dreno final de itens antes do PATCH terminal (com teto): garante que os
2900
+ * totalizadores estejam completos quando a run vira succeeded/failed. Como o
2901
+ * servidor aceita itens tardios, perder a corrida aqui não perde dados.
2699
2902
  */
2700
- flush() {
2701
- if (this.finished) return 0;
2702
- const ready = /* @__PURE__ */ new Set();
2703
- for (const step of this.steps) {
2704
- if (step.parentStepId === this.stepId && step.endTimestamp) ready.add(step.stepId);
2903
+ async finalizeItems(timeoutMs = ITEMS_DRAIN_MS) {
2904
+ this.flushItems();
2905
+ await Promise.race([
2906
+ this.itemsChain,
2907
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2908
+ ]);
2909
+ if (this.itemsBuffer.length > 0 && !this.itemsUnsupported && !this.itemsQuotaExceeded) {
2910
+ this.flushItems();
2911
+ await Promise.race([
2912
+ this.itemsChain,
2913
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
2914
+ ]);
2705
2915
  }
2706
- if (ready.size === 0) return 0;
2707
- const batch = [];
2708
- const remaining = [];
2709
- for (const step of this.steps) {
2710
- if (ready.has(step.stepId) || ready.has(step.parentStepId)) {
2711
- ready.add(step.stepId);
2712
- batch.push(step);
2713
- } else {
2714
- remaining.push(step);
2916
+ }
2917
+ // ── Anexos ───────────────────────────────────────────────────────────────────
2918
+ /**
2919
+ * Pendura uma imagem num evento da run (log, item ou etapa): faz as checagens que não
2920
+ * dependem de I/O (reporter morto, instalação sem anexos, cota), reserva o id e dispara
2921
+ * o upload em segundo plano. Devolve o id — já referenciável pelo evento — ou undefined
2922
+ * quando a imagem foi descartada (sempre com aviso no log da run). Nunca lança: todo
2923
+ * caminho de erro do upload vira aviso. O hint `attachment` de `hobots.captureException`
2924
+ * também cai aqui via `log()`.
2925
+ */
2926
+ queueAttachment(imageIn, ts) {
2927
+ if (this.dead) return void 0;
2928
+ const image3 = toImageAttachment(imageIn);
2929
+ if (this.attachmentsUnsupported) return void 0;
2930
+ if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
2931
+ if (this.attachmentCount === MAX_ATTACHMENTS_PER_RUN) {
2932
+ this.attachmentCount++;
2933
+ this.log("warning", `imagem descartada: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
2715
2934
  }
2935
+ return void 0;
2716
2936
  }
2717
- for (const step of batch) step.succeeded();
2718
- this.steps.splice(0, this.steps.length, ...remaining);
2719
- if (this.client) {
2720
- for (let i = 0; i < batch.length; i += MAX_STEPS) {
2721
- const chunk = batch.slice(i, i + MAX_STEPS);
2722
- const startTimestamp = chunk.reduce((min, step) => step.startTimestamp < min ? step.startTimestamp : min, chunk[0].startTimestamp);
2723
- const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2724
- this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, startTimestamp, nowISO(), chunk));
2937
+ this.attachmentCount++;
2938
+ const id = uuid4();
2939
+ const rawCaption = image3.caption !== void 0 ? safeString(image3.caption) : void 0;
2940
+ const caption = rawCaption ? truncate(scrubSecrets(rawCaption), MAX_CAPTION_CHARS) : void 0;
2941
+ const work = this.uploadAttachment(id, image3.input, { caption, logTs: ts, contentType: image3.contentType }).then(() => void 0).catch(() => void 0);
2942
+ this.pendingAttachments.add(work);
2943
+ void work.finally(() => this.pendingAttachments.delete(work));
2944
+ return id;
2945
+ }
2946
+ /** Retorna o motivo da falha, ou undefined em sucesso. */
2947
+ async uploadAttachment(id, image3, meta) {
2948
+ let bytes;
2949
+ try {
2950
+ bytes = await toBytes(image3);
2951
+ } catch (error) {
2952
+ this.log("warning", `imagem n\xE3o p\xF4de ser lida: ${String(error)}`);
2953
+ return "read-failed";
2954
+ }
2955
+ const contentType = sniffContentType(bytes) ?? meta.contentType;
2956
+ if (!contentType) {
2957
+ this.log("warning", "imagem descartada: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
2958
+ return "unsupported-format";
2959
+ }
2960
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
2961
+ this.log(
2962
+ "warning",
2963
+ `imagem descartada: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
2964
+ );
2965
+ return "too-large";
2966
+ }
2967
+ const dimensions = readPngDimensions(bytes);
2968
+ const release = await this.acquireUploadSlot();
2969
+ try {
2970
+ if (this.abortController.signal.aborted || this.dead) return "canceled";
2971
+ const created = await this.api.createAttachment(
2972
+ this.runId,
2973
+ {
2974
+ id,
2975
+ content_type: contentType,
2976
+ size_bytes: bytes.byteLength,
2977
+ log_ts: meta.logTs,
2978
+ ...meta.caption ? { caption: meta.caption } : {},
2979
+ ...dimensions ?? {}
2980
+ },
2981
+ this.abortController.signal
2982
+ );
2983
+ await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
2984
+ await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
2985
+ });
2986
+ return void 0;
2987
+ } catch (error) {
2988
+ if (this.isUnsupported(error)) {
2989
+ this.attachmentsUnsupported = true;
2990
+ this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
2991
+ return "unsupported";
2992
+ }
2993
+ if (error instanceof TasksApiError && error.status === 429) {
2994
+ this.log("warning", "imagem descartada: limite de anexos por execu\xE7\xE3o atingido no servidor");
2995
+ return "quota";
2725
2996
  }
2997
+ if (this.abortController.signal.aborted) return "canceled";
2998
+ this.log("warning", `falha ao enviar a imagem: ${String(error)}`);
2999
+ void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
3000
+ });
3001
+ return "upload-failed";
3002
+ } finally {
3003
+ release();
3004
+ }
3005
+ }
3006
+ /** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
3007
+ isUnsupported(error) {
3008
+ return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
3009
+ }
3010
+ /** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
3011
+ acquireUploadSlot() {
3012
+ const release = () => {
3013
+ const next = this.uploadQueue.shift();
3014
+ if (next) next();
3015
+ else this.uploadSlots++;
3016
+ };
3017
+ if (this.uploadSlots > 0) {
3018
+ this.uploadSlots--;
3019
+ return Promise.resolve(release);
2726
3020
  }
2727
- return batch.length;
3021
+ return new Promise((resolve2) => {
3022
+ this.uploadQueue.push(() => resolve2(release));
3023
+ });
2728
3024
  }
2729
- /** Entrega já (com teto) o que está na fila do transport — as transactions parciais inclusive. */
2730
- async drain(timeoutMs) {
2731
- await this.client?.flush(timeoutMs);
3025
+ /**
3026
+ * Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
3027
+ * exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
3028
+ */
3029
+ async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
3030
+ if (this.pendingAttachments.size === 0) return;
3031
+ await Promise.race([
3032
+ Promise.allSettled([...this.pendingAttachments]),
3033
+ new Promise((resolve2) => unrefTimer(setTimeout(resolve2, timeoutMs)))
3034
+ ]);
2732
3035
  }
2733
- payload(eventId, op, status, startTimestamp, timestamp, steps) {
2734
- return {
2735
- event_id: eventId,
2736
- name: this.name,
2737
- op,
2738
- status,
2739
- start_timestamp: startTimestamp,
2740
- timestamp,
2741
- steps: steps.map((step) => step.toJSON()),
2742
- tags: Object.keys(this.tags).length > 0 ? { ...this.tags } : void 0,
2743
- run_id: this.runId
2744
- };
3036
+ enqueue(updateIn) {
3037
+ if (this.dead) {
3038
+ this.salvageLogs(updateIn.logs);
3039
+ return;
3040
+ }
3041
+ this.clearProgressTimer();
3042
+ const progress = this.takeProgress();
3043
+ const update = progress ? { ...updateIn, progress } : updateIn;
3044
+ this.pendingUpdates++;
3045
+ this.chain = this.chain.then(async () => {
3046
+ if (this.dead) {
3047
+ this.salvageLogs(update.logs);
3048
+ return;
3049
+ }
3050
+ const payload = update.logs && update.logs.length === 0 ? { ...update, logs: void 0 } : update;
3051
+ try {
3052
+ await this.api.updateRun(this.runId, payload);
3053
+ } catch (error) {
3054
+ if (error instanceof TasksApiError && (error.status === 409 || error.status === 404)) {
3055
+ this.dead = true;
3056
+ this.stopCancelWatch();
3057
+ this.abort();
3058
+ const pending = this.buffer.splice(0, this.buffer.length);
3059
+ this.salvageLogs(pending);
3060
+ this.logger.warn(
3061
+ `run ${this.runId} cancelada/finalizada no servidor \u2014 status suprimido` + (pending.length > 0 ? `, ${pending.length} log(s) enviados como log tardio` : "")
3062
+ );
3063
+ return;
3064
+ }
3065
+ if (update.logs && update.logs.length > 0) {
3066
+ this.buffer.unshift(...update.logs);
3067
+ this.scheduleFlush();
3068
+ }
3069
+ if (update.progress && this.progressTotal != null) this.progressDirty = true;
3070
+ this.logger.error(`update da run ${this.runId} perdido:`, error);
3071
+ }
3072
+ }).finally(() => {
3073
+ this.pendingUpdates--;
3074
+ });
2745
3075
  }
2746
- finish() {
2747
- if (this.finished) return;
2748
- this.finished = true;
2749
- for (const step of this.steps) step.succeeded();
2750
- const endTimestamp = nowISO();
2751
- this.emit(
2752
- "transaction",
2753
- "finish",
2754
- this.op ? `${this.name} (${this.op})` : this.name,
2755
- this.startTimestamp,
2756
- endTimestamp,
2757
- this.status
2758
- );
2759
- if (!this.client) return;
2760
- const steps = this.steps;
2761
- let first = 0;
2762
- while (steps.length - first > MAX_STEPS) {
2763
- const chunk = steps.slice(first, first + MAX_STEPS);
2764
- const status = chunk.some((step) => step.status === "error") ? "error" : "ok";
2765
- this.client.sendTransaction(this.payload(uuid4(), FLUSH_TRANSACTION_OP, status, chunk[0].startTimestamp, endTimestamp, chunk));
2766
- first += MAX_STEPS;
3076
+ /**
3077
+ * Best-effort de log tardio: envia logs para uma run já terminal, fora da fila.
3078
+ * O servidor anexa os logs mesmo respondendo 409; erros são engolidos — este é
3079
+ * o último recurso, não um canal confiável.
3080
+ */
3081
+ salvageLogs(logs) {
3082
+ if (!logs || logs.length === 0) return;
3083
+ for (let i = 0; i < logs.length; i += MAX_LOGS_PER_PATCH) {
3084
+ void this.api.updateRunOnce(this.runId, { status: "running", logs: logs.slice(i, i + MAX_LOGS_PER_PATCH) }).catch(() => {
3085
+ });
2767
3086
  }
2768
- this.client.sendTransaction(this.payload(this.eventId, this.op, this.status, this.startTimestamp, endTimestamp, steps.slice(first)));
2769
3087
  }
2770
3088
  };
2771
3089
 
@@ -2774,7 +3092,7 @@ var DEFAULT_RETRY_DELAY_MS = 5e3;
2774
3092
  async function executeRun(input) {
2775
3093
  const { run, handler, api, client, logger, tracker } = input;
2776
3094
  const options = handler.options;
2777
- const tx = new Transaction(client, "run", "hobots.run", run.id);
3095
+ const tx = new Transaction(client, "run", "hobots.run", run.id, { echo: !client });
2778
3096
  const reporter = new RunReporter(
2779
3097
  api,
2780
3098
  run.id,
@@ -3014,42 +3332,26 @@ var TaskAgent = class {
3014
3332
  }
3015
3333
  };
3016
3334
 
3017
- // src/tasks/attached.ts
3018
- var START_RETRY_DELAYS_MS = [1e3, 4e3, 16e3];
3019
- function isTransient(error) {
3020
- if (!(error instanceof TasksApiError)) return true;
3021
- return error.status >= 500 || error.status === 429;
3022
- }
3023
- function describeRejection(runId, error) {
3024
- if (error instanceof TasksApiError && error.status === 404) {
3025
- return `run ${runId} n\xE3o existe na plataforma (404) \u2014 nada a executar`;
3026
- }
3027
- if (error instanceof TasksApiError && error.status === 409) {
3028
- return `run ${runId} n\xE3o pode ser iniciada (409: cancelada, j\xE1 iniciada ou de outra inst\xE2ncia) \u2014 nada a executar`;
3029
- }
3030
- return `n\xE3o foi poss\xEDvel iniciar a run ${runId} (${String(error)}) \u2014 nada a executar`;
3031
- }
3032
- var AttachedRunner = class {
3033
- constructor(runId, handler, deps) {
3034
- this.runId = runId;
3335
+ // src/tasks/single-run.ts
3336
+ var SingleRunRunner = class {
3337
+ constructor(handler, deps) {
3035
3338
  this.handler = handler;
3036
3339
  this.deps = deps;
3037
3340
  this.done = new Promise((resolve2) => {
3038
3341
  this.resolveDone = resolve2;
3039
3342
  });
3040
3343
  }
3041
- runId;
3042
3344
  handler;
3043
3345
  deps;
3044
3346
  abort = new AbortController();
3045
- /** Resolve quando a run terminou (ou não pôde ser iniciada, ou o runner foi parado antes). */
3347
+ /** Resolve quando a run terminou (ou não pôde começar, ou o runner foi parado antes). */
3046
3348
  done;
3047
3349
  resolveDone;
3048
3350
  async run() {
3049
3351
  try {
3050
- const run = await this.startRun();
3352
+ const run = await this.openRun();
3051
3353
  if (!run) return;
3052
- this.deps.logger.debug(`run ${run.id} iniciada (HOBOTS_RUN_ID)`);
3354
+ this.deps.logger.debug(this.opened(run));
3053
3355
  await executeRun({
3054
3356
  run,
3055
3357
  handler: this.handler,
@@ -3064,11 +3366,37 @@ var AttachedRunner = class {
3064
3366
  this.resolveDone();
3065
3367
  }
3066
3368
  }
3067
- /** Desiste de iniciar a run (close() antes da plataforma responder). Uma run já iniciada segue pelo tracker. */
3369
+ /** Desiste de obter a run (close() antes dela existir). Uma run já em execução segue pelo tracker. */
3068
3370
  stop() {
3069
3371
  this.abort.abort();
3070
3372
  }
3071
- async startRun() {
3373
+ };
3374
+
3375
+ // src/tasks/attached.ts
3376
+ var START_RETRY_DELAYS_MS = [1e3, 4e3, 16e3];
3377
+ function isTransient(error) {
3378
+ if (!(error instanceof TasksApiError)) return true;
3379
+ return error.status >= 500 || error.status === 429;
3380
+ }
3381
+ function describeRejection(runId, error) {
3382
+ if (error instanceof TasksApiError && error.status === 404) {
3383
+ return `run ${runId} n\xE3o existe na plataforma (404) \u2014 nada a executar`;
3384
+ }
3385
+ if (error instanceof TasksApiError && error.status === 409) {
3386
+ return `run ${runId} n\xE3o pode ser iniciada (409: cancelada, j\xE1 iniciada ou de outra inst\xE2ncia) \u2014 nada a executar`;
3387
+ }
3388
+ return `n\xE3o foi poss\xEDvel iniciar a run ${runId} (${String(error)}) \u2014 nada a executar`;
3389
+ }
3390
+ var AttachedRunner = class extends SingleRunRunner {
3391
+ constructor(runId, handler, deps) {
3392
+ super(handler, deps);
3393
+ this.runId = runId;
3394
+ }
3395
+ runId;
3396
+ opened(run) {
3397
+ return `run ${run.id} iniciada (HOBOTS_RUN_ID)`;
3398
+ }
3399
+ async openRun() {
3072
3400
  for (let attempt = 0; ; attempt++) {
3073
3401
  if (this.abort.signal.aborted) return void 0;
3074
3402
  try {
@@ -3131,46 +3459,16 @@ function normalizeItemColumns(input, logger) {
3131
3459
  }
3132
3460
 
3133
3461
  // src/tasks/local.ts
3134
- var LocalRunner = class {
3462
+ var LocalRunner = class extends SingleRunRunner {
3463
+ backoff;
3135
3464
  constructor(handler, deps) {
3136
- this.handler = handler;
3137
- this.deps = deps;
3465
+ super(handler, deps);
3138
3466
  this.backoff = new Backoff(deps.logger, "cria\xE7\xE3o da run");
3139
- this.done = new Promise((resolve2) => {
3140
- this.resolveDone = resolve2;
3141
- });
3142
- }
3143
- handler;
3144
- deps;
3145
- abort = new AbortController();
3146
- backoff;
3147
- /** Resolve quando a run terminou (ou o runner foi parado antes de abri-la). */
3148
- done;
3149
- resolveDone;
3150
- async run() {
3151
- try {
3152
- const run = await this.createRun();
3153
- if (!run) return;
3154
- this.deps.logger.debug(`run ${run.id} aberta (modo start)`);
3155
- await executeRun({
3156
- run,
3157
- handler: this.handler,
3158
- api: this.deps.api,
3159
- client: this.deps.client,
3160
- logger: this.deps.logger,
3161
- tracker: this.deps.tracker
3162
- });
3163
- } catch (error) {
3164
- this.deps.logger.error("erro inesperado na execu\xE7\xE3o do agente:", error);
3165
- } finally {
3166
- this.resolveDone();
3167
- }
3168
3467
  }
3169
- /** Desiste de abrir a run (close() antes da plataforma responder). Uma run já aberta segue pelo tracker. */
3170
- stop() {
3171
- this.abort.abort();
3468
+ opened(run) {
3469
+ return `run ${run.id} aberta (modo start)`;
3172
3470
  }
3173
- async createRun() {
3471
+ async openRun() {
3174
3472
  for (; ; ) {
3175
3473
  if (this.abort.signal.aborted) return void 0;
3176
3474
  try {
@@ -3183,6 +3481,133 @@ var LocalRunner = class {
3183
3481
  }
3184
3482
  };
3185
3483
 
3484
+ // src/tasks/local-transport.ts
3485
+ var LOCAL_RUN_ID = "local";
3486
+ var ITEM_LINE_PREFIX = "[hobots:item]";
3487
+ var SUMMARY_LINE_PREFIX = "[hobots:summary]";
3488
+ var ATTACHMENTS_DISCARDED_WARNING = "imagens e anexos n\xE3o s\xE3o enviados no modo local (descartados)";
3489
+ function unavailable(name) {
3490
+ return new Error(`arquivo "${name}" enviado pela Central n\xE3o est\xE1 dispon\xEDvel no modo local`);
3491
+ }
3492
+ function cell(value) {
3493
+ return (value ?? "").replace(/\r\n|[\t\r\n]/g, " ");
3494
+ }
3495
+ var LocalRunTransport = class {
3496
+ constructor(params = {}, out = (line) => void process.stdout.write(`${line}
3497
+ `)) {
3498
+ this.params = params;
3499
+ this.out = out;
3500
+ }
3501
+ params;
3502
+ out;
3503
+ counts = { succeeded: 0, occurrence: 0, failed: 0 };
3504
+ warnedAttachments = false;
3505
+ /** Status terminal que o pipeline reportou — `undefined` enquanto a run corre (ou se ela nem reportou). */
3506
+ terminalStatus;
3507
+ async updateRun(_runId, update) {
3508
+ if (update.status === "running") return;
3509
+ this.terminalStatus = update.status;
3510
+ if (update.status === "failed" && update.error && update.error.type !== "RunCanceled") {
3511
+ printRunLog("error", `execu\xE7\xE3o falhou \u2014 ${update.error.type ?? "Error"}: ${update.error.message}`);
3512
+ if (update.error.stack) console.error(update.error.stack);
3513
+ }
3514
+ }
3515
+ async updateRunOnce() {
3516
+ }
3517
+ async createItems(_runId, request) {
3518
+ for (const item of request.items) {
3519
+ this.counts[item.status]++;
3520
+ this.out(`${ITEM_LINE_PREFIX} ${item.status} ${cell(item.id)} ${cell(item.message)}`);
3521
+ }
3522
+ return { accepted: request.items.length, duplicates: 0 };
3523
+ }
3524
+ // modo local não tem trabalho anterior: nada a pular
3525
+ async listDoneItems() {
3526
+ return { ids: [], next: null };
3527
+ }
3528
+ /** Totais por status — chame uma vez, no fim da run, antes da linha de conclusão. */
3529
+ printSummary() {
3530
+ const { succeeded, occurrence, failed } = this.counts;
3531
+ this.out(
3532
+ `${SUMMARY_LINE_PREFIX} total=${succeeded + occurrence + failed} ok=${succeeded} avisos=${occurrence} falhas=${failed} pulados=0`
3533
+ );
3534
+ }
3535
+ async createAttachment(_runId, request) {
3536
+ if (!this.warnedAttachments) {
3537
+ this.warnedAttachments = true;
3538
+ printRunLog("warning", ATTACHMENTS_DISCARDED_WARNING);
3539
+ }
3540
+ return { id: request.id, upload_url: "", headers: {}, expires_in: 0 };
3541
+ }
3542
+ async uploadAttachment() {
3543
+ }
3544
+ async confirmAttachment() {
3545
+ }
3546
+ /** Nome do arquivo pelo id (nos params); sem ele, o próprio id. */
3547
+ fileName(fileId) {
3548
+ return collectFileRefs(this.params).find((ref) => ref.id === fileId)?.name ?? fileId;
3549
+ }
3550
+ async requestFileDownload(_runId, fileId) {
3551
+ throw unavailable(this.fileName(fileId));
3552
+ }
3553
+ async fetchFile(url) {
3554
+ throw unavailable(url);
3555
+ }
3556
+ async confirmFileDownload(_runId, fileId) {
3557
+ throw unavailable(this.fileName(fileId));
3558
+ }
3559
+ };
3560
+
3561
+ // src/tasks/local-run.ts
3562
+ var LOCAL_RUN_EXIT_CODES = { succeeded: 0, failed: 1, canceled: 130 };
3563
+ var LOCAL_RUN_CANCEL_GRACE_MS = 4e3;
3564
+ var LOCAL_RUN_BANNER = "modo local \u2014 execu\xE7\xE3o sem a Central; params de HOBOTS_RUN_PARAMS";
3565
+ var LocalRunRunner = class extends SingleRunRunner {
3566
+ constructor(params, handler, deps) {
3567
+ super(handler, deps);
3568
+ this.params = params;
3569
+ }
3570
+ params;
3571
+ canceled = false;
3572
+ startedAt = Date.now();
3573
+ finished = false;
3574
+ run() {
3575
+ this.startedAt = Date.now();
3576
+ return super.run();
3577
+ }
3578
+ opened() {
3579
+ return "run local iniciada (HOBOTS_LOCAL_RUN)";
3580
+ }
3581
+ async openRun() {
3582
+ if (this.abort.signal.aborted) return void 0;
3583
+ return { id: LOCAL_RUN_ID, params: this.params, form: null, source: "local", created_at: nowISO() };
3584
+ }
3585
+ /** SIGINT/SIGTERM: dispara o `ctx.signal` da run em voo — o handler para no próximo ponto de parada. */
3586
+ cancel() {
3587
+ this.canceled = true;
3588
+ this.deps.tracker.singleActive()?.abort();
3589
+ }
3590
+ /** Desfecho da run: cancelada pelo sinal, senão o status terminal reportado (sem reporte = falha). */
3591
+ get outcome() {
3592
+ if (this.canceled) return "canceled";
3593
+ return this.deps.api.terminalStatus ?? "failed";
3594
+ }
3595
+ /**
3596
+ * Fecha o relato no terminal — `[hobots:summary]` e a linha de conclusão — e devolve o
3597
+ * desfecho. Idempotente: o runtime chama ao fim da run ou quando a graça do cancelamento
3598
+ * esgota com o handler ainda rodando.
3599
+ */
3600
+ finish() {
3601
+ const outcome = this.outcome;
3602
+ if (this.finished) return outcome;
3603
+ this.finished = true;
3604
+ this.deps.api.printSummary();
3605
+ const seconds = Math.round((Date.now() - this.startedAt) / 1e3);
3606
+ printRunLog("info", `execu\xE7\xE3o local conclu\xEDda (${outcome}) em ${seconds}s`);
3607
+ return outcome;
3608
+ }
3609
+ };
3610
+
3186
3611
  // src/tasks/registry.ts
3187
3612
  var RunTracker = class {
3188
3613
  constructor(api) {
@@ -3298,26 +3723,10 @@ function hostname() {
3298
3723
  if (!isNode()) return void 0;
3299
3724
  return process.env.HOSTNAME || process.env.COMPUTERNAME || void 0;
3300
3725
  }
3301
- function createRuntime(options, handler) {
3302
- loadDotenv();
3303
- const config = resolveConfig({ clientSecret: options.clientSecret });
3304
- const logger = makeLogger(options.debug ?? false);
3305
- const environment = resolveEnvironmentOption(options.environment);
3306
- const client = new Client(
3307
- {
3308
- environment,
3309
- release: options.release,
3310
- tags: options.tags,
3311
- debug: options.debug,
3312
- captureRunErrors: options.captureUnhandled !== false
3313
- },
3314
- config
3315
- );
3316
- const api = new TasksApi({ baseUrl: config.runsBaseUrl, key: config.key, logger });
3317
- const rt = {
3726
+ function baseRuntime(options, handler, logger, api) {
3727
+ return {
3318
3728
  options: { ...options, clientSecret: void 0 },
3319
- config,
3320
- client,
3729
+ client: void 0,
3321
3730
  logger,
3322
3731
  api,
3323
3732
  handler,
@@ -3327,19 +3736,54 @@ function createRuntime(options, handler) {
3327
3736
  agent: void 0,
3328
3737
  local: void 0,
3329
3738
  attached: void 0,
3739
+ localRun: void 0,
3330
3740
  runId: void 0,
3331
3741
  instanceId: resolveInstanceId(options.instanceId),
3332
- environment,
3742
+ environment: void 0,
3333
3743
  pollIntervalMs: void 0,
3334
3744
  booted: false,
3335
3745
  bootScheduled: false,
3336
3746
  bootPromise: void 0,
3337
- uninstallGlobalHandlers: options.captureUnhandled !== false ? installGlobalHandlers(client) : void 0,
3747
+ uninstallGlobalHandlers: void 0,
3338
3748
  uninstallShutdown: void 0
3339
3749
  };
3750
+ }
3751
+ function createRuntime(options, handler, logger) {
3752
+ const config = resolveConfig({ clientSecret: options.clientSecret });
3753
+ const environment = resolveEnvironmentOption(options.environment);
3754
+ const client = new Client(
3755
+ {
3756
+ environment,
3757
+ release: options.release,
3758
+ tags: options.tags,
3759
+ debug: options.debug,
3760
+ captureRunErrors: options.captureUnhandled !== false
3761
+ },
3762
+ config
3763
+ );
3764
+ const api = new TasksApi({ baseUrl: config.runsBaseUrl, key: config.key, logger });
3765
+ const rt = baseRuntime(options, handler, logger, api);
3766
+ rt.client = client;
3767
+ rt.environment = environment;
3768
+ rt.uninstallGlobalHandlers = options.captureUnhandled !== false ? installGlobalHandlers(client) : void 0;
3340
3769
  logger.debug("inicializado", { endpoint: config.ingestEndpoint });
3341
3770
  return rt;
3342
3771
  }
3772
+ function createLocalRunRuntime(options, handler, logger) {
3773
+ let params;
3774
+ try {
3775
+ params = resolveLocalRunParams();
3776
+ } catch (error) {
3777
+ printRunLog("error", error instanceof Error ? error.message : String(error));
3778
+ process.exit(2);
3779
+ throw error;
3780
+ }
3781
+ const transport = new LocalRunTransport(params);
3782
+ const rt = baseRuntime(options, handler, logger, transport);
3783
+ rt.localRun = new LocalRunRunner(params, handler, { api: transport, client: void 0, logger, tracker: rt.tracker });
3784
+ printRunLog("info", LOCAL_RUN_BANNER);
3785
+ return rt;
3786
+ }
3343
3787
  function validateHandler(handler, fn) {
3344
3788
  if (typeof handler !== "function") {
3345
3789
  throw new Error(`[hobots] ${fn}(): o handler precisa ser uma fun\xE7\xE3o \u2014 ${fn}(async (params) => { ... })`);
@@ -3382,9 +3826,12 @@ function instanceInfo(rt) {
3382
3826
  machine: rt.sampler?.sample()
3383
3827
  };
3384
3828
  }
3829
+ function platformApi(rt) {
3830
+ return rt.api;
3831
+ }
3385
3832
  function ensureAgent(rt) {
3386
3833
  rt.agent ??= new TaskAgent({
3387
- api: rt.api,
3834
+ api: platformApi(rt),
3388
3835
  client: rt.client,
3389
3836
  logger: rt.logger,
3390
3837
  handler: rt.handler,
@@ -3396,7 +3843,7 @@ function ensureAgent(rt) {
3396
3843
  }
3397
3844
  function launchLocal(rt) {
3398
3845
  const runner = new LocalRunner(rt.handler, {
3399
- api: rt.api,
3846
+ api: platformApi(rt),
3400
3847
  client: rt.client,
3401
3848
  logger: rt.logger,
3402
3849
  tracker: rt.tracker,
@@ -3407,7 +3854,7 @@ function launchLocal(rt) {
3407
3854
  }
3408
3855
  function launchAttached(rt, runId) {
3409
3856
  const runner = new AttachedRunner(runId, rt.handler, {
3410
- api: rt.api,
3857
+ api: platformApi(rt),
3411
3858
  client: rt.client,
3412
3859
  logger: rt.logger,
3413
3860
  tracker: rt.tracker,
@@ -3419,7 +3866,43 @@ function launchAttached(rt, runId) {
3419
3866
  if (runtime === rt) void close();
3420
3867
  });
3421
3868
  }
3869
+ function launchLocalRun(rt, runner) {
3870
+ let finished = false;
3871
+ const finish = () => {
3872
+ if (finished) return;
3873
+ finished = true;
3874
+ rt.uninstallShutdown?.();
3875
+ rt.uninstallShutdown = void 0;
3876
+ const code = LOCAL_RUN_EXIT_CODES[runner.finish()];
3877
+ if (runtime === rt) void close(0);
3878
+ process.stdout.write("", () => process.exit(code));
3879
+ };
3880
+ if (rt.options.installExitHandlers !== false) {
3881
+ let signaled = false;
3882
+ const onSignal = (signal) => {
3883
+ if (externalListenerCount(signal) > 0 || signaled) return;
3884
+ signaled = true;
3885
+ runner.cancel();
3886
+ void Promise.race([runner.done, sleep(LOCAL_RUN_CANCEL_GRACE_MS)]).then(finish);
3887
+ };
3888
+ const onInt = markInternal(() => onSignal("SIGINT"));
3889
+ const onTerm = markInternal(() => onSignal("SIGTERM"));
3890
+ process.on("SIGINT", onInt);
3891
+ process.on("SIGTERM", onTerm);
3892
+ rt.uninstallShutdown = () => {
3893
+ process.off("SIGINT", onInt);
3894
+ process.off("SIGTERM", onTerm);
3895
+ };
3896
+ }
3897
+ void runner.run();
3898
+ void runner.done.then(finish);
3899
+ }
3422
3900
  async function boot(rt) {
3901
+ if (rt.localRun) {
3902
+ rt.booted = true;
3903
+ launchLocalRun(rt, rt.localRun);
3904
+ return;
3905
+ }
3423
3906
  if (!rt.instanceId) {
3424
3907
  const id = await fallbackInstanceId();
3425
3908
  if (runtime !== rt) return;
@@ -3456,9 +3939,12 @@ function startAgent(fn, mode, handler, options) {
3456
3939
  }
3457
3940
  const logger = makeLogger(options.debug ?? false);
3458
3941
  const agentHandler = { mode, handler, options: resolveRunOptions(options, mode, logger) };
3459
- const rt = createRuntime(options, agentHandler);
3460
- if (mode === "remote" && options.pollIntervalMs !== void 0) rt.pollIntervalMs = options.pollIntervalMs;
3461
- if (mode === "remote") rt.runId = resolveRunId(options.runId);
3942
+ loadDotenv();
3943
+ const rt = isLocalRun() ? createLocalRunRuntime(options, agentHandler, logger) : createRuntime(options, agentHandler, logger);
3944
+ if (!rt.localRun && mode === "remote") {
3945
+ if (options.pollIntervalMs !== void 0) rt.pollIntervalMs = options.pollIntervalMs;
3946
+ rt.runId = resolveRunId(options.runId);
3947
+ }
3462
3948
  runtime = rt;
3463
3949
  scheduleBoot(rt);
3464
3950
  }
@@ -3481,10 +3967,10 @@ function forwardAttachment(rt, attachment, message, type) {
3481
3967
  const reporter = currentReporter();
3482
3968
  if (!reporter) {
3483
3969
  rt.logger.warn("attachment descartado \u2014 s\xF3 \xE9 suportado dentro de uma execu\xE7\xE3o");
3484
- return;
3970
+ return void 0;
3485
3971
  }
3486
3972
  const withMeta = typeof attachment === "object" && !isImageAttachment(attachment) && "image" in attachment ? image(attachment.image, { caption: attachment.caption, contentType: attachment.contentType }) : attachment;
3487
- reporter.log(toRunLogLevel(type), message, withMeta);
3973
+ return reporter.log(toRunLogLevel(type), message, withMeta);
3488
3974
  }
3489
3975
  function captureException(error, hint) {
3490
3976
  const rt = runtime;
@@ -3495,13 +3981,19 @@ function captureException(error, hint) {
3495
3981
  }
3496
3982
  return void 0;
3497
3983
  }
3984
+ let attachmentId;
3498
3985
  if (hint?.attachment) {
3499
3986
  const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
3500
- forwardAttachment(rt, hint.attachment, message, hint.type ?? "error");
3987
+ attachmentId = forwardAttachment(rt, hint.attachment, message, hint.type ?? "error");
3988
+ }
3989
+ if (!rt.client) {
3990
+ rt.logger.debug("captureException ignorado no modo local (sem Central)");
3991
+ return void 0;
3501
3992
  }
3502
3993
  const runId = currentReporter()?.runId;
3503
3994
  const withRun = runId ? { ...hint, tags: { run_id: runId, ...hint?.tags } } : hint;
3504
- return rt.client.captureException(error, withRun);
3995
+ const contexts = runId && attachmentId ? { run: { id: runId, attachments: [attachmentId] } } : void 0;
3996
+ return rt.client.captureException(error, withRun, contexts);
3505
3997
  }
3506
3998
  async function close(timeoutMs = 3e4) {
3507
3999
  const rt = runtime;
@@ -3511,6 +4003,7 @@ async function close(timeoutMs = 3e4) {
3511
4003
  if (rt.agent) await rt.agent.stop(0);
3512
4004
  rt.local?.stop();
3513
4005
  rt.attached?.stop();
4006
+ rt.localRun?.stop();
3514
4007
  const idle = await rt.tracker.waitIdle(timeoutMs);
3515
4008
  if (!idle) {
3516
4009
  rt.logger.warn(`${rt.tracker.activeCount} run(s) ainda em execu\xE7\xE3o ap\xF3s ${timeoutMs}ms \u2014 abandonando a espera`);
@@ -3519,7 +4012,7 @@ async function close(timeoutMs = 3e4) {
3519
4012
  rt.heartbeat?.stop();
3520
4013
  rt.uninstallShutdown?.();
3521
4014
  rt.uninstallGlobalHandlers?.();
3522
- const flushed = await rt.client.close(timeoutMs);
4015
+ const flushed = rt.client ? await rt.client.close(timeoutMs) : true;
3523
4016
  return idle && flushed;
3524
4017
  }
3525
4018
 
@@ -3548,11 +4041,19 @@ var noop = () => void 0;
3548
4041
  var fallbackEachSink = {
3549
4042
  report: noop,
3550
4043
  startStep: () => void 0,
4044
+ stepScope: (_step, fn) => fn(),
3551
4045
  declareTotal: noop,
3552
4046
  advance: noop,
3553
4047
  checkCanceled: noop,
3554
4048
  isAborted: () => false,
3555
- logError: (message) => printRunLog("error", message)
4049
+ /* v8 ignore start -- fora de uma run não há consulta de concluídos, logo nunca há item pulado para anunciar */
4050
+ logInfo: (message) => printRunLog("info", message),
4051
+ /* v8 ignore stop */
4052
+ logWarning: (message) => printRunLog("warning", message),
4053
+ logError: (message) => printRunLog("error", message),
4054
+ sleep,
4055
+ doneItemIds: () => Promise.resolve(void 0),
4056
+ flush: () => Promise.resolve()
3556
4057
  };
3557
4058
  var fallbackItems = {
3558
4059
  add: noop,
@@ -3571,7 +4072,6 @@ var fallbackFiles = {
3571
4072
  read: outsideRunFiles
3572
4073
  };
3573
4074
  var neverAborts = new AbortController().signal;
3574
- var detachedTransaction;
3575
4075
  function resolve(member) {
3576
4076
  const reporter = currentReporter();
3577
4077
  if (reporter) return reporter.context;
@@ -3592,8 +4092,12 @@ var hobots = Object.defineProperties({}, {
3592
4092
  items: getter("items", () => fallbackItems),
3593
4093
  progress: getter("progress", () => fallbackProgress),
3594
4094
  files: getter("files", () => fallbackFiles),
3595
- transaction: getter("transaction", () => detachedTransaction ??= new Transaction(void 0, "hobots.detached")),
3596
4095
  signal: getter("signal", () => neverAborts),
4096
+ step: method((name, fn, options) => {
4097
+ const ctx = resolve("step");
4098
+ if (ctx) return ctx.step(name, fn, options);
4099
+ return new Transaction(void 0, "hobots.detached").step(name, fn, options);
4100
+ }),
3597
4101
  throwIfCanceled: method(() => resolve("throwIfCanceled")?.throwIfCanceled()),
3598
4102
  sleep: method((ms) => resolve("sleep")?.sleep(ms) ?? sleep(ms)),
3599
4103
  flush: method((timeoutMs) => resolve("flush")?.flush(timeoutMs) ?? Promise.resolve()),
@@ -3631,6 +4135,7 @@ var isFileRef2 = hobots.isFileRef;
3631
4135
  loadDotenv();
3632
4136
  var src_default = hobots;
3633
4137
  export {
4138
+ Occurrence,
3634
4139
  captureException2 as captureException,
3635
4140
  close2 as close,
3636
4141
  src_default as default,