hobots 1.3.3 → 1.3.5

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/README.md CHANGED
@@ -48,7 +48,7 @@ Hobots.init({
48
48
  });
49
49
 
50
50
  Hobots.register('processar-nfe', async (params, ctx) => {
51
- ctx.log('info', 'começando…');
51
+ ctx.log.info('começando…');
52
52
  return { processadas: 12 };
53
53
  });
54
54
 
@@ -120,12 +120,12 @@ try {
120
120
  throw error;
121
121
  }
122
122
 
123
- Hobots.captureMessage('Lote processado com sucesso', 'info');
123
+ Hobots.captureMessage('Lote processado com sucesso', 'success');
124
124
  ```
125
125
 
126
126
  Ambas retornam o `event_id` (ou `undefined` se não inicializado). O hint aceita `level`, `tags`, `extra` e `fingerprint`. Erros **não tratados** são capturados e flushados automaticamente antes do processo sair — desligue com `captureUnhandled: false`.
127
127
 
128
- `Severity = 'fatal' | 'error' | 'warning' | 'info' | 'debug'`.
128
+ `Severity = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'success'`. Use `'success'` para reportar itens concluídos com êxito — contam como eventos, mas **não** viram issue nem disparam alertas.
129
129
 
130
130
  ### Breadcrumbs
131
131
 
@@ -235,9 +235,9 @@ Hobots.init({
235
235
  });
236
236
 
237
237
  Hobots.register('processar-nfe', async (params, ctx) => {
238
- ctx.log('info', `processando lote ${params.lote}…`);
238
+ ctx.log.info(`processando lote ${params.lote}…`);
239
239
  if (ctx.signal.aborted) return; // cancelamento cooperativo
240
- ctx.log('info', '12 notas processadas');
240
+ ctx.log.info('12 notas processadas');
241
241
  });
242
242
 
243
243
  Hobots.start(); // começa o polling e MANTÉM O PROCESSO VIVO
@@ -277,7 +277,7 @@ interface RunContext {
277
277
  ```
278
278
 
279
279
  - **`params`** — os dados do formulário configurado no app (JSON validado). Tipável: `Hobots.register<{ lote: number }>(...)`.
280
- - **`ctx.log(level, message)`** — logs enviados **incrementalmente** ao app (PATCHes com throttle), visíveis na run quase em tempo real.
280
+ - **`ctx.log.<level>(message)`** — logs enviados **incrementalmente** ao app (PATCHes com throttle), visíveis na run quase em tempo real.
281
281
  - **`ctx.signal`** — `AbortSignal` que dispara quando a run é **cancelada no app**, em **timeout** ou no **shutdown**.
282
282
  - **Retorno** — **ignorado**: o SDK não captura nem envia o valor retornado pelo handler (resultados podem conter dados sensíveis e não são coletados).
283
283
  - **Erro lançado** — a run é reportada como `failed` (mensagem cap 10k, stack 20k).
@@ -292,7 +292,7 @@ Uma run cancelada no app **não mata** o handler — o SDK apenas dispara `ctx.s
292
292
  Hobots.register('processar-nfe', async (params, ctx) => {
293
293
  for (const nota of params.notas) {
294
294
  if (ctx.signal.aborted) {
295
- ctx.log('warning', 'cancelamento detectado — parando');
295
+ ctx.log.warn('cancelamento detectado — parando');
296
296
  return;
297
297
  }
298
298
  await processar(nota);
package/dist/index.cjs CHANGED
@@ -111,7 +111,7 @@ function parseStacktrace(stack) {
111
111
 
112
112
  // src/utils.ts
113
113
  var SDK_NAME = "hobots";
114
- var SDK_VERSION = true ? "1.3.2" : "0.0.0-dev";
114
+ var SDK_VERSION = true ? "1.3.4" : "0.0.0-dev";
115
115
  function isNode() {
116
116
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
117
117
  }
@@ -433,28 +433,61 @@ var Heartbeat = class {
433
433
  }
434
434
  };
435
435
 
436
+ // src/internal-handlers.ts
437
+ var INTERNAL = /* @__PURE__ */ Symbol.for("hobots.sdk.internalListener");
438
+ function markInternal(listener) {
439
+ listener[INTERNAL] = true;
440
+ return listener;
441
+ }
442
+ function externalListenerCount(event) {
443
+ const listeners = process.listeners(event);
444
+ return listeners.filter((listener) => !listener[INTERNAL]).length;
445
+ }
446
+ var emergencyTasks = [];
447
+ function addEmergencyTask(task) {
448
+ emergencyTasks.push(task);
449
+ }
450
+ async function awaitEmergencyTasks(capMs) {
451
+ if (emergencyTasks.length === 0) return;
452
+ await Promise.race([Promise.allSettled(emergencyTasks), sleep(capMs)]);
453
+ }
454
+
436
455
  // src/instrument.ts
456
+ var EMERGENCY_REPORT_CAP_MS = 3e3;
437
457
  var installed = false;
458
+ function hasInstalledGlobalHandlers() {
459
+ return installed;
460
+ }
438
461
  function installGlobalHandlers(client2, getScope) {
439
462
  if (installed) return;
440
463
  installed = true;
441
464
  if (isNode()) {
442
- process.on("uncaughtException", (error) => {
443
- client2.captureException(error, { level: "fatal", tags: { mechanism: "uncaughtException" } }, getScope());
444
- void client2.flush(2e3).then(() => {
445
- if (process.listenerCount("uncaughtException") <= 1) {
446
- console.error(error);
447
- process.exit(1);
448
- }
449
- });
450
- });
451
- process.on("unhandledRejection", (reason) => {
452
- client2.captureException(reason, { level: "error", tags: { mechanism: "unhandledRejection" } }, getScope());
453
- void client2.flush(2e3);
454
- });
455
- process.on("beforeExit", () => {
456
- void client2.flush(2e3);
457
- });
465
+ process.on(
466
+ "uncaughtException",
467
+ markInternal((error) => {
468
+ client2.captureException(error, { level: "fatal", tags: { mechanism: "uncaughtException" } }, getScope());
469
+ void client2.flush(2e3).then(async () => {
470
+ if (externalListenerCount("uncaughtException") === 0) {
471
+ await awaitEmergencyTasks(EMERGENCY_REPORT_CAP_MS);
472
+ console.error(error);
473
+ process.exit(1);
474
+ }
475
+ });
476
+ })
477
+ );
478
+ process.on(
479
+ "unhandledRejection",
480
+ markInternal((reason) => {
481
+ client2.captureException(reason, { level: "error", tags: { mechanism: "unhandledRejection" } }, getScope());
482
+ void client2.flush(2e3);
483
+ })
484
+ );
485
+ process.on(
486
+ "beforeExit",
487
+ markInternal(() => {
488
+ void client2.flush(2e3);
489
+ })
490
+ );
458
491
  return;
459
492
  }
460
493
  if (typeof window !== "undefined") {
@@ -750,12 +783,46 @@ var ManagerApi = class {
750
783
  await sleep(delay);
751
784
  }
752
785
  }
786
+ /**
787
+ * Um único PATCH com timeout curto — exit path (o processo está morrendo, o retry
788
+ * ladder do updateRun é proibido aqui). Status HTTP é ignorado: 409/404 significa
789
+ * que a run já foi cancelada/finalizada, o que é aceitável nesse caminho.
790
+ */
791
+ async updateRunOnce(runId, update, timeoutMs = 2e3) {
792
+ await fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}`, {
793
+ method: "PATCH",
794
+ headers: this.headers(),
795
+ body: JSON.stringify(update),
796
+ signal: AbortSignal.timeout(timeoutMs)
797
+ });
798
+ }
753
799
  };
754
800
 
755
801
  // src/manager/context.ts
802
+ function runCanceledError() {
803
+ return Object.assign(new Error("execu\xE7\xE3o cancelada"), { name: "RunCanceled" });
804
+ }
805
+ function cancelableSleep(ms, signal) {
806
+ return new Promise((resolve, reject) => {
807
+ if (signal.aborted) {
808
+ reject(runCanceledError());
809
+ return;
810
+ }
811
+ const onAbort = () => {
812
+ clearTimeout(timer);
813
+ reject(runCanceledError());
814
+ };
815
+ const timer = setTimeout(() => {
816
+ signal.removeEventListener("abort", onAbort);
817
+ resolve();
818
+ }, ms);
819
+ signal.addEventListener("abort", onAbort, { once: true });
820
+ });
821
+ }
756
822
  var DEFAULT_LOG_FLUSH_INTERVAL_MS = 2e3;
757
823
  var DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5e3;
758
824
  var MIN_CANCEL_CHECK_INTERVAL_MS = 1e3;
825
+ var MAX_CANCEL_CHECK_INTERVAL_MS = 15e3;
759
826
  var MAX_LOG_MESSAGE_CHARS = 1e4;
760
827
  var MAX_LOGS_PER_RUN = 500;
761
828
  var MAX_LOGS_PER_PATCH = 100;
@@ -766,15 +833,26 @@ var RunReporter = class {
766
833
  this.runId = runId;
767
834
  this.logger = logger;
768
835
  this.flushIntervalMs = flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
769
- this.cancelCheckIntervalMs = Math.max(
770
- MIN_CANCEL_CHECK_INTERVAL_MS,
771
- cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS
836
+ this.cancelCheckIntervalMs = Math.min(
837
+ MAX_CANCEL_CHECK_INTERVAL_MS,
838
+ Math.max(MIN_CANCEL_CHECK_INTERVAL_MS, cancelCheckIntervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS)
772
839
  );
840
+ const log = {
841
+ debug: (message) => this.log("debug", message),
842
+ info: (message) => this.log("info", message),
843
+ warn: (message) => this.log("warning", message),
844
+ error: (message) => this.log("error", message)
845
+ };
846
+ const signal = this.abortController.signal;
773
847
  this.context = {
774
848
  runId,
775
849
  task,
776
- log: (level, message) => this.log(level, message),
777
- signal: this.abortController.signal
850
+ log,
851
+ signal,
852
+ throwIfCanceled: () => {
853
+ if (signal.aborted) throw runCanceledError();
854
+ },
855
+ sleep: (ms) => cancelableSleep(ms, signal)
778
856
  };
779
857
  }
780
858
  context;
@@ -797,6 +875,17 @@ var RunReporter = class {
797
875
  this.stopCancelWatch();
798
876
  if (!this.abortController.signal.aborted) this.abortController.abort();
799
877
  }
878
+ /**
879
+ * Mata o reporter de vez: aborta o signal e suprime todo reporte futuro.
880
+ * Usado no exit path — depois que o agente reportou AgentTerminated por fora
881
+ * da fila, um handler zumbi não pode reenviar status por cima.
882
+ */
883
+ kill() {
884
+ this.abort();
885
+ this.dead = true;
886
+ this.clearFlushTimer();
887
+ this.buffer = [];
888
+ }
800
889
  log(level, message) {
801
890
  if (this.dead) return;
802
891
  if (this.loggedCount >= MAX_LOGS_PER_RUN) {
@@ -912,6 +1001,50 @@ var RunReporter = class {
912
1001
  }
913
1002
  };
914
1003
 
1004
+ // src/manager/shutdown.ts
1005
+ var WATCHED_SIGNALS = ["SIGINT", "SIGTERM"];
1006
+ function installShutdownHandlers(agent2) {
1007
+ if (!isNode()) return () => {
1008
+ };
1009
+ let signaled = false;
1010
+ const signalHandlers = /* @__PURE__ */ new Map();
1011
+ for (const signal of WATCHED_SIGNALS) {
1012
+ const handler = markInternal(() => {
1013
+ if (externalListenerCount(signal) > 0) return;
1014
+ if (signaled) return;
1015
+ signaled = true;
1016
+ const reraise = () => {
1017
+ process.removeListener(signal, handler);
1018
+ process.kill(process.pid, signal);
1019
+ };
1020
+ if (agent2.activeCount === 0) {
1021
+ reraise();
1022
+ return;
1023
+ }
1024
+ const report = agent2.reportAgentTerminated(`o processo do agente recebeu ${signal} durante a execu\xE7\xE3o`);
1025
+ addEmergencyTask(report);
1026
+ void report.finally(reraise);
1027
+ });
1028
+ process.on(signal, handler);
1029
+ signalHandlers.set(signal, handler);
1030
+ }
1031
+ const onFatal = markInternal((error) => {
1032
+ const report = agent2.activeCount > 0 ? agent2.reportAgentTerminated("o processo do agente morreu com um erro n\xE3o tratado durante a execu\xE7\xE3o") : Promise.resolve();
1033
+ addEmergencyTask(report);
1034
+ if (!hasInstalledGlobalHandlers() && externalListenerCount("uncaughtException") === 0) {
1035
+ void report.finally(() => {
1036
+ console.error(error);
1037
+ process.exit(1);
1038
+ });
1039
+ }
1040
+ });
1041
+ process.on("uncaughtException", onFatal);
1042
+ return () => {
1043
+ for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
1044
+ process.removeListener("uncaughtException", onFatal);
1045
+ };
1046
+ }
1047
+
915
1048
  // src/manager/agent.ts
916
1049
  var DEFAULT_POLL_INTERVAL_MS = 5e3;
917
1050
  var MIN_POLL_INTERVAL_MS = 1e3;
@@ -952,6 +1085,7 @@ var ManagerAgent = class {
952
1085
  consecutiveAuthFailures = 0;
953
1086
  activeRuns = /* @__PURE__ */ new Set();
954
1087
  reporters = /* @__PURE__ */ new Set();
1088
+ uninstallShutdown;
955
1089
  get activeCount() {
956
1090
  return this.activeRuns.size;
957
1091
  }
@@ -964,6 +1098,9 @@ var ManagerAgent = class {
964
1098
  throw new Error("[hobots-manager] nenhuma task registrada \u2014 chame register() antes de start()");
965
1099
  }
966
1100
  this.state = "started";
1101
+ if (this.options.installExitHandlers !== false && !this.uninstallShutdown) {
1102
+ this.uninstallShutdown = installShutdownHandlers(this);
1103
+ }
967
1104
  this.logger.debug(`polling iniciado (tasks: ${[...this.registry.keys()].join(", ")})`);
968
1105
  this.schedule(0);
969
1106
  }
@@ -980,11 +1117,32 @@ var ManagerAgent = class {
980
1117
  const finished = timeoutMs > 0 ? await Promise.race([all, sleep(timeoutMs).then(() => false)]) : false;
981
1118
  if (!finished && this.activeRuns.size > 0) {
982
1119
  this.logger.warn(`${this.activeRuns.size} run(s) ainda em execu\xE7\xE3o ap\xF3s ${timeoutMs}ms \u2014 abandonando a espera`);
983
- for (const reporter of this.reporters) reporter.abort();
1120
+ await this.reportAgentTerminated("agente encerrado durante a execu\xE7\xE3o (shutdown excedeu o tempo de espera)");
984
1121
  }
1122
+ this.uninstallShutdown?.();
1123
+ this.uninstallShutdown = void 0;
985
1124
  this.state = "idle";
986
1125
  return finished;
987
1126
  }
1127
+ /**
1128
+ * Best-effort do exit path: reporta 'failed'/AgentTerminated para as runs ativas
1129
+ * antes do processo morrer, fora da fila do reporter e com timeout curto. Runs já
1130
+ * canceladas no app (reporter morto) são puladas; erros de rede são engolidos —
1131
+ * o sweep server-side é o backstop.
1132
+ */
1133
+ async reportAgentTerminated(message, timeoutMs = 2500) {
1134
+ const pending = [];
1135
+ for (const reporter of this.reporters) {
1136
+ if (reporter.isDead) continue;
1137
+ reporter.kill();
1138
+ pending.push(
1139
+ this.api.updateRunOnce(reporter.context.runId, { status: "failed", error: { type: "AgentTerminated", message } }, timeoutMs).catch(() => {
1140
+ })
1141
+ );
1142
+ }
1143
+ if (pending.length === 0) return;
1144
+ await Promise.race([Promise.allSettled(pending), sleep(timeoutMs)]);
1145
+ }
988
1146
  /** Timer intencionalmente NÃO unref'd: o agente é a razão do processo existir. */
989
1147
  schedule(delayMs) {
990
1148
  if (this.state !== "started" || this.timer) return;